From 6b2b2da230dd631eb50bdd826d2a3d31d25f69fd Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:08 -0400 Subject: [PATCH 01/15] Scope embed sessions to their embed --- .changeset/tidy-embed-session-scope.md | 5 + packages/core/src/action.ts | 1 + .../core/src/db/schema-snapshot.generated.ts | 3 +- .../core/src/db/store-migrations.lock.json | 4 + packages/core/src/deploy/build.spec.ts | 1 + .../build-server.call-parity.spec.ts.snap | 70 -- .../src/mcp/build-server.call-parity.spec.ts | 35 +- packages/core/src/mcp/build-server.ts | 218 +++-- .../core/src/mcp/builtin-cross-app.spec.ts | 33 +- packages/core/src/mcp/builtin-tools.ts | 48 +- .../core/src/mcp/connector-catalog.spec.ts | 5 - packages/core/src/mcp/direct-action-call.ts | 124 ++- packages/core/src/mcp/embed-app.spec.ts | 268 +++--- packages/core/src/mcp/embed-app.ts | 17 +- packages/core/src/mcp/server.spec.ts | 826 +++++++++++++++- packages/core/src/server/action-approval.ts | 16 +- .../core/src/server/action-routes.spec.ts | 649 ++++++++++--- packages/core/src/server/action-routes.ts | 102 +- packages/core/src/server/auth.spec.ts | 276 ++++++ packages/core/src/server/auth.ts | 47 + packages/core/src/server/embed-route.spec.ts | 5 +- packages/core/src/server/embed-route.ts | 1 + .../core/src/server/embed-session.spec.ts | 886 ++++++++++++++++-- packages/core/src/server/embed-session.ts | 368 +++++--- .../core/src/server/request-origin.spec.ts | 12 +- packages/core/src/server/request-origin.ts | 2 +- .../src/server/ui-action-capability.spec.ts | 318 ++++++- .../core/src/server/ui-action-capability.ts | 16 +- templates/content/e2e/helpers.ts | 17 +- .../design/actions/open-visual-edit.spec.ts | 20 + templates/design/actions/open-visual-edit.ts | 3 +- ...returns-an-app-link-when-no-browser-han.md | 6 + .../design/e2e/public-visual-edit.spec.ts | 7 +- .../e2e/concurrent-edit-durability.spec.ts | 7 +- templates/plan/e2e/guest-mode.guest.spec.ts | 7 +- 35 files changed, 3623 insertions(+), 800 deletions(-) create mode 100644 .changeset/tidy-embed-session-scope.md create mode 100644 templates/design/changelog/2026-10-22-local-visual-editing-returns-an-app-link-when-no-browser-han.md diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md new file mode 100644 index 00000000000..75aa93a4192 --- /dev/null +++ b/.changeset/tidy-embed-session-scope.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Scope MCP Apps embed sessions to their resource and declared actions, and require browser cookie sessions for UI capabilities and approvals. diff --git a/packages/core/src/action.ts b/packages/core/src/action.ts index 42835a324fe..720bf4cb0d8 100644 --- a/packages/core/src/action.ts +++ b/packages/core/src/action.ts @@ -834,6 +834,7 @@ export interface ActionMcpAppResourceConfig { title?: string; description?: string; html: string | ActionMcpAppHtmlBuilder; + requiresEmbedSession?: boolean; mimeType?: typeof MCP_APP_MIME_TYPE; _meta?: Record; csp?: ActionMcpAppCsp | ActionMcpAppCspBuilder; diff --git a/packages/core/src/db/schema-snapshot.generated.ts b/packages/core/src/db/schema-snapshot.generated.ts index 9887cb5bdc6..12ae64f9da9 100644 --- a/packages/core/src/db/schema-snapshot.generated.ts +++ b/packages/core/src/db/schema-snapshot.generated.ts @@ -39,7 +39,7 @@ export const SCHEMA_SNAPSHOT: SchemaSnapshot = { 'CREATE TABLE "agent_native_browser_session_requests" (\n "owner_email" text NOT NULL,\n "session_id" text NOT NULL,\n "request_id" text NOT NULL,\n "type" text NOT NULL,\n "name" text,\n "command" text,\n "payload_json" text,\n "status" text NOT NULL,\n "created_at" bigint NOT NULL,\n "claimed_at" bigint,\n "completed_at" bigint,\n "expires_at" bigint NOT NULL,\n "result_json" text,\n "error" text\n);', 'CREATE TABLE "agent_native_browser_sessions" (\n "owner_email" text NOT NULL,\n "session_id" text NOT NULL,\n "label" text,\n "url" text,\n "session_json" text NOT NULL,\n "context_json" text,\n "actions_json" text,\n "connected_at" bigint NOT NULL,\n "last_seen_at" bigint NOT NULL,\n "expires_at" bigint NOT NULL\n);', 'CREATE TABLE "agent_native_embed_session_revocations" (\n "owner_hash" text NOT NULL,\n "revoked_before" bigint NOT NULL\n);', - 'CREATE TABLE "agent_native_embed_tickets" (\n "ticket_hash" text NOT NULL,\n "owner_email" text NOT NULL,\n "org_id" text,\n "target_path" text NOT NULL,\n "scope" text,\n "created_at" bigint NOT NULL,\n "expires_at" bigint NOT NULL,\n "consumed_at" bigint,\n "renewal_expires_at" bigint,\n "session_active_until" bigint\n);', + 'CREATE TABLE "agent_native_embed_tickets" (\n "ticket_hash" text NOT NULL,\n "owner_email" text NOT NULL,\n "org_id" text,\n "target_path" text NOT NULL,\n "scope" text,\n "created_at" bigint NOT NULL,\n "expires_at" bigint NOT NULL,\n "consumed_at" bigint,\n "renewal_expires_at" bigint,\n "session_active_until" bigint,\n "audience_host" text,\n "audience_base_path" text\n);', 'CREATE TABLE "agent_resource_versions" (\n "id" text NOT NULL,\n "resource_type" text NOT NULL,\n "resource_id" text NOT NULL,\n "version_number" bigint NOT NULL,\n "created_at" text NOT NULL,\n "created_by" text,\n "actor_kind" text DEFAULT \'human\'::text NOT NULL,\n "owner_email" text,\n "org_id" text,\n "visibility" text DEFAULT \'private\'::text NOT NULL,\n "title" text,\n "summary" text,\n "snapshot_json" text NOT NULL,\n "metadata_json" text\n);', 'CREATE TABLE "agent_review_comment_reactions" (\n "comment_id" text NOT NULL,\n "actor_email" text NOT NULL,\n "reaction" text NOT NULL,\n "created_at" text NOT NULL\n);', 'CREATE TABLE "agent_review_comments" (\n "id" text NOT NULL,\n "resource_type" text NOT NULL,\n "resource_id" text NOT NULL,\n "thread_id" text NOT NULL,\n "parent_comment_id" text,\n "target_id" text,\n "kind" text DEFAULT \'comment\'::text NOT NULL,\n "status" text DEFAULT \'open\'::text NOT NULL,\n "anchor_json" text,\n "body" text NOT NULL,\n "author_email" text,\n "author_name" text,\n "created_by" text DEFAULT \'human\'::text NOT NULL,\n "resolution_target" text,\n "reply_route_target" text DEFAULT \'legacy\'::text,\n "notification_completed_at" text,\n "mentions_json" text,\n "owner_email" text,\n "org_id" text,\n "visibility" text DEFAULT \'private\'::text NOT NULL,\n "resolved_by" text,\n "resolved_at" text,\n "consumed_at" text,\n "deleted_by" text,\n "deleted_at" text,\n "created_at" text NOT NULL,\n "updated_at" text NOT NULL,\n "metadata_json" text\n);', @@ -1013,6 +1013,7 @@ export const SCHEMA_SNAPSHOT: SchemaSnapshot = { ["email_log", "columns-and-indexes", null], ["email_log", "widen-bigint", null], ["embed_sessions", "baseline", null], + ["embed_sessions", "ticket-audience", null], ["extension_slots", "baseline", "2324076d4536f1ed"], ["extensions", "baseline", null], ["extensions", "drop-legacy-tool-data-index", "4218fe7cb07d6a1c"], diff --git a/packages/core/src/db/store-migrations.lock.json b/packages/core/src/db/store-migrations.lock.json index 58bc19cb83d..8036d21bd0d 100644 --- a/packages/core/src/db/store-migrations.lock.json +++ b/packages/core/src/db/store-migrations.lock.json @@ -461,6 +461,10 @@ { "name": "baseline", "checksum": null + }, + { + "name": "ticket-audience", + "checksum": null } ], "extension_slots": [ diff --git a/packages/core/src/deploy/build.spec.ts b/packages/core/src/deploy/build.spec.ts index 461bfd48fcc..4f636151373 100644 --- a/packages/core/src/deploy/build.spec.ts +++ b/packages/core/src/deploy/build.spec.ts @@ -2407,6 +2407,7 @@ export default { const capability = await worker.fetch( new Request("https://app.test/_agent-native/ui-capability", { method: "GET", + headers: { origin: "https://app.test" }, }), {}, {}, diff --git a/packages/core/src/mcp/__snapshots__/build-server.call-parity.spec.ts.snap b/packages/core/src/mcp/__snapshots__/build-server.call-parity.spec.ts.snap index 75cb42b8f46..beebdf03503 100644 --- a/packages/core/src/mcp/__snapshots__/build-server.call-parity.spec.ts.snap +++ b/packages/core/src/mcp/__snapshots__/build-server.call-parity.spec.ts.snap @@ -1423,13 +1423,6 @@ exports[`direct tools/call wire output > compact catalog, read-only OAuth scope: "userEmail": "oauth@example.com", }, }, - { - "embedTicket": { - "ownerEmail": "oauth@example.com", - "scope": null, - "targetPath": "/inbox/thread-1?__an_mcp_chat_bridge=1", - }, - }, { "mcpToolCall": { "durationMs": "", @@ -1448,16 +1441,6 @@ exports[`direct tools/call wire output > compact catalog, read-only OAuth scope: "jsonrpc": "2.0", "result": { "_meta": { - "agent-native/embedStart": { - "expiresAt": 1735689600000, - "startUrl": "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-ticket&__an_mcp_chat_bridge=1", - }, - "agent-native/openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-1&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-1", - "webUrl": "https://mail.agent-native.com/inbox/thread-1", - }, "io.modelcontextprotocol/serverInfo": { "description": "Mail app", "name": "agent-native-mail", @@ -1478,12 +1461,6 @@ exports[`direct tools/call wire output > compact catalog, read-only OAuth scope: "structuredContent": { "app": "mail", "embed": true, - "openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-1&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-1", - "webUrl": "https://mail.agent-native.com/inbox/thread-1", - }, "title": "Thread", "url": "/inbox/thread-1", }, @@ -1986,14 +1963,6 @@ exports[`direct tools/call wire output > directory catalog: projected results, e "toolName": "dir-proxy-error", }, }, - { - "embedTicket": { - "ownerEmail": "oauth@example.com", - "revocationAnchorCreatedAtMs": 1767225600000, - "scope": "capability:mcp-directory-widget-read:eyJ2ZXJzaW9uIjoxLCJhcHBJZCI6Im1haWwiLCJyZXNvdXJjZVVyaSI6InVpOi8vbWFpbC9zaGVsbC12NjkiLCJyZXNvdXJjZUlkcyI6eyJ0aHJlYWQiOiJ0aHJlYWQtMSJ9LCJhY3Rpb25Bcmd1bWVudHMiOnsiZGlyLXJlYWQiOnsiaWQiOiJ0aHJlYWQtMSJ9fX0", - "targetPath": "/inbox?__an_mcp_chat_bridge=1", - }, - }, { "mcpToolCall": { "durationMs": "", @@ -3056,13 +3025,6 @@ exports[`direct tools/call wire output > full catalog, OAuth user: embed-widget "userEmail": "oauth@example.com", }, }, - { - "embedTicket": { - "ownerEmail": "oauth@example.com", - "scope": null, - "targetPath": "/inbox/thread-1?__an_mcp_chat_bridge=1", - }, - }, { "mcpToolCall": { "durationMs": "", @@ -3081,16 +3043,6 @@ exports[`direct tools/call wire output > full catalog, OAuth user: embed-widget "jsonrpc": "2.0", "result": { "_meta": { - "agent-native/embedStart": { - "expiresAt": 1735689600000, - "startUrl": "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-ticket&__an_mcp_chat_bridge=1", - }, - "agent-native/openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-1&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-1", - "webUrl": "https://mail.agent-native.com/inbox/thread-1", - }, "io.modelcontextprotocol/serverInfo": { "description": "Mail app", "name": "agent-native-mail", @@ -3111,12 +3063,6 @@ exports[`direct tools/call wire output > full catalog, OAuth user: embed-widget "structuredContent": { "app": "mail", "embed": true, - "openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-1&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-1", - "webUrl": "https://mail.agent-native.com/inbox/thread-1", - }, "title": "Thread", "url": "/inbox/thread-1", }, @@ -3229,15 +3175,6 @@ exports[`direct tools/call wire output > full catalog, OAuth user: embed-widget- "jsonrpc": "2.0", "result": { "_meta": { - "agent-native/embedStart": { - "startUrl": "https://mail.agent-native.com/_agent-native/embed/start?ticket=leaked-ticket&__an_mcp_chat_bridge=1", - }, - "agent-native/openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-2&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-2", - "webUrl": "https://mail.agent-native.com/inbox/thread-2", - }, "io.modelcontextprotocol/serverInfo": { "description": "Mail app", "name": "agent-native-mail", @@ -3259,13 +3196,6 @@ exports[`direct tools/call wire output > full catalog, OAuth user: embed-widget- "app": "mail", "deepLinkUrl": "/inbox/thread-2", "message": "Draft ready", - "openLink": { - "desktopUrl": "agentnative://open?app=mail&view=&to=%2Finbox%2Fthread-2&agentSidebar=closed", - "label": "Open mail", - "vscodeUrl": "vscode://builder.agent-native/open?url=https%3A%2F%2Fmail.agent-native.com%2Finbox%2Fthread-2", - "webUrl": "https://mail.agent-native.com/inbox/thread-2", - }, - "url": "https://mail.agent-native.com/inbox/thread-2", }, }, }, diff --git a/packages/core/src/mcp/build-server.call-parity.spec.ts b/packages/core/src/mcp/build-server.call-parity.spec.ts index 688e8df0dd2..55af2b6b5b5 100644 --- a/packages/core/src/mcp/build-server.call-parity.spec.ts +++ b/packages/core/src/mcp/build-server.call-parity.spec.ts @@ -1325,7 +1325,40 @@ describe("direct tools/call embed credential redaction", () => { }; const leakConfig = { ...config, - actions: { ...leakActions, ...contextActions, ...unreadableActions }, + actions: { + ...leakActions, + ...contextActions, + ...unreadableActions, + "fixture-widget-read": act({ + description: "Read the scoped widget fixture.", + readOnly: true, + requiresAuth: true, + http: { method: "GET" }, + parameters: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + run: async (args: Record) => ({ id: args.id }), + }), + }, + directoryProfile: { + connectorCatalog: [ + "leak-in-widget", + "over-cap-widget-write", + "fixture-widget-read", + ], + widgetTargets: Object.fromEntries( + ["leak-in-widget", "over-cap-widget-write"].map((name) => [ + name, + () => ({ + targetPath: "/inbox/thread-1", + resourceIds: { threadId: "thread-1" }, + }), + ]), + ), + widgetReadActionArguments: { "fixture-widget-read": { id: "threadId" } }, + }, }; async function call(name: string) { diff --git a/packages/core/src/mcp/build-server.ts b/packages/core/src/mcp/build-server.ts index ce4ab533548..e0de26ac756 100644 --- a/packages/core/src/mcp/build-server.ts +++ b/packages/core/src/mcp/build-server.ts @@ -420,8 +420,8 @@ function scopeToolSearchToAdvertised( function withoutExternalOptOuts( config: MCPConfig, actions: Record, + denyNames = externalAgentDenySet(config), ): Record { - const denyNames = externalAgentDenySet(config); return Object.fromEntries( Object.entries(actions).filter( ([name, entry]) => @@ -1274,7 +1274,6 @@ export function mcpDirectoryWidgetSourceMeta( export async function withServerMintedMcpAppEmbedStart( result: unknown, meta: MCPRequestMeta | undefined, - directoryLinkUrl?: string, directoryWidget?: { targetPath: string; capability: @@ -1297,75 +1296,28 @@ export async function withServerMintedMcpAppEmbedStart( return result; } - const out = result as Record; - const restrictDirectoryWidgetCapability = directoryWidget !== undefined; - const resultWithoutExistingEmbedTicket = { ...out }; - if (restrictDirectoryWidgetCapability) { - delete resultWithoutExistingEmbedTicket.embedStartUrl; - delete resultWithoutExistingEmbedTicket.embedTargetPath; - delete resultWithoutExistingEmbedTicket.embedExpiresAt; - } - if ( - out.embed === false || - (!restrictDirectoryWidgetCapability && - out.embed !== true && - !directoryLinkUrl) - ) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; - } - if ( - !restrictDirectoryWidgetCapability && - typeof out.embedStartUrl === "string" && - out.embedStartUrl.trim() - ) { - return result; - } + const resultWithoutExistingEmbedTicket = withoutMcpAppEmbedTicket( + result, + ) as Record; if ( - !restrictDirectoryWidgetCapability && - typeof out.url === "string" && - out.url.trim() && - isEmbedStartUrl(out.url) + !directoryWidget || + meta?.inlineMcpApps !== true || + resultWithoutExistingEmbedTicket.embed === false ) { - return result; - } - - const candidates = restrictDirectoryWidgetCapability - ? [directoryWidget.targetPath] - : out.embed === true - ? [out.url, out.path, out.deepLinkUrl, directoryLinkUrl] - : [directoryLinkUrl]; - const candidate = candidates.find((value): value is string => { - if (typeof value !== "string" || value.trim().length === 0) return false; - return !restrictDirectoryWidgetCapability || !isEmbedStartUrl(value); - }); - if (!candidate) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; + return resultWithoutExistingEmbedTicket; } - const trimmed = candidate.trim(); + const trimmed = directoryWidget.targetPath.trim(); const isPath = trimmed.startsWith("/") && !trimmed.startsWith("//"); const isAbsoluteHttp = /^https?:\/\//i.test(trimmed); - if (!isPath && !isAbsoluteHttp) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; - } - if (isAbsoluteHttp && !meta?.origin) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; + if ((!isPath && !isAbsoluteHttp) || isEmbedStartUrl(trimmed)) { + return resultWithoutExistingEmbedTicket; } - const ctx = getRequestContext(); const ownerEmail = ctx?.userEmail?.trim(); - if (!ownerEmail) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; + const requestOrigin = ctx?.requestOrigin; + if (!ownerEmail || !requestOrigin) { + return resultWithoutExistingEmbedTicket; } const { normalizeEmbedTargetPath, createEmbedSessionTicket } = @@ -1373,12 +1325,10 @@ export async function withServerMintedMcpAppEmbedStart( const { buildEmbedStartPath } = await import("../server/embed-route.js"); const targetPath = normalizeEmbedTargetPath( withMcpChatBridgeParam(trimmed), - meta?.origin, + requestOrigin, ); if (!targetPath) { - return restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : result; + return resultWithoutExistingEmbedTicket; } if ( @@ -1399,36 +1349,30 @@ export async function withServerMintedMcpAppEmbedStart( Number.isSafeInteger(revocationAnchorCandidate) ? revocationAnchorCandidate : undefined; - if ( - restrictDirectoryWidgetCapability && - revocationAnchorCreatedAtMs === undefined - ) { + if (revocationAnchorCreatedAtMs === undefined) { throw new Error( "Directory widget sessions require a trusted MCP credential issue time.", ); } const mintTicket = async ( - capability: NonNullable["capability"] | undefined, + capability: NonNullable["capability"], ) => { - const scope = capability - ? capability.mode === "write" + const scope = + capability.mode === "write" ? createMcpDirectoryWidgetWriteCapability(capability.value) - : createMcpDirectoryWidgetReadCapability(capability.value) - : typeof out.chrome === "string" - ? out.chrome - : null; + : createMcpDirectoryWidgetReadCapability(capability.value); // An oversize or invalid scope has no ticket; the caller degrades or // returns the result without one. Throwing here would fail a tool call // whose action already ran. - if (capability && !scope) return null; + if (!scope) return null; return createEmbedSessionTicket({ ownerEmail, orgId: ctx?.orgId, targetPath, scope, ...(capability?.mode === "write" ? { ttlSeconds: 5 * 60 } : {}), - ...(capability ? { revocationAnchorCreatedAtMs } : {}), + revocationAnchorCreatedAtMs, }); }; const writeCapability = @@ -1440,7 +1384,7 @@ export async function withServerMintedMcpAppEmbedStart( "Could not create a valid scoped capability for this MCP directory widget.", ); try { - ticket = await mintTicket(directoryWidget?.capability); + ticket = await mintTicket(directoryWidget.capability); } catch (error) { if (!writeCapability) throw error; writeMintError = error; @@ -1471,14 +1415,10 @@ export async function withServerMintedMcpAppEmbedStart( return resultWithoutExistingEmbedTicket; } const startPath = buildEmbedStartPath(ticket.ticket); - const embedStartUrl = meta?.origin - ? new URL(startPath, meta.origin).toString() - : startPath; + const embedStartUrl = new URL(startPath, requestOrigin).toString(); return { - ...(restrictDirectoryWidgetCapability - ? resultWithoutExistingEmbedTicket - : out), + ...resultWithoutExistingEmbedTicket, embedStartUrl, embedTargetPath: targetPath, embedExpiresAt: ticket.expiresAt, @@ -1493,6 +1433,11 @@ export function withoutMcpAppEmbedTicket(result: unknown): unknown { delete output.embedStartUrl; delete output.embedTargetPath; delete output.embedExpiresAt; + for (const key of ["startUrl", "url", "deepLinkUrl", "path"]) { + if (typeof output[key] === "string" && isEmbedStartUrl(output[key])) { + delete output[key]; + } + } return output; } @@ -1712,14 +1657,13 @@ function mcpDirectoryWidgetSessionTool(config: MCPConfig): Tool | null { const sourceNames = Object.keys(profile?.widgetTargets ?? {}).filter((name) => profile?.connectorCatalog.includes(name), ); - if ( - config.catalogMode !== "directory" || - !profile || - profile.widgets === false || - sourceNames.length === 0 - ) { + if (!profile || profile.widgets === false || sourceNames.length === 0) { return null; } + const genericSession = + config.catalogMode !== "directory" && + (config.builtinCrossAppTools !== false || + config.actions.create_embed_session !== undefined); return { name: "create_embed_session", description: @@ -1727,6 +1671,16 @@ function mcpDirectoryWidgetSessionTool(config: MCPConfig): Tool | null { inputSchema: { type: "object", properties: { + ...(genericSession + ? { + path: { + type: "string", + description: "App-relative document path.", + }, + url: { type: "string", description: "Same-origin document URL." }, + chrome: { type: "string", enum: ["full", "minimal"] }, + } + : {}), sourceTicket: { type: "string", description: "Original server-issued widget ticket from this result.", @@ -1737,7 +1691,7 @@ function mcpDirectoryWidgetSessionTool(config: MCPConfig): Tool | null { "Extend the active scoped widget session without replacing its mounted app frame.", }, }, - required: ["sourceTicket"], + ...(!genericSession ? { required: ["sourceTicket"] } : {}), }, annotations: { readOnlyHint: false, @@ -1757,12 +1711,18 @@ async function renewMcpDirectoryWidgetEmbedSession( | { renewed: true; expiresAt: number } > { const profile = config.directoryProfile; - if (!profile || config.catalogMode !== "directory") { + if (!profile || profile.widgets === false) { throw new Error("Directory widget session renewal is not enabled."); } if (!hasVerifiedMcpUserIdentity(identity)) { throw new Error("Widget session renewal requires a verified user caller."); } + const requestOrigin = getRequestContext()?.requestOrigin; + if (!requestOrigin) { + throw new Error( + "Widget session renewal requires a trusted request origin. Open the full app instead.", + ); + } const sourceTicket = args.sourceTicket; if (typeof sourceTicket !== "string") { @@ -1770,6 +1730,11 @@ async function renewMcpDirectoryWidgetEmbedSession( "Widget session renewal requires its original result ticket.", ); } + if (Object.hasOwn(args, "path") || Object.hasOwn(args, "url")) { + throw new Error( + "Scoped widget renewal does not accept a replacement path or URL.", + ); + } const { createEmbedSessionTicket, @@ -1835,13 +1800,9 @@ async function renewMcpDirectoryWidgetEmbedSession( return { renewed: true, expiresAt }; } - const appOrigin = profile.widgetDomain ?? config.widgetDomain; - if (!appOrigin) { - throw new Error("Directory widget session renewal requires an app origin."); - } let appOriginUrl: URL; try { - appOriginUrl = new URL(appOrigin); + appOriginUrl = new URL(requestOrigin); } catch { throw new Error("Directory widget session renewal has an invalid origin."); } @@ -2271,12 +2232,10 @@ async function resolveMcpAppResource( ): Promise { const resource = entry.mcpApp?.resource; if (!resource) return null; - // Directory widgets open a host pane on every call, so only the profile's - // widgetTargets (create/present tools) attach one; a read tool whose action - // still carries mcpApp.resource for the non-directory surface must not. const widgetTargets = config.directoryProfile?.widgetTargets; if ( - config.catalogMode === "directory" && + (config.catalogMode === "directory" || + resource.requiresEmbedSession === true) && (!widgetTargets || !Object.hasOwn(widgetTargets, actionName)) ) { return null; @@ -2350,9 +2309,18 @@ export function mcpAppWidgetsEnabledForIdentity( function stripDirectoryWidgetMeta( config: MCPConfig, metadata: Record, + suppressResource = false, ): void { - if (config.catalogMode !== "directory") return; - delete metadata.ui; + if (config.catalogMode !== "directory" && !suppressResource) return; + if (config.catalogMode === "directory") { + delete metadata.ui; + } else { + const ui = { ...metadataObject(metadata.ui) }; + delete ui.resourceUri; + if (Object.keys(ui).length > 0) metadata.ui = ui; + else delete metadata.ui; + } + delete metadata["ui/resourceUri"]; delete metadata[MCP_APP_RESOURCE_URI_META_KEY]; delete metadata["openai/ui"]; delete metadata["openai/outputTemplate"]; @@ -2900,14 +2868,18 @@ export async function createMCPServerForRequest( appCatalog || directoryCatalog ? baseActions : mergeBuiltinTools(config, baseActions, requestMeta); + const deniedToolNames = externalAgentDenySet(config); + if (requestMeta.inlineMcpApps !== true) { + deniedToolNames.add("create_embed_session"); + } const actions = withoutExternalOptOuts( config, flatCatalog ? withoutToolSearch(mergedActions) : mergedActions, + deniedToolNames, ); // Tools built outside the action map (`ask-agent`, `create_embed_session`) // never pass through the filter above, so tools/list and tools/call refuse a // denied name too. `ask-agent` runs `ask_app`, so denying either removes it. - const deniedToolNames = externalAgentDenySet(config); if (deniedToolNames.has("ask_app")) deniedToolNames.add("ask-agent"); const { isAdvertisable, @@ -3247,7 +3219,12 @@ export async function createMCPServerForRequest( !Array.isArray((entry.tool as any)._meta) ? { ...((entry.tool as any)._meta as Record) } : {}; - stripDirectoryWidgetMeta(config, rawToolMeta); + stripDirectoryWidgetMeta( + config, + rawToolMeta, + !!entry.mcpApp?.resource && + (!mcpAppResource || requestMeta?.inlineMcpApps !== true), + ); let inputSchema = advertisedInputSchema(name); if (directoryCatalog) { // Shared with the request's other reads, so edited as a copy. @@ -3349,7 +3326,13 @@ export async function createMCPServerForRequest( hasVerifiedMcpUserIdentity(effectiveIdentity) ? mcpDirectoryWidgetSessionTool(config) : null; - if (widgetSessionTool) tools.push(widgetSessionTool); + if (widgetSessionTool) { + const index = tools.findIndex( + (tool) => tool.name === widgetSessionTool.name, + ); + if (index >= 0) tools.splice(index, 1); + tools.push(widgetSessionTool); + } if ( fullCatalogRequested && @@ -3529,9 +3512,10 @@ export async function createMCPServerForRequest( } if ( - directoryCatalog && name === "create_embed_session" && - mcpDirectoryWidgetSessionTool(config) + mcpDirectoryWidgetSessionTool(config) && + (directoryCatalog || + Object.hasOwn(metadataObject(args), "sourceTicket")) ) { try { const renewed = await renewMcpDirectoryWidgetEmbedSession( @@ -3543,7 +3527,19 @@ export async function createMCPServerForRequest( content: [ { type: "text" as const, text: "Widget session ready." }, ], - structuredContent: renewed, + structuredContent: directoryCatalog + ? renewed + : withoutMcpAppEmbedTicket(renewed), + ...(!directoryCatalog + ? { + _meta: { + ...mcpAppOnlyEmbedStartMeta(renewed), + "agent-native/widgetSource": { + sourceTicket: metadataObject(args).sourceTicket, + }, + }, + } + : {}), }; } catch (err: any) { failure = describeMcpError(err); diff --git a/packages/core/src/mcp/builtin-cross-app.spec.ts b/packages/core/src/mcp/builtin-cross-app.spec.ts index 9c5a5ee5980..0fa886ca4f2 100644 --- a/packages/core/src/mcp/builtin-cross-app.spec.ts +++ b/packages/core/src/mcp/builtin-cross-app.spec.ts @@ -219,7 +219,11 @@ describe("open_app — same-app / standalone keeps a relative deep link", () => }); const result: any = await runWithRequestContext( - { userEmail: "owner@example.com", orgId: "org-123" }, + { + userEmail: "owner@example.com", + orgId: "org-123", + requestOrigin: "https://mail.example.com", + }, () => tools.open_app.run({ app: "mail", @@ -287,7 +291,11 @@ describe("open_app — same-app / standalone keeps a relative deep link", () => }); const result: any = await runWithRequestContext( - { userEmail: "owner@example.com", orgId: "org-123" }, + { + userEmail: "owner@example.com", + orgId: "org-123", + requestOrigin: "https://mail.example.com", + }, () => tools.open_app.run({ app: "mail", @@ -375,9 +383,29 @@ describe("open_app — same-app / standalone keeps a relative deep link", () => }); describe("create_embed_session", () => { + it.each(["open_app", "create_embed_session"])( + "returns a public path from %s without minting from an originless context", + async (name) => { + const createTicket = vi.spyOn(embedSession, "createEmbedSessionTicket"); + const tools = getBuiltinCrossAppTools(baseConfig(), { + origin: "https://mail.example.com", + inlineMcpApps: true, + }); + const result = await runWithRequestContext( + { userEmail: "owner@example.com", orgId: "org-123" }, + () => tools[name]!.run({ path: "/inbox", embed: true }), + ); + expect(result).toMatchObject({ url: "/inbox" }); + expect(result).not.toHaveProperty("startUrl"); + expect(result).not.toHaveProperty("embedStartUrl"); + expect(createTicket).not.toHaveBeenCalled(); + }, + ); + it("is write-scoped because the embed ticket becomes a browser session", () => { const tools = getBuiltinCrossAppTools(baseConfig(), { origin: "https://mail.example.com", + inlineMcpApps: true, }); expect(tools.create_embed_session.readOnly).toBe(false); }); @@ -385,6 +413,7 @@ describe("create_embed_session", () => { it("requires an authenticated MCP caller", async () => { const tools = getBuiltinCrossAppTools(baseConfig(), { origin: "https://mail.example.com", + inlineMcpApps: true, }); await expect( tools.create_embed_session.run({ path: "/inbox" }), diff --git a/packages/core/src/mcp/builtin-tools.ts b/packages/core/src/mcp/builtin-tools.ts index 6d825be9409..cd517835623 100644 --- a/packages/core/src/mcp/builtin-tools.ts +++ b/packages/core/src/mcp/builtin-tools.ts @@ -66,6 +66,7 @@ class AskAppInlineDeadlineError extends Error { type AskAppRequestMeta = { origin?: string; basePath?: string; + inlineMcpApps?: boolean; localAgentTaskClient?: import("../a2a/mcp-task-client.js").McpAgentTaskClient; }; @@ -770,10 +771,7 @@ function listAppsTool( }; } -function openAppTool( - config: MCPConfig, - requestMeta?: { origin?: string }, -): ActionEntry { +function openAppTool(config: MCPConfig): ActionEntry { return { tool: { ...tool( @@ -860,7 +858,7 @@ function openAppTool( } const chrome = - typeof args.chrome === "string" + args.chrome === "full" || args.chrome === "minimal" ? args.chrome : chromeParam === "full" || chromeParam === "minimal" ? chromeParam @@ -889,14 +887,14 @@ function openAppTool( await import("../server/request-context.js"); const ctx = getRequestContext(); const ownerEmail = ctx?.userEmail?.trim(); - if (ownerEmail) { + if (ownerEmail && ctx?.requestOrigin) { const { normalizeEmbedTargetPath, createEmbedSessionTicket } = await import("../server/embed-session.js"); const { buildEmbedStartPath } = await import("../server/embed-route.js"); const targetPath = normalizeEmbedTargetPath( withMcpChatBridgeParam(url), - requestMeta?.origin, + ctx.requestOrigin, ); if (targetPath) { const ticket = await createEmbedSessionTicket({ @@ -906,9 +904,7 @@ function openAppTool( scope: chrome ?? null, }); const startPath = buildEmbedStartPath(ticket.ticket); - embedStartUrl = requestMeta?.origin - ? new URL(startPath, requestMeta.origin).toString() - : startPath; + embedStartUrl = new URL(startPath, ctx.requestOrigin).toString(); embedTargetPath = targetPath; embedExpiresAt = ticket.expiresAt; } @@ -953,9 +949,7 @@ function openAppTool( }; } -function createEmbedSessionTool(requestMeta?: { - origin?: string; -}): ActionEntry { +function createEmbedSessionTool(): ActionEntry { return { tool: { ...tool( @@ -982,6 +976,13 @@ function createEmbedSessionTool(requestMeta?: { readOnly: false, parallelSafe: true, run: async (args: Record) => { + if ( + args.chrome !== undefined && + args.chrome !== "full" && + args.chrome !== "minimal" + ) { + throw new Error("Embed chrome must be full or minimal."); + } const { getRequestContext } = await import("../server/request-context.js"); const ctx = getRequestContext(); @@ -1003,13 +1004,16 @@ function createEmbedSessionTool(requestMeta?: { : ""; const targetPath = normalizeEmbedTargetPath( rawTarget, - requestMeta?.origin, + ctx?.requestOrigin, ); if (!targetPath) { throw new Error( "create_embed_session can only embed same-origin app-relative URLs.", ); } + if (!ctx?.requestOrigin) { + return { url: targetPath, targetPath, embed: false }; + } const ticket = await createEmbedSessionTicket({ ownerEmail, @@ -1018,15 +1022,19 @@ function createEmbedSessionTool(requestMeta?: { scope: typeof args.chrome === "string" ? args.chrome : null, }); const startPath = buildEmbedStartPath(ticket.ticket); - const startUrl = requestMeta?.origin - ? new URL(startPath, requestMeta.origin).toString() - : startPath; + const startUrl = new URL(startPath, ctx.requestOrigin).toString(); return { startUrl, targetPath, expiresAt: ticket.expiresAt, }; }, + link: ({ result }) => { + const value = result as { url?: string } | null; + return typeof value?.url === "string" + ? { url: value.url, label: "Open app" } + : null; + }, }; } @@ -1509,8 +1517,10 @@ export function getBuiltinCrossAppTools( ): Record { return { list_apps: listAppsTool(config, requestMeta), - open_app: openAppTool(config, requestMeta), - create_embed_session: createEmbedSessionTool(requestMeta), + open_app: openAppTool(config), + ...(requestMeta?.inlineMcpApps === true + ? { create_embed_session: createEmbedSessionTool() } + : {}), ask_app: askAppTool(config, requestMeta), ask_app_status: askAppStatusTool(config, requestMeta), create_workspace_app: createWorkspaceAppTool(), diff --git a/packages/core/src/mcp/connector-catalog.spec.ts b/packages/core/src/mcp/connector-catalog.spec.ts index 0f4401141db..760d63ac8ec 100644 --- a/packages/core/src/mcp/connector-catalog.spec.ts +++ b/packages/core/src/mcp/connector-catalog.spec.ts @@ -405,7 +405,6 @@ describe("connector-catalog tier", () => { "open_app", "ask_app", "ask_app_status", - "create_embed_session", ].sort(); expect(names).toEqual(expected); @@ -428,7 +427,6 @@ describe("connector-catalog tier", () => { "open_app", "ask_app", "ask_app_status", - "create_embed_session", ].sort(), ); @@ -1034,7 +1032,6 @@ describe("connector-catalog tier — env flag not required", () => { "open_app", "ask_app", "ask_app_status", - "create_embed_session", ].sort(), ); expect(names).not.toContain("db-exec"); @@ -1116,7 +1113,6 @@ describe("connector-catalog tier — no connectorCatalog declared", () => { expect(before).toEqual([ "ask_app", "ask_app_status", - "create_embed_session", "list_apps", "open_app", "set-resource-visibility", @@ -1178,7 +1174,6 @@ describe("connector-catalog tier — no connectorCatalog declared", () => { expect(names).toEqual([ "ask_app", "ask_app_status", - "create_embed_session", "list_apps", "open_app", ]); diff --git a/packages/core/src/mcp/direct-action-call.ts b/packages/core/src/mcp/direct-action-call.ts index dd1c7a7e28b..e6921314062 100644 --- a/packages/core/src/mcp/direct-action-call.ts +++ b/packages/core/src/mcp/direct-action-call.ts @@ -21,6 +21,7 @@ import { extractAgentImagesFromActionResult } from "../agent/tool-result-images. import { writeActionChangeMarker } from "../server/action-change-marker-write.js"; import { captureError } from "../server/capture-error.js"; import { + getRequestContext, getRequestOrgId, getRequestUserEmail, } from "../server/request-context.js"; @@ -30,6 +31,7 @@ import { buildLinkArtifacts, conciseMcpAppToolText, conciseToolResultText, + getMcpDirectoryWidgetResourceUri, hasVerifiedMcpUserIdentity, MCP_ACTION_APPROVAL_INPUT_KEY, type MCPActionEntry, @@ -57,7 +59,11 @@ import { withServerMintedMcpAppEmbedStart, } from "./build-server.js"; import { mcpCallAttribution } from "./call-attribution.js"; -import { describeThrownForLog, redactEmbedText } from "./embed-redaction.js"; +import { + describeThrownForLog, + isEmbedStartUrl, + redactEmbedText, +} from "./embed-redaction.js"; import { embedCredentialSources, type EmbedCredentialGuard, @@ -392,43 +398,58 @@ export function createDirectActionCall(scope: DirectActionCallScope) { mcpAppWidgetsEnabledForIdentity(config, effectiveIdentity) ? await resolveMcpAppResourceSafely(config, name, entry, requestMeta) : null; - let directoryLinkUrl: string | undefined; - if (config.catalogMode === "directory" && entry.link) { - const linked = entry.link({ - args: callArgs(), - result: rawResult, - }); - directoryLinkUrl = linked?.url ?? undefined; - produced(directoryLinkUrl); - } const trustedCredentialIssuedAtMs = effectiveIdentity?.mcpCredentialIssuedAtMs; - const directoryWidget = - directoryCatalog && + let directoryWidget: Awaited< + ReturnType + >; + if ( + !mcpResultIsError && mcpAppResourceCandidate && + getRequestContext()?.requestOrigin && + hasVerifiedMcpUserIdentity(effectiveIdentity) && typeof trustedCredentialIssuedAtMs === "number" && Number.isSafeInteger(trustedCredentialIssuedAtMs) - ? await mcpDirectoryWidgetCapabilityForTool( - config, - mcpAppResourceCandidate, - name, - (args as Record) ?? {}, - projectedRawResult, - visibleActions, - effectiveIdentity, - ) - : undefined; - const missingDirectoryWidgetCapability = - directoryCatalog && - config.directoryProfile !== undefined && - mcpAppResourceCandidate !== null && + ) { + try { + directoryWidget = await mcpDirectoryWidgetCapabilityForTool( + config, + directoryCatalog + ? mcpAppResourceCandidate + : { + ...mcpAppResourceCandidate, + uri: getMcpDirectoryWidgetResourceUri( + config.appId ?? config.name, + ), + }, + name, + callArgs(), + projectedRawResult, + visibleActions, + effectiveIdentity, + ); + } catch (error) { + if (directoryCatalog) throw error; + console.warn( + `[mcp] ${name} returned no scoped widget capability; using a full-app link: ${describeThrownForLog(error)}`, + ); + } + } + const requiresEmbedSession = + directoryCatalog || + entry.mcpApp?.resource?.requiresEmbedSession === true; + const missingWidgetCapability = + requiresEmbedSession && + entry.mcpApp?.resource !== undefined && directoryWidget === undefined; const suppressDirectoryWidget = directoryCatalog && !hasVerifiedMcpUserIdentity(effectiveIdentity); // The host mounts a widget for this tool from its descriptor, with or // without a ticket in the result, so a withheld ticket must be visible. if ( - missingDirectoryWidgetCapability && + directoryCatalog && + mcpAppResourceCandidate && + missingWidgetCapability && !suppressDirectoryWidget && !mcpResultIsError ) { @@ -441,13 +462,13 @@ export function createDirectActionCall(scope: DirectActionCallScope) { ); } const rawResultForClient = - missingDirectoryWidgetCapability || suppressDirectoryWidget + entry.mcpApp?.resource !== undefined && + (!directoryWidget || suppressDirectoryWidget) ? withoutMcpAppEmbedTicket(projectedRawResult) : mcpAppResourceCandidate ? await withServerMintedMcpAppEmbedStart( projectedRawResult, requestMeta, - directoryLinkUrl, directoryWidget, ) : projectedRawResult; @@ -469,13 +490,19 @@ export function createDirectActionCall(scope: DirectActionCallScope) { resultImages.length > 0 || mcpResultHasContent(actionResultForClient); const mcpAppResource = mcpAppResourceCandidate && - !missingDirectoryWidgetCapability && + (!requiresEmbedSession || + (directoryWidget && + typeof metadataObject(actionResultForClient).embedStartUrl === + "string")) && !mcpResultIsError && embedHasContent ? mcpAppResourceCandidate : null; const embedProducedNothing = - !!mcpAppResourceCandidate && !mcpResultIsError && !embedHasContent; + !!mcpAppResourceCandidate && + !missingWidgetCapability && + !mcpResultIsError && + !embedHasContent; const mcpAppOpenLinkMeta = mcpAppResource ? mcpAppEmbedOpenLinkMeta( actionResultForClient, @@ -492,7 +519,7 @@ export function createDirectActionCall(scope: DirectActionCallScope) { // Built before the first redaction, which throws on a result it // cannot read. handoffMeta = { - ...(directoryCatalog && mcpAppResource + ...(directoryWidget && mcpAppResource ? mcpDirectoryWidgetSourceMeta(name, mcpAppOpenLinkMeta) : {}), ...mcpAppOpenLinkMeta, @@ -505,8 +532,36 @@ export function createDirectActionCall(scope: DirectActionCallScope) { }; // The link is built from the result as the action returned it, and // checked with the rest of the response. + const useFullAppLink = + (!directoryCatalog || !getRequestContext()?.requestOrigin) && + requiresEmbedSession && + !!entry.mcpApp?.resource && + !mcpAppResource; + const linkEntry: ActionEntry = useFullAppLink + ? { + ...entry, + link: ({ args, result }) => { + const record = metadataObject(result); + const linked = entry.link?.({ + args, + result: { ...record, embed: false }, + }); + if (linked?.url && !isEmbedStartUrl(linked.url)) return linked; + const url = [record.deepLinkUrl, record.url, record.path].find( + (value): value is string => + typeof value === "string" && + value.trim().length > 0 && + !isEmbedStartUrl(value), + ); + return { + url: url ?? `${requestMeta?.basePath ?? ""}/`, + label: `Open ${config.title ?? config.appId ?? config.name}`, + }; + }, + } + : entry; const { block, _meta } = buildLinkArtifacts( - entry, + linkEntry, callArgs(), actionResultForClient, requestMeta, @@ -528,7 +583,8 @@ export function createDirectActionCall(scope: DirectActionCallScope) { returned.text !== undefined ? projectedResultForClient : visible; const wantsStructuredResult = (returnsQueryPayload(entry) || - entry.mcpApp?.structuredContent === true) && + entry.mcpApp?.structuredContent === true || + (!mcpResultIsError && entry.mcpApp?.resource !== undefined)) && visible && typeof visible === "object"; const contracted = () => diff --git a/packages/core/src/mcp/embed-app.spec.ts b/packages/core/src/mcp/embed-app.spec.ts index 927fee433d3..5f284bafa03 100644 --- a/packages/core/src/mcp/embed-app.spec.ts +++ b/packages/core/src/mcp/embed-app.spec.ts @@ -872,12 +872,13 @@ describe("embedApp", () => { }; const renewalHarness = (options: { + catalogMode?: "directory" | "app"; frame: { contentWindow: object; src?: string }; documentState: { loadGeneration: number }; callEmbedSessionTool: ReturnType; sendToAppFrame: ReturnType; }) => { - const html = htmlFor("directory"); + const html = htmlFor(options.catalogMode ?? "directory"); const source = functionSource(html, "renewExpiredEmbedSession"); return new Function( "appFrame", @@ -904,10 +905,12 @@ describe("embedApp", () => { "appFrameReady", "appFrameLoadTimer", "lastFrameSrc", + "objectValue", + "toolResponseMetadata", `${source}; return { renewExpiredEmbedSession, state: () => ({ openStartUrl, appFrameReady, appFrameLoadTimer, lastFrameSrc }) };`, )( options.frame, - { dataset: { catalogMode: "directory" } }, + { dataset: { catalogMode: options.catalogMode ?? "directory" } }, "https://app.example/slides/deck-1", "https://app.example/_agent-native/embed/start?ticket=old", "mounted-current-ticket", @@ -934,6 +937,8 @@ describe("embedApp", () => { true, null, "https://app.example/_agent-native/embed/start?ticket=old", + objectValue, + { "agent-native/widgetSource": { sourceTicket: "old-source" } }, ) as { renewExpiredEmbedSession: ( requestId: string, @@ -944,50 +949,54 @@ describe("embedApp", () => { }; }; - it("renews a write session in place to preserve pending editor state", async () => { - const frame = { - contentWindow: {}, - src: "https://app.example/design/d1", - }; - const documentState = { loadGeneration: 3 }; - const sendToAppFrame = vi.fn(); - const callEmbedSessionTool = vi.fn(async () => ({ - renewed: true, - expiresAt: Date.now() + 60_000, - })); - const harness = renewalHarness({ - frame, - documentState, - callEmbedSessionTool, - sendToAppFrame, - }); + it.each(["directory", "app"] as const)( + "renews a %s write session in place to preserve pending editor state", + async (catalogMode) => { + const frame = { + contentWindow: {}, + src: "https://app.example/design/d1", + }; + const documentState = { loadGeneration: 3 }; + const sendToAppFrame = vi.fn(); + const callEmbedSessionTool = vi.fn(async () => ({ + renewed: true, + expiresAt: Date.now() + 60_000, + })); + const harness = renewalHarness({ + catalogMode, + frame, + documentState, + callEmbedSessionTool, + sendToAppFrame, + }); - await harness.renewExpiredEmbedSession("renew-1", frame, 3); + await harness.renewExpiredEmbedSession("renew-1", frame, 3); - expect(callEmbedSessionTool).toHaveBeenCalledWith({ - sourceTicket: "mounted-current-ticket", - renewInPlace: true, - }); - expect(frame.src).toBe("https://app.example/design/d1"); - expect(sendToAppFrame).toHaveBeenCalledWith({ - type: "agentNative.embedSessionRenewed", - data: { - requestId: "renew-1", - ok: true, - }, - }); - expect(JSON.stringify(sendToAppFrame.mock.calls)).not.toContain( - "startUrl", - ); - expect(JSON.stringify(sendToAppFrame.mock.calls)).not.toContain( - "ticket=", - ); - expect(harness.state()).toMatchObject({ - openStartUrl: - "https://app.example/_agent-native/embed/start?ticket=old", - appFrameReady: true, - }); - }); + expect(callEmbedSessionTool).toHaveBeenCalledWith({ + sourceTicket: "mounted-current-ticket", + renewInPlace: true, + }); + expect(frame.src).toBe("https://app.example/design/d1"); + expect(sendToAppFrame).toHaveBeenCalledWith({ + type: "agentNative.embedSessionRenewed", + data: { + requestId: "renew-1", + ok: true, + }, + }); + expect(JSON.stringify(sendToAppFrame.mock.calls)).not.toContain( + "startUrl", + ); + expect(JSON.stringify(sendToAppFrame.mock.calls)).not.toContain( + "ticket=", + ); + expect(harness.state()).toMatchObject({ + openStartUrl: + "https://app.example/_agent-native/embed/start?ticket=old", + appFrameReady: true, + }); + }, + ); it("does not acknowledge a renewal after the app document navigates", async () => { const frame = { @@ -1945,8 +1954,9 @@ const WIDGET_CHAT_BRIDGE = "__an_mcp_chat_bridge=1"; * Runs the real directory shell script against a linkedom document and a * ChatGPT-style `window.openai` bridge that replays a persisted tool result. */ -async function mountRestoredDirectoryWidget( +async function mountRestoredWidget( callTool: (name: string, args: Record) => Promise, + catalogMode: "directory" | "app" = "directory", ) { const resource = embedApp({ title: "Slides" }); const html = @@ -1954,7 +1964,7 @@ async function mountRestoredDirectoryWidget( ? resource.html({ actionName: "create-deck", appId: "slides", - catalogMode: "directory", + catalogMode, startToolName: "create_embed_session", }) : resource.html; @@ -2071,103 +2081,111 @@ async function mountRestoredDirectoryWidget( }; } -describe("embedApp directory widget restored after a chat reload", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); +describe.each(["directory", "app"] as const)( + "embedApp %s widget restored after a chat reload", + (catalogMode) => { + const mountRestoredDirectoryWidget = ( + callTool: Parameters[0], + ) => mountRestoredWidget(callTool, catalogMode); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); - const renewed = async () => ({ - structuredContent: { - startUrl: WIDGET_RENEWED_START, - targetPath: "/deck/deck-1", - expiresAt: Date.now() + 15 * 60 * 1000, - }, - }); + const renewed = async () => ({ + structuredContent: { + startUrl: WIDGET_RENEWED_START, + targetPath: "/deck/deck-1", + expiresAt: Date.now() + 15 * 60 * 1000, + }, + }); - it.each([WIDGET_APP_ORIGIN, "null"])( - "renews from the persisted source ticket when the spent start URL answers with the expiry page (origin %s)", - async (origin) => { - const shell = await mountRestoredDirectoryWidget(renewed); + it.each([WIDGET_APP_ORIGIN, "null"])( + "renews from the persisted source ticket when the spent start URL answers with the expiry page (origin %s)", + async (origin) => { + const shell = await mountRestoredDirectoryWidget(renewed); - expect(shell.frame()?.src).toBe( - `${WIDGET_SPENT_START}&${WIDGET_CHAT_BRIDGE}`, - ); - expect(shell.bridge.callTool).not.toHaveBeenCalled(); + expect(shell.frame()?.src).toBe( + `${WIDGET_SPENT_START}&${WIDGET_CHAT_BRIDGE}`, + ); + expect(shell.bridge.callTool).not.toHaveBeenCalled(); + + await shell.postExpiredFrom(origin); + + expect(shell.bridge.callTool).toHaveBeenCalledTimes(1); + expect(shell.bridge.callTool).toHaveBeenCalledWith( + "create_embed_session", + { sourceTicket: "spent-ticket" }, + ); + expect(shell.frame()?.src).toBe( + `${WIDGET_RENEWED_START}&${WIDGET_CHAT_BRIDGE}`, + ); + }, + ); + + it("renews from a start URL the server sends in _meta", async () => { + const shell = await mountRestoredDirectoryWidget(async () => ({ + structuredContent: { + targetPath: "/deck/deck-1", + expiresAt: Date.now() + 15 * 60 * 1000, + }, + _meta: { + "agent-native/embedStart": { startUrl: WIDGET_RENEWED_START }, + }, + })); - await shell.postExpiredFrom(origin); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); expect(shell.bridge.callTool).toHaveBeenCalledTimes(1); - expect(shell.bridge.callTool).toHaveBeenCalledWith( - "create_embed_session", - { sourceTicket: "spent-ticket" }, - ); expect(shell.frame()?.src).toBe( `${WIDGET_RENEWED_START}&${WIDGET_CHAT_BRIDGE}`, ); - }, - ); - - it("renews from a start URL the server sends in _meta", async () => { - const shell = await mountRestoredDirectoryWidget(async () => ({ - structuredContent: { - targetPath: "/deck/deck-1", - expiresAt: Date.now() + 15 * 60 * 1000, - }, - _meta: { "agent-native/embedStart": { startUrl: WIDGET_RENEWED_START } }, - })); - - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - - expect(shell.bridge.callTool).toHaveBeenCalledTimes(1); - expect(shell.frame()?.src).toBe( - `${WIDGET_RENEWED_START}&${WIDGET_CHAT_BRIDGE}`, - ); - }); + }); - it("does not replay the spent start URL when the host re-syncs after recovery", async () => { - const shell = await mountRestoredDirectoryWidget(renewed); - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); + it("does not replay the spent start URL when the host re-syncs after recovery", async () => { + const shell = await mountRestoredDirectoryWidget(renewed); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - await shell.resync("pip"); + await shell.resync("pip"); - expect(shell.bridge.callTool).toHaveBeenCalledTimes(1); - expect(shell.frame()?.src).toBe( - `${WIDGET_RENEWED_START}&${WIDGET_CHAT_BRIDGE}`, - ); - }); + expect(shell.bridge.callTool).toHaveBeenCalledTimes(1); + expect(shell.frame()?.src).toBe( + `${WIDGET_RENEWED_START}&${WIDGET_CHAT_BRIDGE}`, + ); + }); - it("stops at the refresh cap and says so when every renewed session also expires", async () => { - const shell = await mountRestoredDirectoryWidget(renewed); + it("stops at the refresh cap and says so when every renewed session also expires", async () => { + const shell = await mountRestoredDirectoryWidget(renewed); - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - expect(shell.bridge.callTool).toHaveBeenCalledTimes(2); - expect(shell.stageText()).toContain("Reopen the app"); - }); + expect(shell.bridge.callTool).toHaveBeenCalledTimes(2); + expect(shell.stageText()).toContain("Reopen the app"); + }); - it("shows the server's refusal instead of a blank or stale frame", async () => { - const shell = await mountRestoredDirectoryWidget(async () => ({ - isError: true, - content: [ - { - type: "text", - text: "Error: Embed session ticket creation was revoked by logout.", - }, - ], - })); + it("shows the server's refusal instead of a blank or stale frame", async () => { + const shell = await mountRestoredDirectoryWidget(async () => ({ + isError: true, + content: [ + { + type: "text", + text: "Error: Embed session ticket creation was revoked by logout.", + }, + ], + })); - await shell.postExpiredFrom(WIDGET_APP_ORIGIN); + await shell.postExpiredFrom(WIDGET_APP_ORIGIN); - expect(shell.stageText()).toContain("App did not load"); - expect(shell.stageText()).toContain("revoked by logout"); - expect(shell.frame()).toBeNull(); - }); -}); + expect(shell.stageText()).toContain("App did not load"); + expect(shell.stageText()).toContain("revoked by logout"); + expect(shell.frame()).toBeNull(); + }); + }, +); interface LocalMcpAppEmbedHarnessOptions { actionName?: string; diff --git a/packages/core/src/mcp/embed-app.ts b/packages/core/src/mcp/embed-app.ts index 699d9a69eed..05fb6b533a4 100644 --- a/packages/core/src/mcp/embed-app.ts +++ b/packages/core/src/mcp/embed-app.ts @@ -58,6 +58,7 @@ export function embedApp( return { title, + requiresEmbedSession: true, ...(options.description ? { description: options.description } : {}), html: (ctx) => { const remoteBridgeFallbackEnabled = ctx.catalogMode !== "directory"; @@ -609,7 +610,10 @@ export function embedApp( renewInPlace = false, renewalSourceTicket = "", ) { - if (body.dataset.catalogMode === "directory") { + if ( + body.dataset.catalogMode === "directory" || + typeof objectValue(toolResponseMetadata["agent-native/widgetSource"]).sourceTicket === "string" + ) { const widgetSource = objectValue( toolResponseMetadata["agent-native/widgetSource"], ); @@ -1614,10 +1618,11 @@ export function embedApp( frame, frameLoadGeneration, ) { - const directoryMode = body.dataset.catalogMode === "directory"; + const scopedMode = body.dataset.catalogMode === "directory" || + typeof objectValue(toolResponseMetadata["agent-native/widgetSource"]).sourceTicket === "string"; const canDeliverRenewal = () => frame === appFrame && - (!directoryMode || + (!scopedMode || appFrameDocumentState.loadGeneration === frameLoadGeneration); const sendRenewalResult = (message) => { if (!canDeliverRenewal()) return; @@ -1643,13 +1648,13 @@ export function embedApp( try { const result = await callEmbedSessionTool( embedSessionArgsFor( - directoryMode ? openStartUrl || openUrl : openUrl, - directoryMode, + scopedMode ? openStartUrl || openUrl : openUrl, + scopedMode, activeEmbedSessionTicket, ), ); const data = parseToolResult(result); - if (directoryMode) { + if (scopedMode) { if (data.renewed !== true) { sendRenewalResult({ type: "agentNative.embedSessionRenewed", diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 9093db8895e..39143912a37 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -18,6 +18,7 @@ import { AgentConnectionRequiredError, defineAction, } from "../action.js"; +import { createToolSearchEntry } from "../agent/tool-search.js"; import { UnusableSchemaError } from "../json-schema/unusable-schema.js"; import { MCP_ACTION_RESULT_MARKER } from "../mcp-client/app-result.js"; import { listResourceSuggestions } from "../review/suggestions/actions.js"; @@ -27,6 +28,7 @@ import { filesystemActionEntry } from "../server/filesystem-action-entry.js"; import { isMcpDirectoryWidgetReadCapabilityScope, isMcpDirectoryWidgetWriteCapabilityScope, + normalizeMcpDirectoryWidgetReadActionArguments, } from "../shared/embed-auth.js"; import listResourceShares from "../sharing/actions/list-resource-shares.js"; import setResourceVisibility from "../sharing/actions/set-resource-visibility.js"; @@ -34,10 +36,12 @@ import shareResource from "../sharing/actions/share-resource.js"; import unshareResource from "../sharing/actions/unshare-resource.js"; import { createMCPServerForRequest, + type MCPConfig, selectMcpDirectoryWidgetReadActions, selectMcpDirectoryWidgetWriteActions, } from "./build-server.js"; import * as mcpBuildServer from "./build-server.js"; +import { embedApp } from "./embed-app.js"; import { MCP_DIRECTORY_ROUTE_PREFIX } from "./route-paths.js"; const builtinToolMocks = vi.hoisted(() => ({ @@ -528,6 +532,74 @@ const config = { }, }; +function scopedWidgetConfig(serverConfig: MCPConfig): MCPConfig { + const actions: MCPConfig["actions"] = { + ...serverConfig.actions, + "fixture-widget-read": { + tool: { + description: "Read the widget fixture.", + parameters: { + type: "object" as const, + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + readOnly: true, + requiresAuth: true, + http: { method: "GET" as const }, + run: async (args: Record) => ({ id: args.id }), + }, + }; + const widgetNames = Object.entries(serverConfig.actions) + .filter(([, entry]) => entry.mcpApp?.resource) + .map(([name]) => name); + return { + ...serverConfig, + actions: Object.fromEntries( + Object.entries(actions).map(([name, entry]) => [ + name, + widgetNames.includes(name) + ? { + ...entry, + mcpApp: { + ...entry.mcpApp, + resource: { + ...entry.mcpApp!.resource!, + requiresEmbedSession: true, + }, + }, + } + : entry, + ]), + ), + directoryProfile: { + connectorCatalog: [...widgetNames, "fixture-widget-read"], + widgetTargets: Object.fromEntries( + widgetNames.map((name) => [ + name, + (_args: unknown, result: unknown) => { + const record = result as Record; + const path = [record.path, record.url].find( + (value): value is string => + typeof value === "string" && + value.startsWith("/") && + !value.startsWith("//") && + !value.includes("/_agent-native/embed/start"), + ); + return { + targetPath: path ?? "/inbox", + resourceIds: { documentId: "fixture-document" }, + }; + }, + ]), + ), + widgetReadActionArguments: { + "fixture-widget-read": { id: "documentId" }, + }, + }, + }; +} + const veryLongInternalDescription = "INTERNAL_TOOL_BLOAT_SENTINEL ".repeat( 1_000, ); @@ -2287,6 +2359,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", resource: { uri: "ui://mail/create-document/shell-v65", title: "Created document", + requiresEmbedSession: true, html: "Created", }, }, @@ -2304,7 +2377,27 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }); const regularConfig = { ...config, - actions: { "create-document": createArtifact }, + directoryProfile: { + connectorCatalog: ["create-document", "get-document"], + widgetTargets: { + "create-document": () => ({ + targetPath: "/documents/doc-1", + resourceIds: { documentId: "doc-1" }, + }), + }, + widgetReadActionArguments: { "get-document": { id: "documentId" } }, + }, + actions: { + "create-document": createArtifact, + "get-document": defineAction({ + description: "Read one document.", + schema: z.object({ id: z.string() }), + readOnly: true, + requiresAuth: true, + http: { method: "GET" }, + run: async ({ id }) => ({ id }), + }), + }, }; const headers = await mcpAppsAuthHeaders(); @@ -2315,6 +2408,17 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect( listed.result.tools.map((tool: { name: string }) => tool.name), ).toContain("create-document"); + expect( + listed.result.tools.find((tool: any) => tool.name === "create-document") + ._meta.ui.resourceUri, + ).toBe("ui://mail/create-document/shell-v65"); + const resources = await callWeb( + { jsonrpc: "2.0", id: 136, method: "resources/list", params: {} }, + { headers, config: regularConfig }, + ); + expect( + resources.result.resources.map((resource: any) => resource.uri), + ).toContain("ui://mail/create-document/shell-v65"); const called = await callWeb( { @@ -2338,8 +2442,145 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", ownerEmail: "oauth@example.com", orgId: undefined, targetPath: "/documents/doc-1?__an_mcp_chat_bridge=1", - scope: null, + scope: expect.stringContaining("capability:mcp-directory-widget-read:"), + revocationAnchorCreatedAtMs: expect.any(Number), + }); + const scope = + embedSessionMocks.createEmbedSessionTicket.mock.calls[0]?.[0]?.scope; + const readArguments = { + appId: "mail", + resourceUri: "ui://mail/shell-v69", + actionName: "get-document", + allowedArgumentNames: ["id"], + args: { id: "doc-1" }, + }; + expect( + normalizeMcpDirectoryWidgetReadActionArguments(scope, readArguments), + ).toEqual({ id: "doc-1" }); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(scope, { + ...readArguments, + args: { id: "doc-2" }, + }), + ).toBeUndefined(); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(scope, { + ...readArguments, + appId: "other-app", + }), + ).toBeUndefined(); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(scope, { + ...readArguments, + resourceUri: "ui://mail/create-document/shell-v65", + }), + ).toBeUndefined(); + expect(JSON.stringify(called.result.content)).not.toContain( + "minted-picker-ticket", + ); + expect(JSON.stringify(called.result.structuredContent)).not.toContain( + "minted-picker-ticket", + ); + expect(called.result._meta["agent-native/widgetSource"]).toEqual({ + toolName: "create-document", + sourceTicket: "minted-picker-ticket", }); + const descriptor = listed.result.tools.find( + (tool: any) => tool.name === "create_embed_session", + ); + expect(descriptor.inputSchema.properties).toHaveProperty("sourceTicket"); + expect(descriptor.inputSchema.properties).not.toHaveProperty("path"); + const originalTicket = { + ...embedSessionMocks.renewalTickets.get("minted-picker-ticket")!, + }; + embedSessionMocks.createEmbedSessionTicket.mockClear(); + const renewed = await callWeb( + { + jsonrpc: "2.0", + id: 1390, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { sourceTicket: "minted-picker-ticket" }, + }, + }, + { headers, config: regularConfig }, + ); + expect(renewed.result.isError).not.toBe(true); + expect(renewed.result._meta["agent-native/embedStart"].startUrl).toContain( + "minted-picker-ticket", + ); + expect(JSON.stringify(renewed.result.structuredContent)).not.toContain( + "minted-picker-ticket", + ); + const renewedScope = + embedSessionMocks.createEmbedSessionTicket.mock.calls[0]?.[0]?.scope; + expect( + normalizeMcpDirectoryWidgetReadActionArguments( + renewedScope, + readArguments, + ), + ).toEqual({ id: "doc-1" }); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(renewedScope, { + ...readArguments, + args: { id: "doc-2" }, + }), + ).toBeUndefined(); + expect(embedSessionMocks.createEmbedSessionTicket).toHaveBeenCalledWith( + expect.objectContaining({ + scope, + revocationAnchorCreatedAtMs: originalTicket.createdAtMs, + }), + ); + const otherCaller = await callWeb( + { + jsonrpc: "2.0", + id: 1391, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { sourceTicket: "minted-picker-ticket" }, + }, + }, + { + headers: await mcpAppsAuthHeaders({ ownerEmail: "other@example.com" }), + config: regularConfig, + }, + ); + expect(otherCaller.result.isError).toBe(true); + const otherResource = await callWeb( + { + jsonrpc: "2.0", + id: 1393, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { + sourceTicket: "minted-picker-ticket", + path: "/documents/doc-2", + }, + }, + }, + { headers, config: regularConfig }, + ); + expect(otherResource.result.isError).toBe(true); + const inPlace = await callWeb( + { + jsonrpc: "2.0", + id: 1392, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { + sourceTicket: "minted-picker-ticket", + renewInPlace: true, + }, + }, + }, + { headers, config: regularConfig }, + ); + expect(inPlace.result.structuredContent).toMatchObject({ renewed: true }); const legacyToolCall = await callWeb( { @@ -3590,6 +3831,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }); it("renews a scoped directory widget ticket after a saved-chat reload without embed metadata", async () => { + process.env.AGENT_NATIVE_MCP_APPS_INLINE = "0"; const createDocument = defineAction({ description: "Create one editable document.", parameters: {}, @@ -9045,8 +9287,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-app-embed", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9070,7 +9312,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", ).toBe("https://mail.agent-native.com/inbox"); expect(out.result._meta["agent-native/embedStart"]).toMatchObject({ startUrl: - "https://mail.agent-native.com/_agent-native/embed/start?ticket=test-ticket&__an_mcp_chat_bridge=1", + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", }); expect( JSON.stringify(out.result._meta["agent-native/openLink"]), @@ -9124,8 +9366,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-app-embed", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9171,7 +9413,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }, { headers: await mcpAppsFullCatalogHeaders(), - config: embedConfig, + config: scopedWidgetConfig(embedConfig), }, ); @@ -9184,7 +9426,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", ownerEmail: "oauth@example.com", orgId: undefined, targetPath: "/picker?mediaType=image&prompt=cat&__an_mcp_chat_bridge=1", - scope: null, + scope: expect.stringContaining("capability:mcp-directory-widget-read:"), + revocationAnchorCreatedAtMs: expect.any(Number), }); expect(out.result.content).toEqual([ { type: "text", text: "Assets picker ready." }, @@ -9249,8 +9492,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-embed-only", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9258,7 +9501,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(out.result._meta["agent-native/openLink"]).toBeUndefined(); expect(out.result._meta["agent-native/embedStart"]).toMatchObject({ startUrl: - "https://mail.agent-native.com/_agent-native/embed/start?ticket=only-ticket&__an_mcp_chat_bridge=1", + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", }); expect(JSON.stringify(out.result.content)).not.toContain("only-ticket"); expect(JSON.stringify(out.result.structuredContent)).not.toContain( @@ -9300,8 +9543,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-url-only-embed", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9309,7 +9552,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(out.result._meta["agent-native/openLink"]).toBeUndefined(); expect(out.result._meta["agent-native/embedStart"]).toMatchObject({ startUrl: - "https://mail.agent-native.com/_agent-native/embed/start?ticket=url-only-ticket&__an_mcp_chat_bridge=1", + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", }); expect(JSON.stringify(out.result.content)).not.toContain("url-only-ticket"); expect(JSON.stringify(out.result.structuredContent)).not.toContain( @@ -9353,8 +9596,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-root-embed", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9368,7 +9611,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }); expect(out.result._meta["agent-native/embedStart"]).toMatchObject({ startUrl: - "https://mail.agent-native.com/_agent-native/embed/start?ticket=root-ticket&__an_mcp_chat_bridge=1", + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", }); expect(JSON.stringify(out.result.content)).not.toContain("root-ticket"); expect(JSON.stringify(out.result.structuredContent)).not.toContain( @@ -9457,21 +9700,490 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(out.result.content[0].text).toContain("[hidden embed URL]"); }); - it("hands an app-only tool's start URL to the embed iframe in _meta, not structuredContent", async () => { - const embedConfig = { - ...config, - actions: { - create_embed_session: { - tool: { - description: "Create an embed session", - _meta: { ui: { visibility: ["app"] } }, + describe("ordinary inline resources", () => { + it("renews a production-profile widget on its trusted beta host and mount without changing scope", async () => { + process.env.APP_BASE_PATH = "/mail"; + const origin = "https://beta.mail.agent-native.com"; + const routes = await import("../server/embed-route.js"); + const actualRoutes = await vi.importActual< + typeof import("../server/embed-route.js") + >("../server/embed-route.js"); + const startPath = vi + .spyOn(routes, "buildEmbedStartPath") + .mockImplementation(actualRoutes.buildEmbedStartPath); + const serverConfig = scopedWidgetConfig({ + ...config, + widgetDomain: "https://mail.agent-native.com", + actions: { + "create-document": { + tool: { description: "Open a document." }, + readOnly: true, + mcpApp: { resource: embedApp({ title: "Document" }) }, + run: async () => ({ + id: "doc-1", + url: "/documents/doc-1", + embed: true, + }), }, - run: async () => ({ - startUrl: "/_agent-native/embed/start?ticket=embed-session-ticket", - targetPath: "/inbox", - expiresAt: 1735689600, + }, + }); + serverConfig.directoryProfile!.widgetDomain = + "https://mail.agent-native.com"; + const server = await createMCPServerForRequest( + serverConfig, + { + userEmail: "oauth@example.com", + orgId: null, + orgDomain: undefined, + identityAssurance: "user", + mcpCredentialIssuedAtMs: Date.now(), + }, + { origin, basePath: "/mail", inlineMcpApps: true }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "beta-renewal-test", + version: "1.0.0", + }); + try { + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + const created = await client.callTool({ + name: "create-document", + arguments: {}, + }); + expect(created.isError).not.toBe(true); + const sourceTicket = ( + created._meta?.["agent-native/widgetSource"] as { + sourceTicket: string; + } + ).sourceTicket; + const originalScope = + embedSessionMocks.createEmbedSessionTicket.mock.calls[0]?.[0]?.scope; + const renewed = await client.callTool({ + name: "create_embed_session", + arguments: { sourceTicket }, + }); + expect(renewed.isError).not.toBe(true); + expect(renewed._meta?.["agent-native/embedStart"]).toMatchObject({ + startUrl: `${origin}/mail/_agent-native/embed/start?ticket=minted-picker-ticket`, + }); + const renewedScope = + embedSessionMocks.createEmbedSessionTicket.mock.calls[1]?.[0]?.scope; + expect(renewedScope).toBe(originalScope); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(renewedScope, { + appId: "mail", + resourceUri: "ui://mail/shell-v69", + actionName: "fixture-widget-read", + allowedArgumentNames: ["id"], + args: { id: "fixture-document" }, + }), + ).toEqual({ id: "fixture-document" }); + expect( + normalizeMcpDirectoryWidgetReadActionArguments(renewedScope, { + appId: "mail", + resourceUri: "ui://mail/shell-v69", + actionName: "fixture-widget-read", + allowedArgumentNames: ["id"], + args: { id: "other-document" }, }), + ).toBeUndefined(); + } finally { + await Promise.all([client.close(), server.close()]); + startPath.mockRestore(); + } + }); + + it("returns a successful full-app link without minting when the request has no origin", async () => { + const run = vi.fn(async () => ({ + id: "doc-1", + url: "/documents/doc-1", + embed: true, + embedStartUrl: + "/_agent-native/embed/start?ticket=old-originless-ticket", + })); + const server = await createMCPServerForRequest( + scopedWidgetConfig({ + ...config, + actions: { + "view-screen": { + tool: { description: "Open the current document." }, + readOnly: true, + mcpApp: { resource: embedApp({ title: "Document" }) }, + run, + }, + }, + }), + { + userEmail: "oauth@example.com", + orgId: null, + orgDomain: undefined, + identityAssurance: "user", + mcpCredentialIssuedAtMs: Date.now(), + }, + { inlineMcpApps: true, transport: "stdio" }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "no-origin-inline-test", + version: "1.0.0", + }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + try { + const result = await client.callTool({ + name: "view-screen", + arguments: {}, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + id: "doc-1", + url: "/documents/doc-1", + }); + expect( + (result._meta?.["agent-native/openLink"] as { webUrl?: string }) + ?.webUrl, + ).toBe("/documents/doc-1"); + expect(result._meta?.["agent-native/embedStart"]).toBeUndefined(); + expect(result._meta?.["openai/outputTemplate"]).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain("old-originless-ticket"); + expect( + embedSessionMocks.createEmbedSessionTicket, + ).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledOnce(); + const renewal = await client.callTool({ + name: "create_embed_session", + arguments: { sourceTicket: "old-originless-ticket" }, + }); + expect(renewal.isError).toBe(true); + expect(JSON.stringify(renewal.content)).toContain( + "trusted request origin", + ); + } finally { + await Promise.all([client.close(), server.close()]); + } + }); + + it.each([ + ["view-screen", "embedStartUrl"], + ["context-manifest-get", "url"], + ["context-preview-get", "deepLinkUrl"], + ["unsupported-card", "startUrl"], + ])( + "uses a full-app link for unsupported %s and strips %s", + async (name, ticketField) => { + const run = vi.fn(async () => ({ + id: "doc-1", + embed: true, + url: "/documents/doc-1", + path: "/documents/doc-1", + [ticketField]: "/_agent-native/embed/start?ticket=previous-ticket", + })); + const serverConfig = { + ...config, + connectorCatalog: [name], + actions: { + [name]: { + tool: { + description: "Read the current artifact.", + _meta: { + ui: { resourceUri: `ui://mail/${name}` }, + "openai/outputTemplate": `ui://mail/${name}`, + }, + }, + readOnly: true, + mcpApp: { resource: embedApp({ title: "Artifact" }) }, + run, + }, + }, + }; + const headers = await mcpAppsAuthHeaders(); + const rpc = ( + id: number, + method: string, + params: Record, + ) => + callWeb( + { jsonrpc: "2.0", id, method, params }, + { config: serverConfig, headers }, + ); + const listed = await rpc(220, "tools/list", {}); + expect(listed.result.tools.map((tool: any) => tool.name)).toContain( + name, + ); + const descriptor = listed.result.tools.find( + (tool: any) => tool.name === name, + ); + expect(descriptor._meta?.ui?.resourceUri).toBeUndefined(); + expect(descriptor._meta?.["openai/outputTemplate"]).toBeUndefined(); + const resources = await rpc(221, "resources/list", {}); + expect(resources.result.resources).toEqual([]); + const read = await rpc(222, "resources/read", { + uri: `ui://mail/${name}/shell-v65`, + }); + expect(read.error?.message).toContain("MCP App resource not found"); + const called = await rpc(223, "tools/call", { name, arguments: {} }); + expect(called.result.isError).not.toBe(true); + expect(called.result.structuredContent).toMatchObject({ + id: "doc-1", + path: "/documents/doc-1", + }); + if (ticketField !== "url") + expect(called.result.structuredContent.url).toBe("/documents/doc-1"); + expect(called.result._meta["agent-native/openLink"].webUrl).toBe( + "https://mail.agent-native.com/documents/doc-1", + ); + expect(called.result._meta["agent-native/embedStart"]).toBeUndefined(); + expect(called.result._meta["openai/outputTemplate"]).toBeUndefined(); + expect(JSON.stringify(called.result)).not.toContain("previous-ticket"); + expect( + embedSessionMocks.createEmbedSessionTicket, + ).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledOnce(); + }, + ); + + it("keeps a static HTML resource without minting an embed session", async () => { + const headers = await mcpAppsAuthHeaders(); + const rpc = ( + id: number, + method: string, + params: Record, + ) => callWeb({ jsonrpc: "2.0", id, method, params }, { headers }); + const listed = await rpc(224, "tools/list", {}); + expect( + listed.result.tools.find((tool: any) => tool.name === "echo-thing") + ._meta.ui.resourceUri, + ).toBe("ui://mail/echo-thing/shell-v65"); + const resources = await rpc(225, "resources/list", {}); + expect( + resources.result.resources.map((resource: any) => resource.uri), + ).toContain("ui://mail/echo-thing/shell-v65"); + const read = await rpc(226, "resources/read", { + uri: "ui://mail/echo-thing/shell-v65", + }); + expect(read.result.contents[0].text).toContain("Mail review"); + const called = await rpc(227, "tools/call", { + name: "echo-thing", + arguments: { value: "hello" }, + }); + expect(called.result.isError).not.toBe(true); + expect(called.result._meta["openai/outputTemplate"]).toBe( + "ui://mail/echo-thing/shell-v65", + ); + expect(called.result._meta["agent-native/embedStart"]).toBeUndefined(); + expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled(); + }); + }); + + describe("create_embed_session inline capability", () => { + it.each([ + { visibility: undefined, inlineHeader: false }, + { visibility: ["app"], inlineHeader: false }, + { visibility: ["model", "app"], inlineHeader: true }, + ])( + "refuses ordinary connect calls with visibility $visibility and inline header $inlineHeader", + async ({ visibility, inlineHeader }) => { + process.env.AGENT_NATIVE_MCP_APPS_INLINE = "0"; + const run = vi.fn(async () => ({ + startUrl: "/_agent-native/embed/start?ticket=unused-session-ticket", + })); + const { client } = await createModernClient( + { + ...config, + connectorCatalog: ["echo-thing", "create_embed_session"], + actions: { + ...config.actions, + create_embed_session: { + tool: { + description: "Create an embed session", + ...(visibility ? { _meta: { ui: { visibility } } } : {}), + }, + readOnly: true, + run, + }, + }, + }, + { + requestHeaders: { + ...(await mcpAppsAuthHeaders({ scope: "mcp:read mcp:write" })), + ...(inlineHeader + ? { "x-agent-native-mcp-inline-apps": "1" } + : {}), + }, + }, + ); + try { + const names = (await client.listTools()).tools.map( + ({ name }) => name, + ); + expect(names).toContain("echo-thing"); + expect(names).not.toContain("create_embed_session"); + expect( + await client.callTool({ + name: "create_embed_session", + arguments: { path: "/inbox" }, + }), + ).toMatchObject({ + isError: true, + content: [ + { type: "text", text: "Unknown tool: create_embed_session" }, + ], + }); + expect(run).not.toHaveBeenCalled(); + expect( + embedSessionMocks.createEmbedSessionTicket, + ).not.toHaveBeenCalled(); + } finally { + await client.close(); + } + }, + ); + + it("omits the builtin helper from an ordinary connect catalog", async () => { + process.env.AGENT_NATIVE_MCP_APPS_INLINE = "0"; + const headers = await mcpAppsAuthHeaders({ scope: "mcp:read mcp:write" }); + const serverConfig = { ...config, builtinCrossAppTools: true }; + const listed = await callWeb( + { jsonrpc: "2.0", id: 210, method: "tools/list", params: {} }, + { config: serverConfig, headers }, + ); + expect(listed.result.tools.map((tool: any) => tool.name)).toContain( + "open_app", + ); + expect(listed.result.tools.map((tool: any) => tool.name)).not.toContain( + "create_embed_session", + ); + const called = await callWeb( + { + jsonrpc: "2.0", + id: 211, + method: "tools/call", + params: { name: "create_embed_session", arguments: { path: "/" } }, }, + { config: serverConfig, headers }, + ); + expect(called.result).toMatchObject({ + isError: true, + content: [{ type: "text", text: "Unknown tool: create_embed_session" }], + }); + }); + + it.each(["compact", "full", "app"] as const)( + "honors an explicit false inline signal in the %s catalog", + async (catalog) => { + const run = vi.fn(async () => ({ ready: true })); + const server = await createMCPServerForRequest( + { + ...config, + ...(catalog === "app" ? { catalogMode: "app" as const } : {}), + builtinCrossAppTools: true, + connectorCatalog: ["create_embed_session", "echo-thing"], + actions: { + ...config.actions, + create_embed_session: { + tool: { description: "Create an embed session" }, + readOnly: true, + run, + }, + }, + }, + { userEmail: "oauth@example.com", orgId: null, orgDomain: undefined }, + { + inlineMcpApps: false, + fullCatalog: catalog === "full", + }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "inline-capability-test", + version: "1.0.0", + }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + try { + expect( + (await client.listTools()).tools.map(({ name }) => name), + ).not.toContain("create_embed_session"); + expect( + await client.callTool({ + name: "create_embed_session", + arguments: {}, + }), + ).toMatchObject({ isError: true }); + expect(run).not.toHaveBeenCalled(); + } finally { + await Promise.all([client.close(), server.close()]); + } + }, + ); + + it("keeps tool search scoped to the inline-disabled request catalog", async () => { + process.env.AGENT_NATIVE_MCP_APPS_INLINE = "0"; + const actions = { + ...config.actions, + create_embed_session: { + tool: { description: "Create an embed session" }, + readOnly: true, + run: async () => ({ ready: true }), + }, + }; + const headers = await mcpAppsAuthHeaders({ scope: "mcp:read mcp:write" }); + const serverConfig = { + ...config, + connectorCatalog: ["echo-thing", "create_embed_session"], + actions: { + ...actions, + "tool-search": createToolSearchEntry(() => actions), + }, + }; + const search = async (name: string, id: number) => { + const called = await callWeb( + { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: "tool-search", arguments: { names: [name] } }, + }, + { config: serverConfig, headers }, + ); + expect(called.result.isError).not.toBe(true); + return JSON.parse(called.result.content[0].text); + }; + expect(await search("create_embed_session", 212)).toMatchObject({ + count: 0, + results: [], + }); + expect(await search("echo-thing", 213)).toMatchObject({ + count: 1, + results: [{ name: "echo-thing" }], + }); + }); + }); + + it("hands an inline app-only tool's start URL to the embed iframe in _meta, not structuredContent", async () => { + process.env.AGENT_NATIVE_MCP_APPS_INLINE = "0"; + const { getBuiltinCrossAppTools } = + await vi.importActual( + "./builtin-tools.js", + ); + const embedConfig = { + ...config, + actions: { + create_embed_session: getBuiltinCrossAppTools(config, { + origin: "https://mail.agent-native.com", + inlineMcpApps: true, + }).create_embed_session, }, }; @@ -9480,10 +10192,10 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", jsonrpc: "2.0", id: 160, method: "tools/call", - params: { name: "create_embed_session", arguments: {} }, + params: { name: "create_embed_session", arguments: { path: "/inbox" } }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, + headers: await firstPartyMcpAuthHeaders(), config: embedConfig, }, ); @@ -9491,15 +10203,37 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(out.error).toBeUndefined(); expect(out.result.structuredContent).toEqual({ targetPath: "/inbox", - expiresAt: 1735689600, + expiresAt: 1735689600000, }); expect(out.result._meta["agent-native/embedStart"]).toEqual({ - startUrl: "/_agent-native/embed/start?ticket=embed-session-ticket", - expiresAt: 1735689600, + startUrl: + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket", + expiresAt: 1735689600000, }); expect(JSON.stringify(out.result.content)).not.toContain( - "embed-session-ticket", + "minted-picker-ticket", + ); + expect(JSON.stringify(out.result.structuredContent)).not.toContain( + "minted-picker-ticket", + ); + expect(embedSessionMocks.createEmbedSessionTicket).toHaveBeenCalledWith( + expect.objectContaining({ targetPath: "/inbox", scope: null }), ); + embedSessionMocks.createEmbedSessionTicket.mockClear(); + const refused = await callWeb( + { + jsonrpc: "2.0", + id: 161, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { path: "/inbox", chrome: "capability:unsupported" }, + }, + }, + { config: embedConfig, headers: await firstPartyMcpAuthHeaders() }, + ); + expect(refused.result.isError).toBe(true); + expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled(); }); it("preserves complete mutation receipts while sanitizing model-visible structured results", async () => { @@ -9901,8 +10635,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-thread", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9920,7 +10654,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(scJson).not.toContain("1735689600"); expect(scJson).not.toContain("secret-upload-token"); expect(out.result._meta["agent-native/embedStart"].startUrl).toContain( - "open-thread-ticket", + "minted-picker-ticket", ); expect(JSON.stringify(out.result.content)).not.toContain( "open-thread-ticket", @@ -9963,8 +10697,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-deck-by-name", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); @@ -9973,7 +10707,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(out.result.structuredContent.openLink).toBeUndefined(); expect(out.result._meta["agent-native/embedStart"]).toMatchObject({ startUrl: - "https://mail.agent-native.com/_agent-native/embed/start?ticket=deck-name-ticket&__an_mcp_chat_bridge=1", + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", }); expect(JSON.stringify(out.result.content)).not.toContain( "https://mail.agent-native.com/deck", @@ -10036,8 +10770,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-thread-route", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); expect(routeOut.result._meta["agent-native/openLink"]).toMatchObject({ @@ -10055,8 +10789,8 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "open-route-with-params", arguments: {} }, }, { - headers: { "x-agent-native-mcp-full-catalog": "1" }, - config: embedConfig, + headers: await mcpAppsFullCatalogHeaders(), + config: scopedWidgetConfig(embedConfig), }, ); expect(paramsOut.result._meta["agent-native/openLink"]).toMatchObject({ diff --git a/packages/core/src/server/action-approval.ts b/packages/core/src/server/action-approval.ts index 352652be0d6..3a002a4f2da 100644 --- a/packages/core/src/server/action-approval.ts +++ b/packages/core/src/server/action-approval.ts @@ -16,20 +16,24 @@ export function actionApprovalRequiredError( name: string, caller: ActionCaller, serviceIdentity: boolean, + embedded = false, ): ActionContractError { // The browser client answers this code by minting its UI capability and // retrying once, so the app's own UI is never refused for lacking proof. - if (caller === "frontend") { + if (caller === "frontend" && !embedded) { return new ActionContractError( `"${name}" requires approval for these arguments, which only the signed-in app can give.`, { errorCode: UI_CAPABILITY_REQUIRED_ERROR_CODE, statusCode: 403 }, ); } - const message = serviceIdentity - ? `"${name}" requires human approval for these arguments, and a service identity can never give it. Ask a person to run this action from the app.` - : caller === "webmcp" - ? `"${name}" requires human approval for these arguments. WebMCP tool calls cannot grant that approval themselves — ask the user to confirm this action in chat, then call it there.` - : `"${name}" requires human approval for these arguments, which an HTTP action call cannot grant. Ask the user to approve it in the app's agent chat, or call it as an MCP tool, where the approval flow exists.`; + const message = + embedded || caller === "mcp-widget" || caller === "mcp-widget-write" + ? `"${name}" requires human approval for these arguments. Open the full app to approve and finish this action.` + : serviceIdentity + ? `"${name}" requires human approval for these arguments, and a service identity can never give it. Ask a person to run this action from the app.` + : caller === "webmcp" + ? `"${name}" requires human approval for these arguments. WebMCP tool calls cannot grant that approval themselves — ask the user to confirm this action in chat, then call it there.` + : `"${name}" requires human approval for these arguments, which an HTTP action call cannot grant. Ask the user to approve it in the app's agent chat, or call it as an MCP tool, where the approval flow exists.`; return new ActionContractError(message, { errorCode: "approval_required", statusCode: 409, diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 30f09ff24a1..43f193cde1d 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -16,7 +16,12 @@ const mockMarkerLanded = vi.hoisted(() => vi.fn(() => true)); const mockResolveOrgIdForEmail = vi.hoisted(() => vi.fn()); const mockResolveOrgScopeForEmail = vi.hoisted(() => vi.fn()); const mockResolveOrgByDomain = vi.hoisted(() => vi.fn()); -const mockGetSession = vi.hoisted(() => vi.fn(async () => null)); +const mockGetSession = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => null), +); +const mockGetBrowserCookieSession = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => null), +); const mockGetOrgContext = vi.hoisted(() => vi.fn(async () => ({ orgId: undefined })), ); @@ -25,6 +30,18 @@ const mockConsumeOneTimeJti = vi.hoisted(() => vi.fn(async () => false)); const mockResolveEmbedSessionFromRequest = vi.hoisted(() => vi.fn(async () => null), ); +const mockHasPresentedEmbedSessionCredential = vi.hoisted(() => + vi.fn( + (event: any) => + event._hasPresentedEmbedSessionCredential ?? + event._hasExplicitEmbedSessionCredential ?? + Boolean( + event._query?.__an_embed_token || + event._headers?.["x-agent-native-embed-target"] !== undefined || + event._query?.__an_embed_target !== undefined, + ), + ), +); const mockIsExpiredMcpDirectoryWidgetSessionRequest = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => false), ); @@ -50,6 +67,12 @@ vi.mock("h3", () => ({ defineEventHandler: (handler: any) => handler, getMethod: (event: any) => event._method ?? "GET", getQuery: (event: any) => event._query ?? {}, + getCookie: (event: any, name: string) => + event._headers?.cookie + ?.split(";") + .map((part: string) => part.trim()) + .find((part: string) => part.startsWith(`${name}=`)) + ?.slice(name.length + 1), readBody: async (event: any) => event._body, getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], getRequestHeader: (event: any, name: string) => @@ -103,11 +126,15 @@ vi.mock("../org/context.js", () => ({ vi.mock("./auth.js", () => ({ getSession: (...args: unknown[]) => mockGetSession(...args), + getBrowserCookieSession: (...args: unknown[]) => + mockGetBrowserCookieSession(...args), registerAuthPublicPaths: (...args: unknown[]) => mockRegisterAuthPublicPaths(...args), isLoopbackRequest: () => false, })); vi.mock("./embed-session.js", () => ({ + hasPresentedEmbedSessionCredential: (event: any) => + mockHasPresentedEmbedSessionCredential(event), isExpiredMcpDirectoryWidgetSessionRequest: (...args: unknown[]) => mockIsExpiredMcpDirectoryWidgetSessionRequest(...args), hasExplicitEmbedSessionCredential: (event: any) => @@ -155,6 +182,8 @@ describe("mountActionRoutes", () => { }); mockGetSession.mockReset(); mockGetSession.mockResolvedValue(null); + mockGetBrowserCookieSession.mockReset(); + mockGetBrowserCookieSession.mockResolvedValue(null); mockGetOrgContext.mockReset(); mockGetOrgContext.mockResolvedValue({ orgId: undefined }); mockVerifyA2ATokenWithClaims.mockReset(); @@ -162,6 +191,17 @@ describe("mountActionRoutes", () => { mockConsumeOneTimeJti.mockResolvedValue(false); mockResolveEmbedSessionFromRequest.mockReset(); mockResolveEmbedSessionFromRequest.mockResolvedValue(null); + mockHasPresentedEmbedSessionCredential.mockReset(); + mockHasPresentedEmbedSessionCredential.mockImplementation( + (event: any) => + event._hasPresentedEmbedSessionCredential ?? + event._hasExplicitEmbedSessionCredential ?? + Boolean( + event._query?.__an_embed_token || + event._headers?.["x-agent-native-embed-target"] !== undefined || + event._query?.__an_embed_target !== undefined, + ), + ); mockIsExpiredMcpDirectoryWidgetSessionRequest.mockReset(); mockIsExpiredMcpDirectoryWidgetSessionRequest.mockResolvedValue(false); mockHasUiActionCapability.mockReset(); @@ -508,8 +548,10 @@ describe("mountActionRoutes", () => { ), }; const owner = "owner@example.com"; - if (caller === "proven UI") + if (caller === "proven UI") { mockHasUiActionCapability.mockReturnValue(true); + mockGetBrowserCookieSession.mockResolvedValue({ email: owner } as any); + } mountActionRoutes( nitroApp, @@ -583,6 +625,222 @@ describe("mountActionRoutes", () => { }); }); + it.each([ + { + name: "browser cookie and same-origin fetch", + browser: "owner@example.com", + headers: { "sec-fetch-site": "same-origin" }, + runs: true, + }, + { + name: "browser cookie and matching Origin", + browser: "owner@example.com", + headers: { origin: "https://app.example.com" }, + runs: true, + }, + { + name: "browser cookie without CSRF evidence", + browser: "owner@example.com", + headers: {}, + runs: false, + }, + { + name: "general custom session and forged same-origin fetch", + headers: { "sec-fetch-site": "same-origin" }, + runs: false, + }, + { + name: "general bearer session without Origin", + headers: { authorization: "Bearer test-token" }, + runs: false, + }, + { + name: "general bearer session and forged same-origin fetch", + headers: { + authorization: "Bearer test-token", + "sec-fetch-site": "same-origin", + }, + runs: false, + }, + { + name: "bearer with real same-user browser cookie", + browser: "owner@example.com", + headers: { + authorization: "Bearer test-token", + "sec-fetch-site": "same-origin", + }, + runs: false, + }, + { + name: "different browser-cookie identity", + browser: "other@example.com", + headers: { "sec-fetch-site": "same-origin" }, + runs: false, + }, + { + name: "browser cookie without UI capability", + browser: "owner@example.com", + headers: { "sec-fetch-site": "same-origin" }, + capability: false, + runs: false, + }, + { + name: "card and forged same-origin fetch", + embedded: true, + headers: { "sec-fetch-site": "same-origin" }, + runs: false, + }, + { + name: "card with real same-user browser cookie and UI capability", + embedded: true, + browser: "owner@example.com", + headers: { "sec-fetch-site": "same-origin" }, + runs: false, + }, + ])("requires browser identity and UI proof: $name", async (testCase) => { + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const owner = "owner@example.com"; + mockGetSession.mockResolvedValue({ email: owner } as any); + mockGetBrowserCookieSession.mockResolvedValue( + testCase.browser ? ({ email: testCase.browser } as any) : null, + ); + mockHasUiActionCapability.mockReturnValue(testCase.capability !== false); + if (testCase.embedded) { + mockResolveEmbedSessionFromRequest.mockResolvedValue({ + email: owner, + token: "signed-card-token", + targetPath: "/page/doc-1", + scope: "full", + } as any); + } + const run = vi.fn(async () => ({ ok: true })); + mountActionRoutes( + { use: (path: string, handler: any) => mounted.push({ path, handler }) }, + { "send-message": { run, needsApproval: true } as any }, + { + getOwnerFromEvent: async (event) => + ((await mockGetSession(event)) as any).email, + }, + ); + const event: any = { + _method: "POST", + _headers: { + host: "app.example.com", + "x-agent-native-frontend": "1", + cookie: + "better-auth.session_token=test-session; an_ui_capability=test-capability", + ...testCase.headers, + }, + req: { json: async () => ({}) }, + }; + const result = await mounted[0]!.handler(event); + expect(mockGetSession).toHaveBeenCalled(); + if (testCase.runs) { + expect(result).toEqual({ ok: true }); + expect(run).toHaveBeenCalledOnce(); + return; + } + expect(run).not.toHaveBeenCalled(); + expect(result.errorCode).toBe( + testCase.embedded ? "approval_required" : "ui_capability_required", + ); + if (testCase.embedded) { + expect(event._status).toBe(409); + expect(result.error).toContain("Open the full app"); + } + }); + + it.each( + (["valid", "expired", "invalid"] as const).flatMap((credential) => + [true, false].map((needsApproval) => ({ credential, needsApproval })), + ), + )( + "keeps a real $credential embed cookie separate from browser authority (approval: $needsApproval)", + async ({ credential, needsApproval }) => { + const embedSessions = + await vi.importActual( + "./embed-session.js", + ); + const db = await import("../db/client.js"); + const { EMBED_SESSION_COOKIE } = await import("../shared/embed-auth.js"); + const { mountActionRoutes } = await import("./action-routes.js"); + vi.spyOn(embedSessions.embedSessionsStore, "ready").mockResolvedValue( + undefined, + ); + vi.spyOn(db, "getDbExec").mockReturnValue({ + execute: vi.fn(async () => ({ rows: [], rowsAffected: 0 })), + } as any); + mockResolveEmbedSessionFromRequest.mockImplementation( + embedSessions.resolveEmbedSessionFromRequest as any, + ); + mockHasPresentedEmbedSessionCredential.mockImplementation( + embedSessions.hasPresentedEmbedSessionCredential, + ); + const owner = "owner@example.com"; + mockGetBrowserCookieSession.mockResolvedValue({ email: owner } as any); + mockHasUiActionCapability.mockReturnValue(true); + const issuedAt = + credential === "expired" + ? vi.spyOn(Date, "now").mockReturnValue(Date.now() - 120_000) + : undefined; + const token = + credential === "invalid" + ? "unrelated-stale-cookie" + : embedSessions.signEmbedSessionToken({ + ownerEmail: owner, + audienceHost: "app.example.com", + targetPath: "/page/doc-1", + ttlSeconds: 60, + }); + issuedAt?.mockRestore(); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(async () => ({ ok: true })); + const getOwnerFromEvent = vi.fn(async () => owner); + mountActionRoutes( + { + use: (path: string, handler: any) => mounted.push({ path, handler }), + }, + { "update-document": { run, needsApproval } as any }, + { getOwnerFromEvent }, + ); + const event: any = { + _method: "POST", + _headers: { + host: "app.example.com", + origin: "https://app.example.com", + "sec-fetch-site": "same-origin", + "x-agent-native-frontend": "1", + cookie: `better-auth.session_token=test-session; an_ui_capability=test-capability; ${EMBED_SESSION_COOKIE}=${token}`, + }, + req: { + method: "POST", + url: "https://app.example.com/_agent-native/actions/update-document", + json: async () => ({ id: "doc-1" }), + }, + }; + await expect( + embedSessions.resolveEmbedSessionFromRequest(event), + ).resolves.toBeNull(); + expect(embedSessions.hasPresentedEmbedSessionCredential(event)).toBe( + credential !== "invalid", + ); + if (credential === "invalid") { + await expect(mounted[0]!.handler(event)).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledOnce(); + expect(getOwnerFromEvent).toHaveBeenCalledOnce(); + } else { + await expect(mounted[0]!.handler(event)).rejects.toMatchObject({ + statusCode: 401, + }); + expect(getOwnerFromEvent).not.toHaveBeenCalled(); + expect(mockGetBrowserCookieSession).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + } + expect(mockResolveEmbedSessionFromRequest).toHaveBeenCalledOnce(); + }, + ); + it.each(["GET", "HEAD", "OPTIONS"] as const)( "does not treat a frontend POST as a %s action call", async (method) => { @@ -1636,60 +1894,75 @@ describe("mountActionRoutes", () => { ); }); - it("propagates a verified capability to a public action without impersonating its owner", async () => { - const { mountActionRoutes } = await import("./action-routes.js"); - const { getRequestAuthCapability, getRequestUserEmail } = - await import("./request-context.js"); - const mounted: Array<{ path: string; handler: any }> = []; - const nitroApp = { - use: vi.fn((path: string, handler: any) => - mounted.push({ path, handler }), - ), - }; - mockResolveEmbedSessionFromRequest.mockResolvedValue({ - email: "ticket-owner@example.com", - token: "signed-capability", - targetPath: "/visual-edit/design_1", - scope: "capability:visual-edit:design:design_1", - }); - const unauthenticated = Object.assign(new Error("Unauthenticated"), { - statusCode: 401, - }); + it.each([ + { requestedId: "design_1", browserUser: undefined }, + { requestedId: "design_2", browserUser: undefined }, + { requestedId: "design_1", browserUser: "browser@example.com" }, + { requestedId: "design_2", browserUser: "browser@example.com" }, + ])( + "binds a public Design read to $requestedId with browser identity $browserUser", + async ({ requestedId, browserUser }) => { + const { mountActionRoutes } = await import("./action-routes.js"); + const { getRequestAuthCapability, getRequestUserEmail } = + await import("./request-context.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + mockResolveEmbedSessionFromRequest.mockResolvedValue({ + email: "ticket-owner@example.com", + token: "signed-capability", + targetPath: "/visual-edit/design_1", + scope: "capability:visual-edit:design:design_1", + }); + const unauthenticated = Object.assign(new Error("Unauthenticated"), { + statusCode: 401, + }); + const run = vi.fn(async (_args, ctx) => ({ + ctxUserEmail: ctx?.userEmail, + requestUserEmail: getRequestUserEmail(), + authCapability: getRequestAuthCapability(), + })); - mountActionRoutes( - nitroApp, - { - "get-design": { - http: { method: "GET" }, - readOnly: true, - requiresAuth: false, - run: vi.fn(async (_args, ctx) => ({ - ctxUserEmail: ctx?.userEmail, - requestUserEmail: getRequestUserEmail(), - authCapability: getRequestAuthCapability(), - })), - } as any, - }, - { - getOwnerFromEvent: async () => { - throw unauthenticated; + mountActionRoutes( + nitroApp, + { + "get-design": { + http: { method: "GET" }, + readOnly: true, + requiresAuth: false, + run, + } as any, }, - }, - ); + { + getOwnerFromEvent: async () => { + if (browserUser) return browserUser; + throw unauthenticated; + }, + }, + ); - await expect( - mounted[0]!.handler({ + const result = await mounted[0]!.handler({ _method: "GET", + _query: { id: requestedId, designId: "design_1" }, req: { - url: "http://app.test/_agent-native/actions/get-design?id=design_1", + url: `http://app.test/_agent-native/actions/get-design?id=${requestedId}&designId=design_1`, }, - }), - ).resolves.toEqual({ - ctxUserEmail: undefined, - requestUserEmail: undefined, - authCapability: "capability:visual-edit:design:design_1", - }); - }); + }); + if (requestedId !== "design_1") { + expect(result).toEqual({ error: "Unauthorized" }); + expect(run).not.toHaveBeenCalled(); + return; + } + expect(result).toEqual({ + ctxUserEmail: browserUser, + requestUserEmail: browserUser, + authCapability: "capability:visual-edit:design:design_1", + }); + }, + ); it("does not let a capability satisfy account-backed write action auth", async () => { const { mountActionRoutes } = await import("./action-routes.js"); @@ -2729,7 +3002,10 @@ describe("mountActionRoutes", () => { const dataRoutesOnly = "This widget capability only permits its scoped data routes."; - async function mountShareRoutes() { + async function mountShareRoutes( + needsApproval?: ActionEntry["needsApproval"], + access?: ActionEntry["access"], + ) { const { mountActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; const run = vi.fn(async (_args: unknown, context: any) => ({ @@ -2772,6 +3048,8 @@ describe("mountActionRoutes", () => { http: { method: "POST" }, readOnly: false, requiresAuth: true, + needsApproval, + access, schema: schemas[name], tool: { parameters: { type: "object", properties: {} } }, run, @@ -2781,6 +3059,8 @@ describe("mountActionRoutes", () => { http: { method: "GET" }, readOnly: true, requiresAuth: true, + needsApproval, + access, run, }) as any; mountActionRoutes( @@ -2831,13 +3111,14 @@ describe("mountActionRoutes", () => { name: string, args: Record, method: "GET" | "POST" = "POST", + headers: Record = { origin: "null" }, ) => { const query = new URLSearchParams( Object.entries(args).map(([key, value]) => [key, String(value)]), ); const event: any = { _method: method, - _headers: { "x-agent-native-frontend": "1" }, + _headers: { "x-agent-native-frontend": "1", ...headers }, req: { url: `http://app.test/_agent-native/actions/${name}${method === "GET" ? `?${query}` : ""}`, json: async () => args, @@ -2894,7 +3175,118 @@ describe("mountActionRoutes", () => { }); } - it("runs each share write for the ticketed document as the widget-write caller", async () => { + it.each(["read", "write"] as const)( + "requires approval for a scoped %s card even with matching browser credentials", + async (scope) => { + await widgetSession(scope); + mockGetBrowserCookieSession.mockResolvedValue({ email: OWNER } as any); + mockHasUiActionCapability.mockReturnValue(true); + const { call, run } = await mountShareRoutes(true); + const { status, result } = await call( + scope === "write" ? "share-resource" : "list-resource-shares", + scope === "write" + ? shareBodies["share-resource"]! + : readArguments["list-resource-shares"], + scope === "write" ? "POST" : "GET", + { + host: "app.example.com", + "sec-fetch-site": "same-origin", + cookie: + "better-auth.session_token=test-session; an_ui_capability=test-capability", + }, + ); + expect(status).toBe(409); + expect(result).toMatchObject({ errorCode: "approval_required" }); + expect(result.error).toContain("Open the full app"); + expect(run).not.toHaveBeenCalled(); + }, + ); + + it.each(["read", "write"] as const)( + "evaluates a scoped %s card's caller-dependent approval as MCP", + async (scope) => { + await widgetSession(scope); + const gate = vi.fn((_args, ctx) => ctx?.caller === "mcp"); + const { call, run } = await mountShareRoutes(gate); + const name = + scope === "write" ? "share-resource" : "list-resource-shares"; + const args = + scope === "write" + ? shareBodies[name]! + : readArguments["list-resource-shares"]; + const { status, result } = await call( + name, + args, + scope === "write" ? "POST" : "GET", + ); + expect(status).toBe(409); + expect(result).toMatchObject({ errorCode: "approval_required" }); + expect(gate).toHaveBeenCalledWith( + args, + expect.objectContaining({ caller: "mcp", userEmail: OWNER }), + ); + expect(run).not.toHaveBeenCalled(); + }, + ); + + it.each(["read", "write"] as const)( + "allows a nongated scoped %s card with Origin:null", + async (scope) => { + await widgetSession(scope); + const gate = vi.fn(() => false); + const { call, run } = await mountShareRoutes(gate); + const name = + scope === "write" ? "share-resource" : "list-resource-shares"; + const args = + scope === "write" + ? shareBodies[name]! + : readArguments["list-resource-shares"]; + const { status, result } = await call( + name, + args, + scope === "write" ? "POST" : "GET", + { origin: "null" }, + ); + expect(status).toBe(200); + expect(result).toMatchObject({ + ok: true, + caller: scope === "write" ? "mcp-widget-write" : "mcp-widget", + }); + expect(gate).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledOnce(); + }, + ); + + it.each([null, "viewer", "editor"] as const)( + "checks normal resource access before refusing approval for a card: %s", + async (role) => { + await widgetSession("write"); + const sharingAccess = await import("../sharing/access.js"); + const resolveAccess = vi + .spyOn(sharingAccess, "resolveAccess") + .mockResolvedValue(role ? ({ role } as any) : null); + const { call, run } = await mountShareRoutes(true, { + resource: { type: "document", idFrom: "resourceId", level: "editor" }, + }); + const { status, result } = await call( + "share-resource", + shareBodies["share-resource"]!, + ); + expect(status).toBe(role === "editor" ? 409 : 403); + if (role === "editor") + expect(result.errorCode).toBe("approval_required"); + else expect(result.error).toContain("Requires editor access"); + expect(resolveAccess).toHaveBeenCalledWith( + "document", + DOCUMENT_ID, + { userEmail: OWNER, orgId: ORG }, + { skipResourceBody: true }, + ); + expect(run).not.toHaveBeenCalled(); + }, + ); + + it("runs each nongated share write with Origin:null for the ticketed document", async () => { await widgetSession("write"); const { call, run } = await mountShareRoutes(); @@ -3527,7 +3919,7 @@ describe("mountActionRoutes", () => { } }); - it("runs the Slides share actions as mcp-widget-write for the granted deck only, without needsApproval", async () => { + it("runs nongated Slides share actions within their granted deck", async () => { const { createMcpDirectoryWidgetWriteCapability } = await import("../shared/embed-auth.js"); const { mountActionRoutes } = await import("./action-routes.js"); @@ -3535,7 +3927,6 @@ describe("mountActionRoutes", () => { await import("../../../../templates/slides/server/lib/chatgpt-directory-tools.js"); const mounted: Array<{ path: string; handler: any }> = []; const run = vi.fn(async () => ({ ok: true })); - const needsApproval = vi.fn(async () => true); const shareModules = { "list-resource-shares": await import("../sharing/actions/list-resource-shares.js"), @@ -3548,7 +3939,7 @@ describe("mountActionRoutes", () => { const shareActions = Object.fromEntries( Object.entries(shareModules).map(([name, { default: action }]) => [ name, - { ...action, run, needsApproval }, + { ...action, run, needsApproval: false }, ]), ); // Mirrors the mcpDirectoryWidget*ActionArguments agent-chat-plugin derives @@ -3692,7 +4083,6 @@ describe("mountActionRoutes", () => { }, }), ); - expect(needsApproval).not.toHaveBeenCalled(); run.mockClear(); const unshareBody = { @@ -3713,7 +4103,6 @@ describe("mountActionRoutes", () => { post("set-resource-visibility", visibilityBody), ).resolves.toMatchObject({ status: 200 }); expect(run).toHaveBeenCalledTimes(2); - expect(needsApproval).not.toHaveBeenCalled(); run.mockClear(); const denied = [ @@ -3736,7 +4125,6 @@ describe("mountActionRoutes", () => { expect([name, body, status]).toEqual([name, body, 403]); } expect(run).not.toHaveBeenCalled(); - expect(needsApproval).not.toHaveBeenCalled(); const read = async (resourceId: string) => { const event: any = { @@ -3772,70 +4160,87 @@ describe("mountActionRoutes", () => { expect(run).not.toHaveBeenCalled(); }); - it("rejects a capability request for a different design", async () => { - const { mountActionRoutes } = await import("./action-routes.js"); - const mounted: Array<{ path: string; handler: any }> = []; - const run = vi.fn(async () => ({ ok: true })); - mockResolveEmbedSessionFromRequest - .mockResolvedValueOnce({ - email: "ticket-owner@example.com", - token: "signed-capability", - targetPath: "/visual-edit/design_1", - scope: "capability:visual-edit:design:design_1", - }) - .mockResolvedValueOnce({ - email: "ticket-owner@example.com", - token: "signed-capability", - targetPath: "/visual-edit/design%2F1", - scope: "capability:visual-edit:design:design%2F1", + it.each([undefined, "browser-user@example.com"])( + "rejects a capability request for a different design with ambient owner %s", + async (browserOwner) => { + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(async () => ({ ok: true })); + const headers = { + "x-agent-native-frontend": "1", + "sec-fetch-site": "same-origin", + ...(browserOwner + ? { cookie: "better-auth.session_token=test-session" } + : {}), + }; + if (browserOwner) { + mockGetBrowserCookieSession.mockResolvedValue({ + email: browserOwner, + } as any); + mockHasUiActionCapability.mockReturnValue(true); + } + mockResolveEmbedSessionFromRequest + .mockResolvedValueOnce({ + email: "ticket-owner@example.com", + token: "signed-capability", + targetPath: "/visual-edit/design_1", + scope: "capability:visual-edit:design:design_1", + }) + .mockResolvedValueOnce({ + email: "ticket-owner@example.com", + token: "signed-capability", + targetPath: "/visual-edit/design%2F1", + scope: "capability:visual-edit:design:design%2F1", + }); + const unauthenticated = Object.assign(new Error("Unauthenticated"), { + statusCode: 401, }); - const unauthenticated = Object.assign(new Error("Unauthenticated"), { - statusCode: 401, - }); - mountActionRoutes( - { - use: vi.fn((path: string, handler: any) => - mounted.push({ path, handler }), - ), - }, - { - "list-files": { - http: { method: "GET" }, - requiresAuth: true, - capabilityScopes: ["visual-edit"], - run, - } as any, - }, - { - getOwnerFromEvent: async () => { - throw unauthenticated; + mountActionRoutes( + { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), }, - }, - ); - - await expect( - mounted[0]!.handler({ - _method: "GET", - _headers: { "x-agent-native-frontend": "1" }, - req: { - url: "http://app.test/_agent-native/actions/list-files?designId=design_2", + { + "list-files": { + http: { method: "GET" }, + requiresAuth: true, + capabilityScopes: ["visual-edit"], + run, + } as any, }, - }), - ).resolves.toEqual({ error: "Unauthorized" }); - expect(run).not.toHaveBeenCalled(); - - await expect( - mounted[0]!.handler({ - _method: "GET", - _headers: { "x-agent-native-frontend": "1" }, - req: { - url: "http://app.test/_agent-native/actions/list-files?designId=design%2F1", + { + getOwnerFromEvent: async () => { + if (browserOwner) return browserOwner; + throw unauthenticated; + }, }, - }), - ).resolves.toEqual({ ok: true }); - expect(run).toHaveBeenCalledOnce(); - }); + ); + + await expect( + mounted[0]!.handler({ + _method: "GET", + _headers: headers, + req: { + url: "http://app.test/_agent-native/actions/list-files?designId=design_2", + }, + }), + ).resolves.toEqual({ error: "Unauthorized" }); + expect(run).not.toHaveBeenCalled(); + + await expect( + mounted[0]!.handler({ + _method: "GET", + _headers: headers, + req: { + url: "http://app.test/_agent-native/actions/list-files?designId=design%2F1", + }, + }), + ).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledOnce(); + }, + ); it("registers only capability-scoped action routes with the auth guard", async () => { const { mountActionRoutes } = await import("./action-routes.js"); @@ -5631,6 +6036,9 @@ describe("mountActionRoutes", () => { ); // Changing app access needs approval, which the signed-in app gives. mockHasUiActionCapability.mockReturnValue(true); + mockGetBrowserCookieSession.mockResolvedValue({ + email: "owner@example.com", + } as any); const handlerFor = (name: string) => mounted.find(({ path }) => path === `/_agent-native/actions/${name}`)! .handler; @@ -5660,7 +6068,10 @@ describe("mountActionRoutes", () => { await expect( handlerFor("set-workspace-app-access")({ _method: "POST", - _headers: { "x-agent-native-frontend": "1" }, + _headers: { + "x-agent-native-frontend": "1", + "sec-fetch-site": "same-origin", + }, context: {}, req: { url: "http://app.test/_agent-native/actions/set-workspace-app-access", diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index 62f4de27896..ccc764bea1d 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -9,7 +9,6 @@ import { readBody as readH3Body, } from "h3"; -import "../authorization/check-action.js"; import { verifyA2ATokenWithClaims } from "../a2a-claims.js"; import { isA2AIdentityVerificationUnavailableError } from "../a2a/server.js"; import { actionChangeResource } from "../action-call-classification.js"; @@ -37,6 +36,7 @@ import { LLM_MISSING_CREDENTIALS_MESSAGE, } from "../agent/engine/credential-errors.js"; import type { ActionEntry } from "../agent/production-agent.js"; +import { assertActionAccess } from "../authorization/check-action.js"; import { isTransientDatabaseError } from "../db/client.js"; import { declaresFeatureFlagDelegation } from "../feature-flags/a2a-action-route.js"; import { isFeatureFlagAdminEmail } from "../feature-flags/permissions.js"; @@ -112,7 +112,7 @@ import { readCorsAllowedOrigins, } from "./cors-origins.js"; import { - hasExplicitEmbedSessionCredential, + hasPresentedEmbedSessionCredential, isExpiredMcpDirectoryWidgetSessionRequest, resolveEmbedSessionFromRequest, resolvedEmbedCapabilityScope, @@ -152,7 +152,11 @@ function currentBuildId(): string { return configured?.trim() || "unknown"; } -import { isLoopbackRequest, registerAuthPublicPaths } from "./auth.js"; +import { + getBrowserCookieSession, + isLoopbackRequest, + registerAuthPublicPaths, +} from "./auth.js"; import { getH3App } from "./framework-request-handler.js"; import { isDirectLoopbackRequest } from "./loopback.js"; import { @@ -276,20 +280,27 @@ function isFrontendActionRequest(event: any): boolean { /** * The frontend header is only the client's claim. A request proves it came - * from this app's signed-in UI with a same-origin request that carries the - * short-lived UI capability the browser minted for the same user. + * from this app's signed-in UI with a browser-cookie identity, a same-origin + * request, and the short-lived UI capability minted for the same user. */ -function isProvenUiRequest( +async function isProvenUiRequest( event: any, caller: ActionCaller, userEmail: string | undefined, -): boolean { - return ( - caller === "frontend" && - Boolean(userEmail) && - isSameOriginRequest(event) && - hasUiActionCapability(event, userEmail) - ); + embedded: boolean, +): Promise { + if ( + caller !== "frontend" || + !userEmail || + embedded || + getHeader(event, "authorization") || + !isSameOriginRequest(event) || + !hasUiActionCapability(event, userEmail) + ) { + return false; + } + const session = await getBrowserCookieSession(event); + return session?.email.trim().toLowerCase() === userEmail.trim().toLowerCase(); } type CorsOrigin = { @@ -606,15 +617,17 @@ function allowsMcpDirectoryWidgetReadManifestAction( } function allowsWebMcpCapabilityResource( + actionName: string, authCapability: string | undefined, params: Record, ): boolean { const prefix = "capability:visual-edit:"; if (!authCapability?.startsWith(prefix)) return true; const match = /^design:([^:]+)$/.exec(authCapability.slice(prefix.length)); - if (!match || typeof params.designId !== "string") return false; + const designId = actionName === "get-design" ? params.id : params.designId; + if (!match || typeof designId !== "string") return false; try { - return decodeURIComponent(match[1]) === params.designId; + return decodeURIComponent(match[1]) === designId; } catch { // coercion-ok: malformed capability scope is invalid and must fail closed. return false; @@ -624,8 +637,14 @@ function allowsWebMcpCapabilityResource( async function resolveRequestAuthCapability( event: any, ): Promise { + return resolvedEmbedCapabilityScope(await resolveRequestEmbedSession(event)); +} + +async function resolveRequestEmbedSession( + event: any, +): Promise { const session = await resolveEmbedSessionFromRequest(event); - if (!session && hasExplicitEmbedSessionCredential(event)) { + if (!session && hasPresentedEmbedSessionCredential(event)) { if (await isExpiredMcpDirectoryWidgetSessionRequest(event)) { setResponseHeader( event, @@ -635,13 +654,7 @@ async function resolveRequestAuthCapability( } throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); } - return resolvedEmbedCapabilityScope(session); -} - -function resolveRequestEmbedSession( - event: any, -): Promise { - return resolveEmbedSessionFromRequest(event); + return session; } /** @@ -832,14 +845,14 @@ function mountActionRoutesInternal( let userEmail: string | undefined; let userName: string | undefined; let authUserId: string | undefined; - const authCapability = await resolveRequestAuthCapability(event); + const embedSession = await resolveRequestEmbedSession(event); + const authCapability = resolvedEmbedCapabilityScope(embedSession); + const embedded = + Boolean(embedSession) || hasPresentedEmbedSessionCredential(event); const directoryWidgetWriteCapability = isMcpDirectoryWidgetWriteCapabilityScope(authCapability); const directoryWidgetCapability = isMcpDirectoryWidgetCapabilityScope(authCapability); - const embedSession = directoryWidgetCapability - ? await resolveRequestEmbedSession(event) - : null; const directoryWidgetWriteGrant = directoryWidgetWriteCapability && embedSession ? getMcpDirectoryWidgetWriteCapabilityGrant(authCapability, { @@ -1225,16 +1238,19 @@ function mountActionRoutesInternal( : isFrontendActionRequest(event) ? "frontend" : "http"); - const provenUiRequest = isProvenUiRequest( + const provenUiRequest = await isProvenUiRequest( event, requestCaller, userEmail, + embedded, ); if (entry.uiOnly === true && !provenUiRequest) { setResponseStatus(event, 403); return { error: "This action can only be called from the signed-in app UI.", - errorCode: UI_CAPABILITY_REQUIRED_ERROR_CODE, + errorCode: embedded + ? "ui_only_action" + : UI_CAPABILITY_REQUIRED_ERROR_CODE, }; } const timezone = readTimezoneHeader(event); @@ -1467,9 +1483,7 @@ function mountActionRoutesInternal( params = normalizedArgs; } if ( - capabilityAllowed && - !userEmail && - !allowsWebMcpCapabilityResource(authCapability, params) + !allowsWebMcpCapabilityResource(name, authCapability, params) ) { throw createError({ statusCode: 401, @@ -1584,27 +1598,27 @@ function mountActionRoutesInternal( // Each reader below gets its own copy of the prepared // arguments; this one is for a failure's change marker. params = preparedCallArguments(prepared) as Record; - // Approval is given in the app: in its UI, or in an agent chat - // or MCP flow that runs the action in-process. No other HTTP - // caller can grant it. The app's UI is the proven browser app or - // its MCP directory widget, whose capability is scoped to the - // resources and actions the user is working on. + // Cards retain their scoped execution caller, but approval + // predicates must see their external MCP origin. if ( !provenUiRequest && - !directoryWidgetReadAllowed && - !directoryWidgetWriteAllowed && (await preparedCallNeedsApproval(entry, prepared, { - userEmail, - orgId: orgId ?? null, - appId: options?.appId, - caller, - actionName: name, + ...runContext, + caller: embedded ? "mcp" : caller, })) ) { + if (embedded && entry.access) { + await assertActionAccess( + entry.access, + preparedCallArguments(prepared), + runContext, + ); + } throw actionApprovalRequiredError( name, caller, Boolean(verifiedServiceIdentity), + embedded, ); } const result = await runPreparedActionCall( diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index 3ea19bf9e5f..d49072ae93b 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -8940,6 +8940,282 @@ describe("server/auth", () => { }); }); + describe("getBrowserCookieSession", () => { + const cookieOwner = "cookie-owner@example.com"; + const credentialOwner = "credential-owner@example.com"; + let getSessionApi: ReturnType; + + beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.AUTH_DISABLED; + delete process.env.ACCESS_TOKEN; + delete process.env.ACCESS_TOKENS; + getSessionApi = vi.fn(async ({ headers }: { headers: Headers }) => + headers.get("authorization") === "Bearer bearer-login" + ? { + user: { id: "bearer-user", email: credentialOwner }, + session: { token: "bearer-login" }, + } + : null, + ); + const betterAuth = { api: { getSession: getSessionApi } }; + vi.doMock("./better-auth-instance.js", async (importOriginal) => ({ + ...(await importOriginal()), + getBetterAuth: vi.fn(async () => betterAuth), + getBetterAuthSync: vi.fn(() => betterAuth), + resumeIdentityRekeysForEmail: vi.fn(async () => {}), + })); + const execute = vi.fn( + async (query: string | { sql: string; args?: unknown[] }) => { + const sql = typeof query === "string" ? query : query.sql; + const args = typeof query === "string" ? undefined : query.args; + const ledger = storeLedgerResult(sql); + if (ledger) return ledger; + if ( + sql.includes("SELECT email, created_at FROM sessions") && + ["legacy-login", "bearer-login", "query-login"].includes( + String(args?.[0]), + ) + ) { + return { + rows: [ + { + email: + args?.[0] === "legacy-login" + ? cookieOwner + : credentialOwner, + created_at: Date.now(), + }, + ], + }; + } + return { rows: [] }; + }, + ); + const db = { execute }; + vi.doMock("../db/client.js", () => ({ + getDbExec: () => db, + isLocalDatabase: () => true, + retryOnDdlRace: (fn: () => Promise) => fn(), + describeDbError: (error: unknown) => String(error), + })); + vi.doMock("./legacy-auth-migration.js", () => ({ + resolveCanonicalUserForLegacySession: vi.fn(async () => null), + })); + vi.doMock("../org/context.js", () => ({ + resolveOrgIdForEmailViaEvent: vi.fn(async () => "org-cookie"), + })); + vi.doMock("./embed-session.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEmbedSessionFromRequest: vi.fn(async (event: any) => + event.req.headers + .get("cookie") + ?.includes("an_embed_session=embed-login") + ? { email: credentialOwner, token: "embed-login", scope: "app" } + : null, + ), + })); + }); + + it.each([ + { + name: "bearer", + headers: { authorization: "Bearer bearer-login" }, + query: {}, + }, + { + name: "bearer with an unrelated cookie", + headers: { authorization: "Bearer bearer-login", cookie: "theme=dark" }, + query: {}, + }, + { + name: "embed", + headers: { cookie: "an_embed_session=embed-login" }, + query: {}, + }, + { name: "query", headers: {}, query: { _session: "query-login" } }, + ])( + "does not reuse a general $name identity as a browser cookie session", + async ({ headers, query }) => { + const { getSession, getBrowserCookieSession } = + await import("./auth.js"); + const event = createMockEvent({ + headers: { ...headers, "sec-fetch-site": "same-origin" }, + query, + }); + + await expect(getSession(event)).resolves.toMatchObject({ + email: credentialOwner, + }); + await expect(getBrowserCookieSession(event)).resolves.toBeNull(); + }, + ); + + it("does not resolve an identity from fetch metadata alone", async () => { + const { getBrowserCookieSession } = await import("./auth.js"); + await expect( + getBrowserCookieSession( + createMockEvent({ + headers: { "sec-fetch-site": "same-origin" }, + }), + ), + ).resolves.toBeNull(); + }); + + it("reads only the Better Auth cookie even when a bearer and embed identity are present", async () => { + getSessionApi.mockImplementation( + async ({ headers }: { headers: Headers }) => { + if (headers.get("authorization")) { + return { + user: { id: "bearer-user", email: credentialOwner }, + session: { token: "bearer-login" }, + }; + } + return headers + .get("cookie") + ?.includes("__Secure-an.session_token=cookie/login") + ? { + user: { + id: "cookie-user", + email: cookieOwner, + emailVerified: true, + }, + session: { token: "cookie/login" }, + } + : null; + }, + ); + const { getSession, getBrowserCookieSession } = await import("./auth.js"); + const event = createMockEvent({ + headers: { + cookie: + "__Secure-an.session_token=cookie%2Flogin; an_embed_session=embed-login", + authorization: "Bearer bearer-login", + "sec-fetch-site": "same-origin", + }, + }); + event.headers = new Headers(); + + await expect(getSession(event)).resolves.toMatchObject({ + email: credentialOwner, + }); + await expect(getBrowserCookieSession(event)).resolves.toMatchObject({ + email: cookieOwner, + authUserId: "cookie-user", + token: "cookie/login", + emailVerified: true, + orgId: "org-cookie", + }); + }); + + it("resolves a framework login cookie without using a bearer identity", async () => { + const { COOKIE_NAME, getBrowserCookieSession } = + await import("./auth.js"); + await expect( + getBrowserCookieSession( + createMockEvent({ + headers: { + cookie: `${COOKIE_NAME}=legacy-login`, + authorization: "Bearer bearer-login", + }, + }), + ), + ).resolves.toMatchObject({ + email: cookieOwner, + token: "legacy-login", + orgId: "org-cookie", + }); + }); + + it.each([false, true])( + "does not trust a custom session resolver (AUTH_DISABLED=%s)", + async (authDisabled) => { + if (authDisabled) vi.stubEnv("AUTH_DISABLED", "1"); + const { + autoMountAuth, + COOKIE_NAME, + getSession, + getBrowserCookieSession, + } = await import("./auth.js"); + await autoMountAuth(createMockApp(), { + getSession: async () => ({ email: credentialOwner }), + }); + const event = createMockEvent({ + headers: { cookie: `${COOKIE_NAME}=legacy-login` }, + }); + + await expect(getSession(event)).resolves.toMatchObject({ + email: credentialOwner, + }); + await expect(getBrowserCookieSession(event)).resolves.toBeNull(); + }, + ); + + it("does not use the desktop SSO fallback as a browser cookie session", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("AGENT_NATIVE_DISABLE_DESKTOP_SSO_FALLBACK", "0"); + vi.doMock("./desktop-sso.js", () => ({ + readDesktopSso: vi.fn(async () => ({ + email: credentialOwner, + token: "desktop-login", + })), + writeDesktopSso: vi.fn(), + clearDesktopSso: vi.fn(), + })); + try { + const { getSession, getBrowserCookieSession } = + await import("./auth.js"); + const event = createMockEvent({ + headers: { + "user-agent": + "Mozilla/5.0 Electron/41.2.2 AgentNativeDesktop/0.1.215", + }, + }); + event.req.context = { clientAddress: "127.0.0.1" }; + event.req.ip = "127.0.0.1"; + event.node.req.socket = { remoteAddress: "127.0.0.1" }; + event.node.req.connection = event.node.req.socket; + + await expect(getSession(event)).resolves.toMatchObject({ + email: credentialOwner, + token: "desktop-login", + }); + await expect(getBrowserCookieSession(event)).resolves.toBeNull(); + } finally { + vi.doUnmock("./desktop-sso.js"); + } + }); + + it.each(["1", "true"])( + "preserves AUTH_DISABLED=%s and its browser logout opt-out", + async (value) => { + vi.stubEnv("AUTH_DISABLED", value); + const { COOKIE_NAME, getBrowserCookieSession } = + await import("./auth.js"); + + await expect( + getBrowserCookieSession(createMockEvent()), + ).resolves.toMatchObject({ email: "dev@local.test" }); + await expect( + getBrowserCookieSession( + createMockEvent({ + headers: { cookie: `${COOKIE_NAME}_auth_disabled_opt_out=1` }, + }), + ), + ).resolves.toBeNull(); + await expect( + getBrowserCookieSession( + createMockEvent({ + headers: { + cookie: `${COOKIE_NAME}_auth_disabled_opt_out=1; ${COOKIE_NAME}=legacy-login`, + }, + }), + ), + ).resolves.toMatchObject({ email: cookieOwner, token: "legacy-login" }); + }, + ); + }); + describe("getSession", () => { it("rechecks the database after a legacy session is revoked", async () => { vi.stubEnv("NODE_ENV", "production"); diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index 9bd0f640a18..cf83ab7f3fb 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -4819,6 +4819,53 @@ export async function getSession(event: H3Event): Promise { return ctx.__anSessionCache; } +/** + * The browser's signed-in user, read only from the session cookies sign-in + * sets: Better Auth's and the framework's own. No other credential counts: + * not a bearer or embed token, a query token, a custom resolver, or the + * desktop fallback, nor any credential `getSession` accepts in future. For + * pages whose decisions only the person may make. An app with a custom + * resolver has no session here, since the framework can't tell which + * credentials that resolver reads. The framework cookie takes a raw login + * token, so a desktop or mobile app can present its own login here; that app + * is the user. With AUTH_DISABLED every request is the dev account, here too. + */ +export async function getBrowserCookieSession( + event: H3Event, +): Promise { + const session = await resolveBrowserCookieSession(event); + if (!session?.email) return null; + const resolved = await backfillSessionOrg(session, event); + await resumeIdentityRekeyForSession(resolved.email); + return resolved; +} + +async function resolveBrowserCookieSession( + event: H3Event, +): Promise { + if (customGetSession) return null; + const cookie = getHeader(event, "cookie"); + if (cookie) { + try { + const ba = getBetterAuthSync() ?? (await getBetterAuth()); + if (ba) { + const baSession = await ba.api.getSession({ + headers: new Headers({ cookie: decodeCookieHeader(cookie) }), + }); + if (baSession?.user?.email) return mapBetterAuthSession(baSession); + } + } catch (e) { + console.error("[auth] ba.api.getSession error:", e); + (event.context as Record)[ + SESSION_RESOLUTION_ERROR_CONTEXT_KEY + ] = true; + } + const cookieSession = await getLegacyCookieSessionSafely(event); + if (cookieSession) return cookieSession; + } + return getAuthDisabledSession(event); +} + async function resolveSessionUncached( event: H3Event, options: { ignoreEmbedSession?: boolean } = {}, diff --git a/packages/core/src/server/embed-route.spec.ts b/packages/core/src/server/embed-route.spec.ts index 5a0e7b70894..d738679b51d 100644 --- a/packages/core/src/server/embed-route.spec.ts +++ b/packages/core/src/server/embed-route.spec.ts @@ -134,7 +134,10 @@ describe("createEmbedStartRouteHandler", () => { expect(consumeEmbedSessionTicket).toHaveBeenCalledWith( "ticket-123", - expect.objectContaining({ expectedOwnerEmail: null }), + expect.objectContaining({ + audienceHost: "beta.calendar.agent-native.com", + expectedOwnerEmail: null, + }), ); expect(setEmbedSessionCookie).toHaveBeenCalledTimes(1); expect(signEmbedSessionToken).toHaveBeenCalledWith({ diff --git a/packages/core/src/server/embed-route.ts b/packages/core/src/server/embed-route.ts index 040760856a6..218387e0f38 100644 --- a/packages/core/src/server/embed-route.ts +++ b/packages/core/src/server/embed-route.ts @@ -337,6 +337,7 @@ export function createEmbedStartRouteHandler( .catch(() => null); let consumeDiagnostic: EmbedSessionTicketConsumeDiagnostic | null = null; const consumed = await consumeEmbedSessionTicket(ticket, { + audienceHost: getForwardedRequestHostname(event), expectedOwnerEmail: existingSession?.email ?? null, allowCapabilityIdentityMismatch: true, onResult: (diagnostic) => { diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index cac5a2fe8ef..9eb5f855332 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { H3Event } from "h3"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,11 +37,13 @@ import { normalizeEmbedTargetPath, requestHasEmbedAuthMarker, resolveEmbedSessionFromRequest, - consumeEmbedSessionTicket, - createEmbedSessionTicket, + consumeEmbedSessionTicket as consumeEmbedSessionTicketForAudience, + createEmbedSessionTicket as createEmbedSessionTicketForAudience, hasExplicitEmbedSessionCredential, - readMcpDirectoryWidgetRenewalTicket, - renewMcpDirectoryWidgetSession, + hasPresentedEmbedSessionCredential, + isExpiredMcpDirectoryWidgetSessionRequest, + readMcpDirectoryWidgetRenewalTicket as readMcpDirectoryWidgetRenewalTicketForAudience, + renewMcpDirectoryWidgetSession as renewMcpDirectoryWidgetSessionForAudience, revokeEmbedSessionsForOwner, revokeEmbedSessionsForOwners, resolveEmbedSessionCookieOwners, @@ -54,6 +56,92 @@ import { getRequestContext, runWithRequestContext } from "./request-context.js"; const ORIGINAL_ENV = { ...process.env }; +function ticketAudienceFixture( + audienceHost = "content.example.test", + audienceBasePath = "", +) { + return { audience_host: audienceHost, audience_base_path: audienceBasePath }; +} + +function createEmbedSessionTicket( + input: Parameters[0], +) { + return createEmbedSessionTicketForAudience({ + audienceHost: "content.example.test", + ...input, + }); +} + +function consumeEmbedSessionTicket( + ticket: string | undefined | null, + options: Parameters[1] = {}, +) { + return consumeEmbedSessionTicketForAudience(ticket, { + audienceHost: "content.example.test", + ...options, + }); +} + +function readMcpDirectoryWidgetRenewalTicket( + ticket: string, + options: { audienceHost?: string } = {}, +) { + return readMcpDirectoryWidgetRenewalTicketForAudience(ticket, { + audienceHost: "content.example.test", + ...options, + }); +} + +function renewMcpDirectoryWidgetSession( + input: Parameters[0], +) { + return renewMcpDirectoryWidgetSessionForAudience({ + audienceHost: "content.example.test", + ...input, + }); +} + +function installTicketStore() { + const rows = new Map>(); + dbExec.execute.mockImplementation(async ({ sql, args }: any) => { + if (sql.includes("INSERT INTO agent_native_embed_tickets")) { + rows.set(args[0], { + ticket_hash: args[0], + owner_email: args[1], + org_id: args[2], + target_path: args[3], + scope: args[4], + created_at: args[5], + expires_at: args[6], + consumed_at: args[7], + renewal_expires_at: args[8], + session_active_until: args[9], + audience_host: args[10], + audience_base_path: args[11], + }); + } + if (sql.includes("FROM agent_native_embed_tickets WHERE ticket_hash")) { + const row = rows.get(args[0]); + return { rows: row ? [{ ...row }] : [], rowsAffected: 0 }; + } + if (sql.startsWith("UPDATE agent_native_embed_tickets SET consumed_at")) { + const row = rows.get(args[2]); + if (!row || row.consumed_at !== null) + return { rows: [], rowsAffected: 0 }; + row.consumed_at = args[0]; + row.session_active_until = args[1]; + } + if (sql.startsWith("UPDATE agent_native_embed_tickets SET scope")) { + const row = rows.get(args[2]); + if (!row || row.scope !== args[3]) return { rows: [], rowsAffected: 0 }; + row.scope = args[0]; + row.session_active_until = args[1]; + } + return { rows: [], rowsAffected: 1 }; + }); + return rows; +} + function contentWidgetWriteScope({ userEmail = "owner@example.com", orgId, @@ -98,7 +186,7 @@ describe("embed session tokens", () => { afterEach(() => { vi.useRealTimers(); - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); it("round-trips signed owner/org claims", () => { @@ -182,6 +270,7 @@ describe("embed session tokens", () => { ticket_hash: "a".repeat(64), owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope, created_at: ticketCreatedAtMs, consumed_at: ticketCreatedAtMs, @@ -225,6 +314,7 @@ describe("embed session tokens", () => { { owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: contentWidgetWriteScope(), created_at: createdAt, expires_at: Date.now() + 60_000, @@ -264,6 +354,7 @@ describe("embed session tokens", () => { { owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: "capability:mcp-directory-widget-read:example", created_at: createdAtMs, expires_at: Date.now() + 60_000, @@ -303,6 +394,7 @@ describe("embed session tokens", () => { ticket_hash: "a".repeat(64), owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: contentWidgetWriteScope(), created_at: createdAt, expires_at: Date.now() + 60_000, @@ -415,7 +507,8 @@ describe("embed session tickets", () => { expect(inserted[0].args[7]).toBeNull(); expect(inserted[0].args[8]).toBe(Date.now() + 30 * 24 * 60 * 60 * 1000); expect(inserted[0].args[9]).toBeNull(); - expect(inserted[0].args).toHaveLength(10); + expect(inserted[0].args.slice(10)).toEqual(["content.example.test", ""]); + expect(inserted[0].args).toHaveLength(12); }); it("rejects an initial write-widget ticket authenticated before owner logout", async () => { @@ -559,7 +652,8 @@ describe("embed session tickets", () => { expect(inserted).toHaveLength(1); expect(inserted[0].args[8]).toBe(renewalExpiresAtMs); expect(inserted[0].args[9]).toBe(Date.now() + 15 * 60 * 1000); - expect(inserted[0].args).toHaveLength(10); + expect(inserted[0].args.slice(10)).toEqual(["content.example.test", ""]); + expect(inserted[0].args).toHaveLength(12); }); it("rejects renewing a widget ticket created before owner logout", async () => { @@ -684,6 +778,7 @@ describe("embed session tickets", () => { ticket_hash: "b".repeat(64), owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: "capability:mcp-directory-widget-write:example", created_at: createdAt, expires_at: expiresAt, @@ -715,6 +810,7 @@ describe("embed session tickets", () => { ticket_hash: "c".repeat(64), owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: "capability:mcp-directory-widget-read:example", created_at: createdAt, expires_at: createdAt + 15 * 60 * 1000, @@ -746,6 +842,7 @@ describe("embed session tickets", () => { ticket_hash: "d".repeat(64), owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: "capability:mcp-directory-widget-read:example", created_at: createdAt, expires_at: createdAt + 5 * 60 * 1000, @@ -919,6 +1016,7 @@ describe("embed session tickets", () => { owner_email: "owner@example.com", org_id: "owner-org", target_path: "/visual-edit/design-1?editorView=overview", + ...ticketAudienceFixture(), scope: "capability:visual-edit:design:design-1", created_at: Date.now(), expires_at: Date.now() + 60_000, @@ -943,7 +1041,7 @@ describe("embed session tickets", () => { describe("normalizeEmbedTargetPath", () => { afterEach(() => { - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); it("accepts same-origin absolute URLs and strips APP_BASE_PATH", () => { @@ -994,7 +1092,7 @@ describe("requestMatchesEmbedTarget", () => { }); afterEach(() => { - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); function fakeEvent( @@ -1037,6 +1135,685 @@ describe("requestMatchesEmbedTarget", () => { expect(requestMatchesEmbedTarget(event, "/page/unrelated")).toBe(false); }); + function credentialEvent( + path: string, + token: string, + transport: "bearer" | "query" | "cookie", + headers: Record = {}, + ) { + const url = new URL(path, "http://mail.test"); + if (transport === "query") url.searchParams.set("__an_embed_token", token); + return fakeEvent(`${url.pathname}${url.search}`, { + host: "mail.test", + origin: "null", + ...(transport === "bearer" ? { authorization: `Bearer ${token}` } : {}), + ...(transport === "cookie" + ? { cookie: `${EMBED_SESSION_COOKIE}=${token}` } + : {}), + ...headers, + }); + } + + it.each([ + ["/_agent-native/auth/session", "GET"], + ["/_agent-native/application-state", "GET"], + ["/_agent-native/actions/update-document", "POST"], + ["/api/emails", "GET"], + ])( + "rejects ordinary embed cookies on unrelated %s requests with target hints", + async (path, method) => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + scope: "full", + }); + const event = credentialEvent( + `${path}?__an_embed_target=%2Finbox`, + token, + "cookie", + { + [EMBED_TARGET_HEADER]: "/inbox", + referer: "http://mail.test/inbox?embedded=1", + }, + ); + event.req.method = method; + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(event)).toBe(false); + }, + ); + + it.each([undefined, "full", "minimal", "none"])( + "limits ordinary scope %s to its document and static runtime requests", + async (scope) => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox?documentId=doc-1", + scope, + }); + for (const transport of ["bearer", "query", "cookie"] as const) { + for (const path of [ + "/inbox?documentId=doc-1", + "/@vite/client", + "/app/entry.client.tsx", + "/assets/app.js", + ]) { + const event = credentialEvent(path, token, transport); + await expect( + resolveEmbedSessionFromRequest(event), + ).resolves.toMatchObject({ email: "owner@example.com" }); + expect(requestHasEmbedAuthMarker(event)).toBe(true); + } + for (const path of [ + "/inbox?documentId=doc-2", + "/inbox?documentId=doc-1&documentId=doc-2", + "/inbox", + "/settings", + "/api/emails", + "/_agent-native/actions/list-emails", + "/_agent-native/webmcp/actions/list-emails", + "/_agent-native/auth/session", + "/_agent-native/auth/session?__an_embed_target=%2Finbox%3FdocumentId%3Ddoc-1", + "/_agent-native/application-state", + "/_agent-native/db/query", + "/_agent-native/files", + "/_agent-native/agent/run", + "/app/private-data", + "/@other/runtime", + ]) { + const event = credentialEvent(path, token, transport, { + [EMBED_TARGET_HEADER]: "/inbox?documentId=doc-1", + referer: "http://mail.test/inbox?documentId=doc-1", + }); + await expect( + resolveEmbedSessionFromRequest(event), + ).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(event)).toBe(false); + } + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/inbox?documentId=doc-1", token, transport, { + host: "other.test", + }), + ), + ).resolves.toBeNull(); + } + }, + ); + + it.each(["read", "write", "visual-edit", "bootstrap"] as const)( + "resolves %s capability actions without a target hint or referrer", + async (kind) => { + const scope = + kind === "read" + ? createMcpDirectoryWidgetReadCapability({ + appId: "content", + resourceUri: "ui://content/shell-v66", + resourceIds: { documentId: "doc_123" }, + actionArguments: { "get-document": { documentId: "doc_123" } }, + }) + : kind === "write" + ? contentWidgetWriteScope() + : kind === "bootstrap" + ? `capability:visual-edit-bootstrap:${"a".repeat(32)}` + : "capability:visual-edit:design:design-1"; + if (!scope) throw new Error("Could not build capability fixture."); + const ticketCreatedAtMs = Date.now(); + if (kind === "write") { + dbExec.execute.mockImplementation(async ({ sql }: any) => ({ + rows: sql.includes("FROM agent_native_embed_tickets") + ? [ + { + ticket_hash: "a".repeat(64), + owner_email: "owner@example.com", + target_path: "/page/doc_123", + ...ticketAudienceFixture("mail.test"), + scope, + created_at: ticketCreatedAtMs, + consumed_at: ticketCreatedAtMs, + renewal_expires_at: ticketCreatedAtMs + 24 * 60 * 60 * 1000, + session_active_until: ticketCreatedAtMs + 60_000, + }, + ] + : [], + })); + } + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/page/doc_123", + scope, + ticketCreatedAtMs, + ...(kind === "write" ? { sessionId: "a".repeat(64) } : {}), + }); + for (const transport of ["bearer", "query", "cookie"] as const) { + for (const path of [ + "/page/doc_123", + "/@vite/client", + "/_agent-native/actions/get-document", + "/_agent-native/webmcp/actions/get-document", + ]) { + const event = credentialEvent(path, token, transport); + await expect( + resolveEmbedSessionFromRequest(event), + ).resolves.toMatchObject({ email: "owner@example.com", scope }); + expect(requestHasEmbedAuthMarker(event)).toBe(true); + expect(hasExplicitEmbedSessionCredential(event)).toBe(true); + } + for (const path of [ + "/page/other-document", + "/api/documents/doc_123", + "/_agent-native/application-state", + "/_agent-native/auth/session", + "/_agent-native/actions-other/get-document", + "/_agent-native/webmcp/actions-other/get-document", + ]) { + const event = credentialEvent(path, token, transport, { + [EMBED_TARGET_HEADER]: "/page/doc_123", + referer: "http://mail.test/page/doc_123", + }); + await expect( + resolveEmbedSessionFromRequest(event), + ).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(event)).toBe(false); + } + await expect( + resolveEmbedSessionFromRequest( + credentialEvent( + "/_agent-native/actions/get-document", + token, + transport, + { host: "other.test" }, + ), + ), + ).resolves.toBeNull(); + } + }, + ); + + it.each([ + [ + "/_agent-native/open?app=content&view=editor&documentId=doc-2", + "/_agent-native/open?app=content&view=editor&documentId=doc-1", + ], + [ + "/_agent-native/open?app=mail&view=editor&documentId=doc-1", + "/_agent-native/open?app=content&view=editor&documentId=doc-1", + ], + ["/inbox?documentId=doc-2", "/inbox?documentId=doc-1"], + ["/?documentId=doc-2", "/?documentId=doc-1"], + ["/?documentId=doc-2", "/"], + [ + "/inbox?documentId=doc-2", + "/_agent-native/open?view=inbox&documentId=doc-1", + ], + [ + "/inbox?documentId=doc-2", + "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1", + ], + [ + "/page/doc-1?documentId=doc-2", + "/_agent-native/open?view=editor&documentId=doc-1", + ], + ])( + "rejects resource selectors %s outside target %s", + async (path, targetPath) => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath, + }); + const event = credentialEvent(path, token, "query", { + [EMBED_TARGET_HEADER]: targetPath, + }); + expect(requestMatchesEmbedTarget(event, targetPath)).toBe(false); + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + }, + ); + + it("preserves signed resource queries through open redirects", async () => { + const targetPath = "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1"; + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath, + }); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent( + "/inbox?documentId=doc-1&embedded=1&agentSidebar=closed", + token, + "query", + ), + ), + ).resolves.toMatchObject({ targetPath }); + }); + + it.each([ + ["/inbox", undefined], + ["/@vite/client", undefined], + [ + "/_agent-native/actions/get-document", + "capability:visual-edit:design:design-1", + ], + ])("confines %s to the configured mounted app", async (path, scope) => { + process.env.APP_BASE_PATH = "/mail"; + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + scope, + }); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent(`/mail${path}`, token, "cookie"), + ), + ).resolves.toMatchObject({ email: "owner@example.com" }); + for (const prefix of ["", "/calendar", "/mailbox", "/mail/../calendar"]) { + const event = credentialEvent(`${prefix}${path}`, token, "cookie", { + [EMBED_TARGET_HEADER]: "/mail/inbox", + }); + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(event)).toBe(false); + } + const mounted = credentialEvent(path, token, "cookie"); + mounted.context = { _mountedPathname: `/mail${path}` }; + mounted.url = new URL(mounted.req.url); + await expect( + resolveEmbedSessionFromRequest(mounted), + ).resolves.toMatchObject({ email: "owner@example.com" }); + mounted.context._mountedPathname = `/calendar${path}`; + await expect(resolveEmbedSessionFromRequest(mounted)).resolves.toBeNull(); + }); + + it.each([ + ["/mail", "/calendar"], + ["", "/mail"], + ["/mail", ""], + ])( + "binds tokens issued at %s to that mount when config changes to %s", + async (sourceBase, otherBase) => { + process.env.APP_BASE_PATH = sourceBase ? `${sourceBase}/` : ""; + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox?documentId=doc-1", + }); + expect(verifyEmbedSessionToken(token)).toMatchObject({ + ok: true, + claims: { audienceBasePath: sourceBase }, + }); + for (const transport of ["bearer", "query", "cookie"] as const) { + process.env.APP_BASE_PATH = sourceBase; + const source = credentialEvent( + `${sourceBase}/inbox?documentId=doc-1`, + token, + transport, + ); + await expect( + resolveEmbedSessionFromRequest(source), + ).resolves.toMatchObject({ email: "owner@example.com" }); + expect(requestHasEmbedAuthMarker(source)).toBe(true); + await expect( + resolveEmbedSessionTokenForHost(token, "mail.test"), + ).resolves.toMatchObject({ audienceBasePath: sourceBase }); + + process.env.APP_BASE_PATH = otherBase; + const other = credentialEvent( + `${otherBase}/inbox?documentId=doc-1`, + token, + transport, + { + [EMBED_TARGET_HEADER]: `${otherBase}/inbox?documentId=doc-1`, + }, + ); + await expect(resolveEmbedSessionFromRequest(other)).resolves.toBeNull(); + await expect( + resolveEmbedSessionTokenForHost(token, "mail.test"), + ).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(other)).toBe(false); + } + }, + ); + + it("allows pre-mount audience claims only in an unmounted app", async () => { + process.env.OAUTH_STATE_SECRET = "embed-test-secret"; + const current = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + }); + const claims = JSON.parse( + Buffer.from(current.split(".")[0], "base64url").toString(), + ); + delete claims.audienceBasePath; + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const signature = createHmac("sha256", "embed-test-secret") + .update(payload) + .digest("base64url"); + const legacy = `${payload}.${signature}`; + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/inbox", legacy, "cookie"), + ), + ).resolves.toMatchObject({ email: "owner@example.com" }); + process.env.APP_BASE_PATH = "/mail"; + const mounted = credentialEvent("/mail/inbox", legacy, "cookie", { + [EMBED_TARGET_HEADER]: "/mail/inbox", + }); + await expect(resolveEmbedSessionFromRequest(mounted)).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(mounted)).toBe(false); + }); + + it("keeps renewed and expired widget sessions within their issuing mount", async () => { + vi.useFakeTimers(); + try { + process.env.APP_BASE_PATH = "/mail"; + const createdAtMs = Date.now(); + const scope = contentWidgetWriteScope(); + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/page/doc_123", + scope, + ticketCreatedAtMs: createdAtMs, + sessionId: "a".repeat(64), + ttlSeconds: 1, + }); + let activeUntilMs = createdAtMs + 60_000; + dbExec.execute.mockImplementation(async ({ sql }: any) => ({ + rows: sql.includes("FROM agent_native_embed_tickets") + ? [ + { + ticket_hash: "a".repeat(64), + owner_email: "owner@example.com", + target_path: "/page/doc_123", + ...ticketAudienceFixture("mail.test", "/mail"), + scope, + created_at: createdAtMs, + consumed_at: createdAtMs, + renewal_expires_at: createdAtMs + 24 * 60 * 60 * 1000, + session_active_until: activeUntilMs, + }, + ] + : [], + })); + vi.advanceTimersByTime(2_000); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent( + "/mail/_agent-native/actions/get-document", + token, + "cookie", + ), + ), + ).resolves.toMatchObject({ scope }); + activeUntilMs = Date.now() - 1; + await expect( + isExpiredMcpDirectoryWidgetSessionRequest( + credentialEvent( + "/mail/_agent-native/actions/get-document", + token, + "cookie", + ), + ), + ).resolves.toBe(true); + process.env.APP_BASE_PATH = "/calendar"; + const other = credentialEvent( + "/calendar/_agent-native/actions/get-document", + token, + "cookie", + { [EMBED_TARGET_HEADER]: "/calendar/page/doc_123" }, + ); + await expect( + isExpiredMcpDirectoryWidgetSessionRequest(other), + ).resolves.toBe(false); + activeUntilMs = Date.now() + 60_000; + await expect(resolveEmbedSessionFromRequest(other)).resolves.toBeNull(); + await expect( + resolveEmbedSessionTokenForHost(token, "mail.test"), + ).resolves.toBeNull(); + expect(requestHasEmbedAuthMarker(other)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["bearer", "cookie"] as const)( + "recognizes a presented ordinary %s session independently of route eligibility and expiry", + async (transport) => { + vi.useFakeTimers(); + try { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + ttlSeconds: 1, + }); + const event = credentialEvent( + "/_agent-native/actions/get-document", + token, + transport, + ); + event.req.method = "POST"; + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + expect(hasExplicitEmbedSessionCredential(event)).toBe(false); + expect(hasPresentedEmbedSessionCredential(event)).toBe(true); + vi.advanceTimersByTime(2_000); + expect(verifyEmbedSessionToken(token)).toMatchObject({ + ok: false, + reason: "expired", + }); + expect(hasPresentedEmbedSessionCredential(event)).toBe(true); + const invalid = credentialEvent( + "/_agent-native/actions/get-document", + "invalid-stale-token", + transport, + ); + expect(hasPresentedEmbedSessionCredential(invalid)).toBe(false); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("recognizes explicit embed markers without treating an unrelated cookie as embed credentials", () => { + expect( + hasPresentedEmbedSessionCredential( + fakeEvent( + "/_agent-native/actions/get-document?__an_embed_token=invalid", + ), + ), + ).toBe(true); + expect( + hasPresentedEmbedSessionCredential( + fakeEvent("/_agent-native/actions/get-document", { + [EMBED_TARGET_HEADER]: "/inbox", + }), + ), + ).toBe(true); + expect( + hasPresentedEmbedSessionCredential( + fakeEvent( + "/_agent-native/actions/get-document?__an_embed_target=%2Finbox", + ), + ), + ).toBe(true); + expect( + hasPresentedEmbedSessionCredential( + fakeEvent("/_agent-native/actions/get-document", { + cookie: `${EMBED_SESSION_COOKIE}=invalid-stale-cookie`, + }), + ), + ).toBe(false); + }); + + it.each(["mount", "host"] as const)( + "checks the ticket issuing %s before embed-start consumption", + async (change) => { + process.env.APP_BASE_PATH = "/mail"; + const rows = installTicketStore(); + const ticket = await runWithRequestContext( + { requestOrigin: "https://mail.test" }, + () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + }), + ); + expect(rows.get(ticket.ticketHash)).toMatchObject({ + audience_host: "mail.test", + audience_base_path: "/mail", + }); + const { createEmbedStartRouteHandler } = await import("./embed-route.js"); + const handler = createEmbedStartRouteHandler(); + process.env.APP_BASE_PATH = change === "mount" ? "/calendar" : "/mail"; + const other = fakeEvent( + `${process.env.APP_BASE_PATH}/_agent-native/embed/start?ticket=${ticket.ticket}`, + { + host: change === "host" ? "other.test" : "mail.test", + [EMBED_TARGET_HEADER]: `${process.env.APP_BASE_PATH}/inbox`, + }, + ); + other.req.method = "GET"; + const denied = (await handler(other)) as Response; + expect(denied.status).toBe(401); + expect(rows.get(ticket.ticketHash)?.consumed_at).toBeNull(); + + process.env.APP_BASE_PATH = "/mail"; + const source = fakeEvent( + `/mail/_agent-native/embed/start?ticket=${ticket.ticket}`, + { host: "mail.test" }, + ); + source.req.method = "GET"; + const accepted = (await handler(source)) as Response; + expect(accepted.status).toBe(302); + const location = new URL( + accepted.headers.get("location")!, + "http://mail.test", + ); + expect(location.pathname).toBe("/mail/inbox"); + expect( + verifyEmbedSessionToken(location.searchParams.get("__an_embed_token")), + ).toMatchObject({ + ok: true, + claims: { + audienceHost: "mail.test", + audienceBasePath: "/mail", + targetPath: "/inbox", + }, + }); + expect(rows.get(ticket.ticketHash)?.consumed_at).toEqual( + expect.any(Number), + ); + }, + ); + + it.each(["audience_host", "audience_base_path"])( + "requires a fresh ticket when legacy metadata %s is unknown", + async (column) => { + const rows = installTicketStore(); + const ticket = await createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + audienceHost: "mail.test", + }); + delete rows.get(ticket.ticketHash)![column]; + await expect( + consumeEmbedSessionTicketForAudience(ticket.ticket, { + audienceHost: "mail.test", + }), + ).resolves.toBeNull(); + expect(rows.get(ticket.ticketHash)?.consumed_at).toBeNull(); + }, + ); + + it("requires a trusted origin or explicit server host to mint an embed ticket", async () => { + installTicketStore(); + await expect( + runWithRequestContext({ requestOrigin: undefined }, () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + }), + ), + ).rejects.toThrow("trusted app audience"); + await expect( + runWithRequestContext({ requestOrigin: undefined }, () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + audienceHost: "mail.test", + }), + ), + ).resolves.toMatchObject({ ticket: expect.any(String) }); + }); + + it("binds source-ticket lookup and in-place renewal to the original host and mount", async () => { + process.env.APP_BASE_PATH = "/mail"; + const rows = installTicketStore(); + const scope = contentWidgetWriteScope(); + const ticket = await runWithRequestContext( + { requestOrigin: "https://mail.test" }, + () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/page/doc_123", + scope, + ttlSeconds: 300, + revocationAnchorCreatedAtMs: Date.now(), + }), + ); + await expect( + consumeEmbedSessionTicketForAudience(ticket.ticket, { + audienceHost: "mail.test", + }), + ).resolves.toMatchObject({ ownerEmail: "owner@example.com" }); + const original = { ...rows.get(ticket.ticketHash)! }; + for (const [base, host] of [ + ["/calendar", "mail.test"], + ["/mail", "other.test"], + ["", "mail.test"], + ]) { + process.env.APP_BASE_PATH = base; + await runWithRequestContext( + { requestOrigin: `https://${host}` }, + async () => { + await expect( + readMcpDirectoryWidgetRenewalTicketForAudience(ticket.ticket), + ).resolves.toBeNull(); + await expect( + renewMcpDirectoryWidgetSessionForAudience({ + sourceTicket: ticket.ticket, + ownerEmail: "owner@example.com", + expectedScope: scope, + renewedScope: scope, + }), + ).resolves.toBeNull(); + }, + ); + expect(rows.get(ticket.ticketHash)).toEqual(original); + } + process.env.APP_BASE_PATH = "/mail"; + await runWithRequestContext( + { requestOrigin: "https://mail.test" }, + async () => { + await expect( + readMcpDirectoryWidgetRenewalTicketForAudience(ticket.ticket), + ).resolves.toMatchObject({ targetPath: "/page/doc_123", scope }); + await expect( + renewMcpDirectoryWidgetSessionForAudience({ + sourceTicket: ticket.ticket, + ownerEmail: "owner@example.com", + expectedScope: scope, + renewedScope: scope, + }), + ).resolves.toBeGreaterThan(original.session_active_until); + }, + ); + }); + it("allows the route produced by an embedded open deep link", () => { expect( requestMatchesEmbedTarget( @@ -1153,7 +1930,7 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(true); }); - it("allows app runtime requests from the embedded target referrer", () => { + it("does not match runtime requests through an embedded target referrer", () => { expect( requestMatchesEmbedTarget( fakeEvent("/_agent-native/application-state/compose", { @@ -1162,7 +1939,7 @@ describe("requestMatchesEmbedTarget", () => { }), "/_agent-native/open?app=mail&view=inbox&composeDraftId=d1", ), - ).toBe(true); + ).toBe(false); expect( requestMatchesEmbedTarget( fakeEvent("/api/emails?view=inbox", { @@ -1231,7 +2008,7 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(false); }); - it("allows same-origin fetches only when the embed target header matches", () => { + it("does not match runtime requests through an embed target header", () => { expect( requestMatchesEmbedTarget( fakeEvent("/_agent-native/actions/list-emails", { @@ -1239,7 +2016,7 @@ describe("requestMatchesEmbedTarget", () => { }), "/_agent-native/open?app=mail&view=inbox", ), - ).toBe(true); + ).toBe(false); expect( requestMatchesEmbedTarget( fakeEvent("/api/emails?view=inbox&limit=25", { @@ -1247,7 +2024,7 @@ describe("requestMatchesEmbedTarget", () => { }), "/_agent-native/open?app=mail&view=inbox", ), - ).toBe(true); + ).toBe(false); expect( requestMatchesEmbedTarget( @@ -1259,7 +2036,7 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(false); }); - it("treats bearer embed tokens as embed auth markers for CORS headers", () => { + it("does not mark ordinary bearer sessions on action routes through target headers", () => { const token = signEmbedSessionToken({ ownerEmail: "owner@example.com", targetPath: "/picker?embedded=1", @@ -1274,7 +2051,7 @@ describe("requestMatchesEmbedTarget", () => { [EMBED_TARGET_HEADER]: "/picker?embedded=1", }), ), - ).toBe(true); + ).toBe(false); expect( requestHasEmbedAuthMarker( fakeEvent("/_agent-native/actions/list-libraries", { @@ -1286,7 +2063,7 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(false); }); - it("allows app runtime requests with the embed cookie when referrer headers are unavailable", async () => { + it("rejects app data requests with an ordinary embed cookie without referrer headers", async () => { process.env.OAUTH_STATE_SECRET = "embed-test-secret"; const token = signEmbedSessionToken({ ownerEmail: "owner@example.com", @@ -1302,11 +2079,7 @@ describe("requestMatchesEmbedTarget", () => { }), ); - expect(runtimeSession).toMatchObject({ - email: "owner@example.com", - orgId: "org_123", - targetPath: "/inbox?__an_mcp_chat_bridge=1", - }); + expect(runtimeSession).toBeNull(); await expect( resolveEmbedSessionFromRequest( @@ -1356,7 +2129,7 @@ describe("requestMatchesEmbedTarget", () => { cookie: `${EMBED_SESSION_COOKIE}=${embedCookie}`, }), ), - ).toBe(false); + ).toBe(true); expect( hasExplicitEmbedSessionCredential( fakeEvent("/_agent-native/actions/get-document", { @@ -1367,7 +2140,7 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(true); }); - it("resolves the embed owner for logout without a target referrer", async () => { + it("does not authenticate logout with an ordinary embed cookie", async () => { const token = signEmbedSessionToken({ ownerEmail: "owner@example.com", audienceHost: "mail.test", @@ -1382,7 +2155,7 @@ describe("requestMatchesEmbedTarget", () => { cookie: `${EMBED_SESSION_COOKIE}=${token}`, }), ), - ).resolves.toMatchObject({ email: "owner@example.com" }); + ).resolves.toBeNull(); }); it("revokes embed cookies across browser partitions after logout", async () => { @@ -1500,6 +2273,7 @@ describe("requestMatchesEmbedTarget", () => { { owner_email: "owner@example.com", target_path: "/inbox", + ...ticketAudienceFixture(), created_at: createdAt, expires_at: Date.now() + 60_000, consumed_at: null, @@ -1534,6 +2308,7 @@ describe("requestMatchesEmbedTarget", () => { ticket_hash: ticketHash, owner_email: "owner@example.com", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope, created_at: createdAt, expires_at: expiresAt, @@ -1574,6 +2349,7 @@ describe("requestMatchesEmbedTarget", () => { { owner_email: "owner@example.com", target_path: "/inbox", + ...ticketAudienceFixture(), created_at: createdAt, expires_at: Date.now() + 60_000, consumed_at: null, @@ -1806,46 +2582,25 @@ describe("requestMatchesEmbedTarget", () => { expect(event.responseHeaders.get("referrer-policy")).toBe("no-referrer"); }); - it("binds capability sessions to their visual-edit target on data requests", async () => { - process.env.OAUTH_STATE_SECRET = "embed-test-secret"; + it("allows visual-edit actions independently of target hints while retaining document scope", async () => { const token = signEmbedSessionToken({ ownerEmail: "owner@example.com", + audienceHost: "mail.test", targetPath: "/visual-edit/design-1?editorView=overview", scope: "capability:visual-edit:design:design-1", ttlSeconds: 60, }); - const matchingTarget = encodeURIComponent( - "/visual-edit/design-1?editorView=overview&embedded=1", - ); - - await expect( - resolveEmbedSessionFromRequest( - fakeEvent( - `/_agent-native/actions/get-design?__an_embed_token=${token}&${EMBED_TARGET_QUERY_PARAM}=${matchingTarget}`, - { host: "mail.test" }, - ), - ), - ).resolves.toMatchObject({ - scope: "capability:visual-edit:design:design-1", - targetPath: "/visual-edit/design-1?editorView=overview", - }); - - await expect( - resolveEmbedSessionFromRequest( - fakeEvent( - `/_agent-native/actions/get-design?__an_embed_token=${token}&${EMBED_TARGET_QUERY_PARAM}=${encodeURIComponent("/design/design-1")}`, - { host: "mail.test" }, - ), - ), - ).resolves.toBeNull(); - await expect( - resolveEmbedSessionFromRequest( - fakeEvent("/_agent-native/actions/get-design", { - host: "mail.test", - cookie: `${EMBED_SESSION_COOKIE}=${token}`, - }), - ), - ).resolves.toBeNull(); + for (const hint of ["/visual-edit/design-1", "/design/design-2"]) { + const event = fakeEvent( + `/_agent-native/actions/get-design?__an_embed_token=${token}&${EMBED_TARGET_QUERY_PARAM}=${encodeURIComponent(hint)}`, + { host: "mail.test", origin: "null" }, + ); + await expect( + resolveEmbedSessionFromRequest(event), + ).resolves.toMatchObject({ + scope: "capability:visual-edit:design:design-1", + }); + } await expect( resolveEmbedSessionFromRequest( fakeEvent("/design/design-1", { @@ -1877,7 +2632,7 @@ describe("requestMatchesEmbedTarget", () => { }); expect( requestHasEmbedAuthMarker( - fakeEvent("/_agent-native/actions/get-design", { + fakeEvent("/_agent-native/application-state", { host: "mail.test", cookie: `${EMBED_SESSION_COOKIE}=${token}`, }), @@ -1899,7 +2654,7 @@ describe("directory widget write session renewal", () => { afterEach(() => { vi.useRealTimers(); - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); function sessionRow(overrides: Record = {}) { @@ -1908,6 +2663,7 @@ describe("directory widget write session renewal", () => { owner_email: "owner@example.com", org_id: "org_123", target_path: "/page/doc_123", + ...ticketAudienceFixture(), scope: contentWidgetWriteScope({ orgId: "org_123" }), created_at: createdAtMs, consumed_at: createdAtMs + 1, @@ -2132,6 +2888,8 @@ describe("directory widget recovery after a chat reload", () => { owner_email: owner, org_id: org, target_path: target, + audience_host: args[10], + audience_base_path: args[11], scope, created_at: created, expires_at: expires, @@ -2172,7 +2930,7 @@ describe("directory widget recovery after a chat reload", () => { afterEach(() => { vi.useRealTimers(); - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); /** The result ticket the tool call minted, as the widget persists it. */ @@ -2330,7 +3088,7 @@ describe("directory widget session token size", () => { }); afterEach(() => { - process.env = ORIGINAL_ENV; + process.env = { ...ORIGINAL_ENV }; }); function slidesWidgetToken({ diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index 4efb6ff4f69..aca3ee1b8ac 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -13,12 +13,15 @@ import { import { getDbExec, type DbExec } from "../db/client.js"; import { ensureColumnExists, ensureTableExists } from "../db/ddl-guard.js"; import { defineStore } from "../db/store-registry.js"; +import { AGENT_SIDEBAR_QUERY_PARAM } from "../shared/agent-sidebar-url.js"; import { EMBED_MODE_QUERY_PARAM, EMBED_SESSION_COOKIE, EMBED_TARGET_HEADER, EMBED_TARGET_QUERY_PARAM, EMBED_TOKEN_QUERY_PARAM, + MCP_APP_CHAT_BRIDGE_QUERY_PARAM, + MCP_DIRECTORY_WIDGET_QUERY_PARAM, MCP_DIRECTORY_WIDGET_WRITE_CAPABILITY_MAX_AGE_MS, canRenewMcpDirectoryWidgetCapabilityScope, isMcpDirectoryWidgetCapabilityScope, @@ -27,7 +30,10 @@ import { MCP_DIRECTORY_WIDGET_WRITE_CAPABILITY_MAX_LENGTH, } from "../shared/embed-auth.js"; import { normalizeAppPath } from "../shared/sign-in-journey.js"; -import { getConfiguredAppBasePath } from "./app-base-path.js"; +import { + getConfiguredAppBasePath, + normalizeAppBasePath, +} from "./app-base-path.js"; import { resolveAuthCookieNamespace } from "./cookie-namespace.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; import { isHttpsRequest } from "./https-request.js"; @@ -41,6 +47,14 @@ const MCP_DIRECTORY_WIDGET_RENEWAL_TTL_MS = 30 * 24 * 60 * 60 * 1000; const EMBED_CAPABILITY_SCOPE_PREFIX = "capability:"; const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]"); const OPEN_ROUTE_PATH = "/_agent-native/open"; +const EMBED_PRESENTATION_QUERY_PARAMS = new Set([ + EMBED_MODE_QUERY_PARAM, + EMBED_TOKEN_QUERY_PARAM, + EMBED_TARGET_QUERY_PARAM, + MCP_APP_CHAT_BRIDGE_QUERY_PARAM, + MCP_DIRECTORY_WIDGET_QUERY_PARAM, + AGENT_SIDEBAR_QUERY_PARAM, +]); const OPEN_ROUTE_VIEW_PATHS: Record = { ask: "/", calendar: "/", @@ -79,6 +93,7 @@ let _devSigningKey: string | undefined; export interface EmbedSessionTicketInput { ownerEmail: string; + audienceHost?: string; orgId?: string | null; targetPath: string; scope?: string | null; @@ -113,6 +128,7 @@ export type EmbedSessionTicketConsumeOutcome = | "expired" | "identity-mismatch" | "org-mismatch" + | "audience-mismatch" | "consumption-race" | "invalid-row" | "revoked" @@ -131,6 +147,7 @@ export interface EmbedSessionTicketConsumeDiagnostic { } export interface ConsumeEmbedSessionTicketOptions { + audienceHost?: string; expectedOwnerEmail?: string | null; expectedOrgId?: string | null; allowCapabilityIdentityMismatch?: boolean; @@ -153,6 +170,7 @@ export interface EmbedSessionTokenClaims { orgId?: string; targetPath: string; audienceHost?: string; + audienceBasePath?: string; scope?: string; iat: number; issuedAtMs?: number; @@ -239,6 +257,21 @@ export const embedSessionsStore = defineStore({ ); }, }, + { + name: "ticket-audience", + run: async () => { + await ensureColumnExists( + "agent_native_embed_tickets", + "audience_host", + "ALTER TABLE agent_native_embed_tickets ADD COLUMN audience_host TEXT", + ); + await ensureColumnExists( + "agent_native_embed_tickets", + "audience_base_path", + "ALTER TABLE agent_native_embed_tickets ADD COLUMN audience_base_path TEXT", + ); + }, + }, ], }); @@ -612,31 +645,19 @@ function requestUrlFromEvent(event: H3Event): string { function requestPathname(event: H3Event): string | null { const mountedPathname = event.context?._mountedPathname; - return stripConfiguredBasePath( + const pathname = typeof mountedPathname === "string" && mountedPathname ? mountedPathname - : event.url.pathname, - ); -} - -function headerTargetPathname(event: H3Event): string | null { - const direct = - (event as any).request?.headers?.get?.(EMBED_TARGET_HEADER) ?? - (event as any).headers?.get?.(EMBED_TARGET_HEADER) ?? - (event as any).node?.req?.headers?.[EMBED_TARGET_HEADER] ?? - (event as any).node?.req?.headers?.[EMBED_TARGET_HEADER.toLowerCase()]; - if (typeof direct === "string") return pathnameFromPath(direct); - try { - const raw = getHeader(event, EMBED_TARGET_HEADER); - if (typeof raw === "string") return pathnameFromPath(raw); - const queryValue = getQuery(event)?.[EMBED_TARGET_QUERY_PARAM]; - const queryTarget = Array.isArray(queryValue) ? queryValue[0] : queryValue; - return typeof queryTarget === "string" - ? pathnameFromPath(queryTarget) - : null; - } catch { + : event.url.pathname; + const base = getConfiguredAppBasePath(); + if (base && pathname !== base && !pathname.startsWith(`${base}/`)) { return null; } + return stripConfiguredBasePath(pathname); +} + +function requestUrl(event: H3Event): URL | null { + return event.url ?? null; } function requestHostname(event: H3Event): string | null { @@ -654,6 +675,43 @@ function normalizedHostname( return normalized || null; } +interface EmbedTicketAudience { + audienceHost: string; + audienceBasePath: string; +} + +function ticketAudience(audienceHost?: string): EmbedTicketAudience | null { + let hostname = audienceHost; + if (hostname === undefined) { + const origin = getRequestContext()?.requestOrigin; + if (!origin || !URL.canParse(origin)) return null; + const url = new URL(origin); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + hostname = url.hostname; + } + const normalized = normalizedHostname(hostname); + if (!normalized) return null; + const audienceOrigin = `https://${normalized}`; + if (!URL.canParse(audienceOrigin)) return null; + const url = new URL(audienceOrigin); + if (url.hostname !== normalized || url.origin !== audienceOrigin) return null; + return { + audienceHost: normalized, + audienceBasePath: getConfiguredAppBasePath(), + }; +} + +function ticketMatchesAudience( + row: Record, + audience: EmbedTicketAudience | null, +): boolean { + return ( + !!audience && + row.audience_host === audience.audienceHost && + row.audience_base_path === audience.audienceBasePath + ); +} + function isFirstPartyAppHostname(hostname: string | null): boolean { return Boolean( !hostname || @@ -670,6 +728,8 @@ function embedTokenMatchesHostname( hostname: string | null | undefined, claims: EmbedSessionTokenClaims, ): boolean { + const basePath = getConfiguredAppBasePath(); + if ((claims.audienceBasePath ?? "") !== basePath) return false; const requestHostname = normalizedHostname(hostname); return claims.audienceHost === undefined ? !isFirstPartyAppHostname(requestHostname) @@ -683,77 +743,109 @@ function embedTokenMatchesRequestAudience( return embedTokenMatchesHostname(requestHostname(event), claims); } -function referrerTargetPathname(event: H3Event): string | null { - let raw: string | null = - (event as any).request?.headers?.get?.("referer") ?? - (event as any).request?.headers?.get?.("referrer") ?? - (event as any).headers?.get?.("referer") ?? - (event as any).headers?.get?.("referrer") ?? - (event as any).node?.req?.headers?.referer ?? - (event as any).node?.req?.headers?.referrer ?? - null; - try { - raw = - raw ?? - getHeader(event, "referer") ?? - getHeader(event, "referrer") ?? - null; - } catch { - raw = raw ?? null; +function isEmbedDocumentPath(pathname: string): boolean { + return ( + pathname !== "/api" && + !pathname.startsWith("/api/") && + (pathname === OPEN_ROUTE_PATH || + (pathname !== "/_agent-native" && + !pathname.startsWith("/_agent-native/"))) + ); +} + +function matchesEmbedTargetQuery(current: URL, target: URL): boolean { + const expected = new URLSearchParams(target.search); + const redirectedOpen = + target.pathname === OPEN_ROUTE_PATH && current.pathname !== OPEN_ROUTE_PATH; + if (redirectedOpen) { + const to = normalizeEmbedTargetPath(expected.get("to")); + if (to) { + const destination = new URL(to, "http://agent-native.invalid"); + for (const key of new Set(destination.searchParams.keys())) { + expected.delete(key); + for (const value of destination.searchParams.getAll(key)) + expected.append(key, value); + } + } } - if (!raw) return null; - const hostname = requestHostname(event); - if (!hostname || !URL.canParse(raw)) return null; - const referrer = new URL(raw); - if ( - (referrer.protocol !== "http:" && referrer.protocol !== "https:") || - referrer.hostname.toLowerCase().replace(/\.$/, "") !== hostname - ) { - return null; + const keys = new Set([ + ...current.searchParams.keys(), + ...(redirectedOpen ? [] : expected.keys()), + ]); + // Open redirects move navigation selectors into application state, but a URL + // that carries selectors must still agree with the signed target. + for (const key of keys) { + if (EMBED_PRESENTATION_QUERY_PARAMS.has(key)) continue; + const actual = current.searchParams.getAll(key); + const wanted = expected.getAll(key); + if ( + actual.length !== wanted.length || + actual.some((value, index) => value !== wanted[index]) + ) { + return false; + } } - return pathnameFromPath(`${referrer.pathname}${referrer.search}`); + return true; } export function requestMatchesEmbedTarget( event: H3Event, targetPath: string, ): boolean { - const allowed = allowedEmbedTargetPathnames(targetPath); - if (allowed.size === 0) return false; - - const current = requestPathname(event); - if (current && allowed.has(current)) return true; - - const headerTarget = headerTargetPathname(event); - if (headerTarget && allowed.has(headerTarget)) return true; - - const referrerTarget = referrerTargetPathname(event); - return !!referrerTarget && allowed.has(referrerTarget); + const current = requestUrl(event); + const pathname = requestPathname(event); + const target = normalizeEmbedTargetPath(targetPath); + if (!current || !pathname || !target || !isEmbedDocumentPath(pathname)) + return false; + if (!allowedEmbedTargetPathnames(target).has(pathname)) return false; + return matchesEmbedTargetQuery( + current, + new URL(target, "http://agent-native.invalid"), + ); } -function isEmbedRuntimeRequest(event: H3Event): boolean { +function isEmbedActionRuntimeRequest(event: H3Event): boolean { const pathname = requestPathname(event); return ( !!pathname && - (pathname === "/api" || - pathname.startsWith("/api/") || - pathname.startsWith("/@") || - pathname.startsWith("/app/") || - pathname.startsWith("/node_modules/") || - pathname.startsWith("/packages/") || - pathname === "/_agent-native" || - pathname.startsWith("/_agent-native/")) + ["/_agent-native/actions", "/_agent-native/webmcp/actions"].some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ) ); } function isEmbedStaticRuntimeRequest(event: H3Event): boolean { const pathname = requestPathname(event); + if (!pathname) return false; return ( - !!pathname && - (pathname.startsWith("/@") || - pathname.startsWith("/app/") || - pathname.startsWith("/node_modules/") || - pathname.startsWith("/packages/")) + pathname === "/@react-refresh" || + pathname.startsWith("/@vite/") || + pathname.startsWith("/@id/") || + pathname.startsWith("/@fs/") || + (["/assets/", "/app/", "/node_modules/", "/packages/"].some((prefix) => + pathname.startsWith(prefix), + ) && + /\.(?:[cm]?[jt]sx?|css|json|wasm|woff2?|ttf|otf|svg|png|jpe?g|gif|webp|ico|map)$/.test( + pathname, + )) + ); +} + +function embedSessionAllowsRequest( + event: H3Event, + claims: EmbedSessionTokenClaims, +): boolean { + if ( + isEmbedCapabilityScope(claims.scope) && + isEmbedActionRuntimeRequest(event) + ) + return true; + const method = + (event as any).req?.method ?? (event as any).node?.req?.method ?? "GET"; + return ( + (method === "GET" || method === "HEAD") && + (requestMatchesEmbedTarget(event, claims.targetPath) || + isEmbedStaticRuntimeRequest(event)) ); } @@ -806,6 +898,10 @@ export async function createEmbedSessionTicket( const targetPath = normalizeEmbedTargetPath(input.targetPath); if (!targetPath) throw new Error("Embed session ticket requires a safe path."); + const audience = ticketAudience(input.audienceHost); + if (!audience) { + throw new Error("Embed session ticket requires a trusted app audience."); + } const now = Date.now(); const context = getRequestContext(); @@ -916,8 +1012,8 @@ export async function createEmbedSessionTicket( await tx.execute({ sql: "INSERT INTO agent_native_embed_tickets " + - "(ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at, session_active_until) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at, session_active_until, audience_host, audience_base_path) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", args: [ ticketHash, ownerEmail, @@ -929,6 +1025,8 @@ export async function createEmbedSessionTicket( null, renewalExpiresAt, sessionActiveUntil, + audience.audienceHost, + audience.audienceBasePath, ], }); }; @@ -947,6 +1045,7 @@ export async function createEmbedSessionTicket( export async function readMcpDirectoryWidgetRenewalTicket( ticket: string, + options: { audienceHost?: string } = {}, ): Promise { if (!ticket || ticket.length > 128 || CONTROL_CHARS.test(ticket)) { return null; @@ -954,12 +1053,13 @@ export async function readMcpDirectoryWidgetRenewalTicket( await ensureTable(); const { rows } = await getDbExec().execute({ sql: - "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at, session_active_until " + + "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at, session_active_until, audience_host, audience_base_path " + "FROM agent_native_embed_tickets WHERE ticket_hash = ? LIMIT 1", args: [hashTicket(ticket)], }); const row = rows[0] as Record | undefined; - if (!row) return null; + if (!row || !ticketMatchesAudience(row, ticketAudience(options.audienceHost))) + return null; const ownerEmail = stringOrUndefined(row.owner_email ?? row.ownerEmail); const targetPath = normalizeEmbedTargetPath( stringOrUndefined(row.target_path ?? row.targetPath), @@ -1011,6 +1111,7 @@ export async function readMcpDirectoryWidgetRenewalTicket( } export async function renewMcpDirectoryWidgetSession(input: { + audienceHost?: string; sourceTicket: string; ownerEmail: string; orgId?: string | null; @@ -1037,12 +1138,13 @@ export async function renewMcpDirectoryWidgetSession(input: { const ticketHash = hashTicket(input.sourceTicket); const { rows } = await tx.execute({ sql: - "SELECT owner_email, org_id, target_path, scope, created_at, consumed_at, renewal_expires_at, session_active_until " + + "SELECT owner_email, org_id, target_path, scope, created_at, consumed_at, renewal_expires_at, session_active_until, audience_host, audience_base_path " + "FROM agent_native_embed_tickets WHERE ticket_hash = ? FOR UPDATE", args: [ticketHash], }); const row = rows[0] as Record | undefined; - if (!row) return null; + if (!row || !ticketMatchesAudience(row, ticketAudience(input.audienceHost))) + return null; const currentOwner = stringOrUndefined(row.owner_email ?? row.ownerEmail); const currentOrgId = stringOrUndefined(row.org_id ?? row.orgId); @@ -1141,17 +1243,19 @@ interface McpDirectoryWidgetSessionState { async function readMcpDirectoryWidgetSessionState( sessionId: string, + hostname: string, ): Promise { if (!/^[a-f0-9]{64}$/.test(sessionId)) return null; await ensureTable(); const { rows } = await getDbExec().execute({ sql: - "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, consumed_at, renewal_expires_at, session_active_until " + + "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, consumed_at, renewal_expires_at, session_active_until, audience_host, audience_base_path " + "FROM agent_native_embed_tickets WHERE ticket_hash = ? LIMIT 1", args: [sessionId], }); const row = rows[0] as Record | undefined; - if (!row) return null; + if (!row || !ticketMatchesAudience(row, ticketAudience(hostname))) + return null; const ownerEmail = stringOrUndefined(row.owner_email ?? row.ownerEmail); const targetPath = normalizeEmbedTargetPath( @@ -1215,7 +1319,10 @@ export async function resolveEmbedSessionTokenForHost( } if (claims.sessionId) { if (!widgetCapability) return null; - const session = await readMcpDirectoryWidgetSessionState(claims.sessionId); + const session = await readMcpDirectoryWidgetSessionState( + claims.sessionId, + hostname, + ); if ( !session || normalizedEmail(session.ownerEmail) !== @@ -1268,12 +1375,13 @@ export async function isExpiredMcpDirectoryWidgetSessionRequest( !verified.claims.sessionId || !isMcpDirectoryWidgetCapabilityScope(verified.claims.scope) || !embedTokenMatchesHostname(hostname, verified.claims) || - !requestMatchesEmbedTarget(event, verified.claims.targetPath) + !embedSessionAllowsRequest(event, verified.claims) ) { continue; } const session = await readMcpDirectoryWidgetSessionState( verified.claims.sessionId, + hostname, ); if ( !session || @@ -1344,7 +1452,7 @@ export async function consumeEmbedSessionTicket( const ticketKey = ticketHash.slice(0, 12); const { rows } = await getDbExec().execute({ sql: - "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at " + + "SELECT ticket_hash, owner_email, org_id, target_path, scope, created_at, expires_at, consumed_at, renewal_expires_at, audience_host, audience_base_path " + "FROM agent_native_embed_tickets WHERE ticket_hash = ?", args: [ticketHash], }); @@ -1377,6 +1485,20 @@ export async function consumeEmbedSessionTicket( const capabilityScope = isEmbedCapabilityScope(scope); const widgetCapability = isMcpDirectoryWidgetCapabilityScope(scope); const revokesWithOwner = !capabilityScope || widgetCapability; + if (!ticketMatchesAudience(row, ticketAudience(options.audienceHost))) { + options.onResult?.({ + outcome: "audience-mismatch", + ticketKey, + ticketRowFound: true, + consumed: false, + expired: false, + expectedOwnerKey, + ticketOwnerKey, + expectedOrgKey, + ticketOrgKey, + }); + return null; + } if (!ownerEmail || createdAt === null || expiresAt === null) { options.onResult?.({ outcome: "invalid-row", @@ -1595,6 +1717,7 @@ export function signEmbedSessionToken(input: { kind: TOKEN_KIND, ownerEmail: input.ownerEmail, targetPath, + audienceBasePath: getConfiguredAppBasePath(), iat: now, issuedAtMs, exp: now + ttl, @@ -1655,7 +1778,11 @@ export function verifyEmbedSessionToken( (typeof claims.sessionId !== "string" || !/^[a-f0-9]{64}$/.test(claims.sessionId))) || typeof claims.exp !== "number" || - !Number.isFinite(claims.exp) + !Number.isFinite(claims.exp) || + (claims.audienceBasePath !== undefined && + (typeof claims.audienceBasePath !== "string" || + normalizeAppBasePath(claims.audienceBasePath) !== + claims.audienceBasePath)) ) { return { ok: false, reason: "claims" }; } @@ -1747,22 +1874,7 @@ export async function resolveEmbedSessionFromRequest( hostname, ); if (!claims) continue; - const matchesTarget = requestMatchesEmbedTarget(event, claims.targetPath); - const isRuntimeRequest = isEmbedRuntimeRequest(event); - const isRuntimeCookieRequest = - candidate.source === "cookie" && isRuntimeRequest; - const isRuntimeQueryRequest = - candidate.source === "query" && isRuntimeRequest; - const capabilityScope = isEmbedCapabilityScope(claims.scope); - const allowsUnboundRuntimeRequest = - !capabilityScope || isEmbedStaticRuntimeRequest(event); - if ( - !matchesTarget && - (!allowsUnboundRuntimeRequest || - (!isRuntimeCookieRequest && !isRuntimeQueryRequest)) - ) { - continue; - } + if (!embedSessionAllowsRequest(event, claims)) continue; if (candidate.source === "query" && candidate.token) { try { setEmbedSessionCookie(event, candidate.token); @@ -1797,42 +1909,44 @@ export function hasExplicitEmbedSessionCredential(event: H3Event): boolean { return true; } - const cookieClaims = verifyEmbedSessionToken( - getCookie(event, EMBED_SESSION_COOKIE), + return [bearerToken(event), getCookie(event, EMBED_SESSION_COOKIE)].some( + (token) => { + const verified = verifyEmbedSessionToken(token, { allowExpired: true }); + return ( + verified.ok && + embedTokenMatchesRequestAudience(event, verified.claims) && + embedSessionAllowsRequest(event, verified.claims) + ); + }, ); - return ( - cookieClaims.ok && - embedTokenMatchesRequestAudience(event, cookieClaims.claims) && - requestMatchesEmbedTarget(event, cookieClaims.claims.targetPath) +} + +export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { + if ( + queryToken(event) || + getHeader(event, EMBED_TARGET_HEADER) !== undefined || + getQuery(event)?.[EMBED_TARGET_QUERY_PARAM] !== undefined + ) { + return true; + } + return [bearerToken(event), getCookie(event, EMBED_SESSION_COOKIE)].some( + (token) => verifyEmbedSessionToken(token, { allowExpired: true }).ok, ); } export function requestHasEmbedAuthMarker(event: H3Event): boolean { try { - const q = getQuery(event) ?? {}; - const queryToken = Array.isArray(q[EMBED_TOKEN_QUERY_PARAM]) - ? q[EMBED_TOKEN_QUERY_PARAM][0] - : q[EMBED_TOKEN_QUERY_PARAM]; - const cookieToken = getCookie(event, EMBED_SESSION_COOKIE); const candidates = [ - { token: queryToken, allowRuntime: true }, - { token: bearerToken(event), allowRuntime: false }, - { token: cookieToken, allowRuntime: true }, + queryToken(event), + bearerToken(event), + getCookie(event, EMBED_SESSION_COOKIE), ]; - const runtimeRequest = isEmbedRuntimeRequest(event); - for (const candidate of candidates) { - const verified = verifyEmbedSessionToken(candidate.token); - const allowsUnboundRuntimeRequest = - verified.ok && - (!isEmbedCapabilityScope(verified.claims.scope) || - isEmbedStaticRuntimeRequest(event)); + for (const token of candidates) { + const verified = verifyEmbedSessionToken(token); if ( verified.ok && embedTokenMatchesRequestAudience(event, verified.claims) && - (requestMatchesEmbedTarget(event, verified.claims.targetPath) || - (candidate.allowRuntime && - runtimeRequest && - allowsUnboundRuntimeRequest)) + embedSessionAllowsRequest(event, verified.claims) ) { return true; } diff --git a/packages/core/src/server/request-origin.spec.ts b/packages/core/src/server/request-origin.spec.ts index 8c98f20fd70..893a42e674a 100644 --- a/packages/core/src/server/request-origin.spec.ts +++ b/packages/core/src/server/request-origin.spec.ts @@ -328,7 +328,17 @@ describe("isSameOriginRequest", () => { { name: "non-browser client without browser headers", headers: {}, - expected: true, + expected: false, + }, + { + name: "opaque iframe origin", + headers: { host: "app.example.com", origin: "null" }, + expected: false, + }, + { + name: "Origin without a request host", + headers: { origin: "https://app.example.com" }, + expected: false, }, { name: "Tauri production origin against loopback app host", diff --git a/packages/core/src/server/request-origin.ts b/packages/core/src/server/request-origin.ts index 5e12f7e87ae..b0aad2649c2 100644 --- a/packages/core/src/server/request-origin.ts +++ b/packages/core/src/server/request-origin.ts @@ -178,5 +178,5 @@ export function isSameOriginRequest(event: H3Event): boolean { return false; } } - return true; + return false; } diff --git a/packages/core/src/server/ui-action-capability.spec.ts b/packages/core/src/server/ui-action-capability.spec.ts index f95bac92c51..16c1dbfce3d 100644 --- a/packages/core/src/server/ui-action-capability.spec.ts +++ b/packages/core/src/server/ui-action-capability.spec.ts @@ -1,16 +1,48 @@ import { createApp, defineEventHandler } from "h3"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mockGetSession = vi.hoisted(() => vi.fn()); +const { mockGetSession, mockGetBrowserCookieSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetBrowserCookieSession: vi.fn(), +})); vi.mock("./auth.js", () => ({ getSession: (...args: unknown[]) => mockGetSession(...args), + getBrowserCookieSession: (...args: unknown[]) => + mockGetBrowserCookieSession(...args), +})); + +const { mockCreateEmbedSessionTicket, mockConsumeEmbedSessionTicket } = + vi.hoisted(() => ({ + mockCreateEmbedSessionTicket: vi.fn(), + mockConsumeEmbedSessionTicket: vi.fn(), + })); + +vi.mock("./embed-session.js", async (importOriginal) => ({ + ...(await importOriginal()), + createEmbedSessionTicket: (...args: unknown[]) => + mockCreateEmbedSessionTicket(...args), + consumeEmbedSessionTicket: (...args: unknown[]) => + mockConsumeEmbedSessionTicket(...args), })); vi.mock("./framework-request-handler.js", () => ({ getH3App: (app: any) => app, })); +import { getBuiltinCrossAppTools } from "../mcp/builtin-tools.js"; +import { + EMBED_SESSION_COOKIE, + EMBED_START_PATH, + EMBED_TOKEN_QUERY_PARAM, +} from "../shared/embed-auth.js"; +import { createEmbedStartRouteHandler } from "./embed-route.js"; +import { + signEmbedSessionToken, + verifyEmbedSessionToken, + type EmbedSessionTicketInput, +} from "./embed-session.js"; +import { runWithRequestContext } from "./request-context.js"; import { hasUiActionCapability, mountUiActionCapabilityRoute, @@ -20,11 +52,17 @@ describe("UI action capability", () => { beforeEach(() => { vi.stubEnv("BETTER_AUTH_SECRET", "test-ui-capability-secret"); vi.stubEnv("OAUTH_STATE_SECRET", "test-ui-capability-secret"); - mockGetSession.mockResolvedValue({ email: "Alice@example.com" }); + mockGetSession.mockResolvedValue({ email: "general@example.com" }); + mockGetBrowserCookieSession.mockResolvedValue({ + email: "Alice@example.com", + }); }); afterEach(() => { + mockCreateEmbedSessionTicket.mockReset(); + mockConsumeEmbedSessionTicket.mockReset(); mockGetSession.mockReset(); + mockGetBrowserCookieSession.mockReset(); vi.unstubAllEnvs(); }); @@ -33,7 +71,9 @@ describe("UI action capability", () => { mountUiActionCapabilityRoute(app); const mint = await app.fetch( - new Request("https://example.test/_agent-native/ui-capability"), + new Request("https://example.test/_agent-native/ui-capability", { + headers: { "sec-fetch-site": "same-origin" }, + }), ); expect(mint.status).toBe(200); @@ -84,21 +124,289 @@ describe("UI action capability", () => { mountUiActionCapabilityRoute(app, "/_agent-native", "/docs"); const response = await app.fetch( - new Request("https://example.test/_agent-native/ui-capability"), + new Request("https://example.test/_agent-native/ui-capability", { + headers: { "sec-fetch-site": "same-origin" }, + }), ); + expect(response.status).toBe(200); expect(response.headers.get("set-cookie")).toContain( "Path=/docs/_agent-native/actions", ); }); + it.each([ + { name: "no browser headers", headers: {} }, + { name: "cross-site fetch", headers: { "sec-fetch-site": "cross-site" } }, + { name: "same-site fetch", headers: { "sec-fetch-site": "same-site" } }, + { name: "different origin", headers: { origin: "https://other.test" } }, + { name: "opaque iframe origin", headers: { origin: "null" } }, + ])( + "does not mint for $name even with a cookie session", + async ({ headers }) => { + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request("https://example.test/_agent-native/ui-capability", { + headers: { host: "example.test", ...headers }, + }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Same-origin request required", + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }, + ); + + it("mints for a matching Origin without fetch metadata", async () => { + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request("https://example.test/_agent-native/ui-capability", { + headers: { host: "example.test", origin: "https://example.test" }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(response.headers.get("set-cookie")).toContain( + "agent-native-ui-capability=", + ); + }); + + it.each([ + { + name: "bearer", + headers: { authorization: "Bearer general-login" }, + query: "", + }, + { name: "query", headers: {}, query: "?_session=query-login" }, + { name: "custom resolver", headers: {}, query: "" }, + ])( + "does not mint from a general $name session with same-origin metadata", + async ({ headers, query }) => { + mockGetBrowserCookieSession.mockResolvedValue(null); + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request( + `https://example.test/_agent-native/ui-capability${query}`, + { + headers: { ...headers, "sec-fetch-site": "same-origin" }, + }, + ), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: "Authentication required", + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }, + ); + + it.each([ + { + name: "embed query", + headers: { cookie: "an_session=browser-login" }, + query: "?__an_embed_token=embed-login", + }, + { + name: "embed bearer and target", + headers: { + cookie: "an_session=browser-login", + authorization: "Bearer embed-login", + "x-agent-native-embed-target": "/documents/doc-1", + }, + query: "", + }, + ])( + "does not mint for an explicit $name alongside a browser session", + async ({ headers, query }) => { + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request( + `https://example.test/_agent-native/ui-capability${query}`, + { + headers: { ...headers, "sec-fetch-site": "same-origin" }, + }, + ), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Browser cookie session required", + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }, + ); + + it.each([ + { ownerEmail: "alice@example.com", source: "cookie" }, + { ownerEmail: "other@example.com", source: "cookie" }, + { ownerEmail: "alice@example.com", source: "bearer" }, + { ownerEmail: "other@example.com", source: "bearer" }, + ])( + "does not mint for a signed embed $source owned by $ownerEmail alongside a browser session", + async ({ ownerEmail, source }) => { + const embedToken = signEmbedSessionToken({ + ownerEmail, + targetPath: "/documents/doc-1", + audienceHost: "example.test", + }); + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request("https://example.test/_agent-native/ui-capability", { + headers: { + "sec-fetch-site": "same-origin", + ...(source === "cookie" + ? { + cookie: `an_embed_session=${embedToken}; an_session=browser-login`, + } + : { + cookie: "an_session=browser-login", + authorization: `Bearer ${embedToken}`, + }), + }, + }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Browser cookie session required", + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }, + ); + + it("mints for a browser session with an invalid leftover embed cookie", async () => { + const app = createApp(); + mountUiActionCapabilityRoute(app); + + const response = await app.fetch( + new Request("https://example.test/_agent-native/ui-capability", { + headers: { + "sec-fetch-site": "same-origin", + cookie: "an_embed_session=invalid; an_session=browser-login", + }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(response.headers.get("set-cookie")).toContain( + "agent-native-ui-capability=", + ); + }); + + it("does not mint a UI capability from an inline-enabled MCP embed session", async () => { + const origin = "https://example.test"; + const ownerEmail = "alice@example.com"; + const createdAt = Date.now(); + const expiresAt = createdAt + 60_000; + let issued: EmbedSessionTicketInput | undefined; + mockCreateEmbedSessionTicket.mockImplementation( + async (input: EmbedSessionTicketInput) => { + issued = { ...input }; + return { + ticket: "inline-test-ticket", + ticketHash: "inline-test-ticket-hash", + expiresAt, + }; + }, + ); + mockConsumeEmbedSessionTicket.mockImplementation(async (ticket: string) => { + if (ticket !== "inline-test-ticket" || !issued) return null; + const input = issued; + issued = undefined; + return { + ownerEmail: input.ownerEmail, + orgId: input.orgId ?? undefined, + targetPath: input.targetPath, + scope: input.scope ?? undefined, + expiresAt, + ticketCreatedAtMs: createdAt, + }; + }); + const tools = getBuiltinCrossAppTools( + { + name: "Documents", + appId: "content", + description: "Test app", + actions: {}, + }, + { inlineMcpApps: true, origin }, + ); + expect(tools.create_embed_session).toBeDefined(); + const result = (await runWithRequestContext( + { userEmail: ownerEmail, orgId: "org-inline", requestOrigin: origin }, + () => + tools.create_embed_session!.run({ + path: "/documents/doc-1", + chrome: "minimal", + }), + )) as { startUrl: string; targetPath: string }; + expect(result.targetPath).toBe("/documents/doc-1"); + expect(new URL(result.startUrl).pathname).toBe(EMBED_START_PATH); + + const app = createApp(); + app.use(EMBED_START_PATH, createEmbedStartRouteHandler()); + mountUiActionCapabilityRoute(app); + const start = await app.fetch(new Request(result.startUrl)); + expect(start.status).toBe(302); + const target = new URL(start.headers.get("location")!, origin); + expect(target.pathname).toBe("/documents/doc-1"); + const token = target.searchParams.get(EMBED_TOKEN_QUERY_PARAM); + expect(token).toEqual(expect.any(String)); + expect(verifyEmbedSessionToken(token)).toMatchObject({ + ok: true, + claims: { + ownerEmail, + orgId: "org-inline", + targetPath: "/documents/doc-1", + scope: "minimal", + audienceHost: "example.test", + }, + }); + expect(start.headers.get("set-cookie")).toContain( + `${EMBED_SESSION_COOKIE}=`, + ); + + const response = await app.fetch( + new Request(`${origin}/_agent-native/ui-capability`, { + headers: { + origin, + "sec-fetch-site": "same-origin", + cookie: `${EMBED_SESSION_COOKIE}=${token}; an_session=browser-login`, + }, + }), + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Browser cookie session required", + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + it("does not mint a capability without an authenticated session", async () => { mockGetSession.mockResolvedValue(null); + mockGetBrowserCookieSession.mockResolvedValue(null); const app = createApp(); mountUiActionCapabilityRoute(app); const response = await app.fetch( - new Request("https://example.test/_agent-native/ui-capability"), + new Request("https://example.test/_agent-native/ui-capability", { + headers: { "sec-fetch-site": "same-origin" }, + }), ); expect(response.status).toBe(401); diff --git a/packages/core/src/server/ui-action-capability.ts b/packages/core/src/server/ui-action-capability.ts index 47bb06de14a..3f43e844275 100644 --- a/packages/core/src/server/ui-action-capability.ts +++ b/packages/core/src/server/ui-action-capability.ts @@ -12,10 +12,12 @@ import { getConfiguredAppBasePath, normalizeAppBasePath, } from "./app-base-path.js"; -import { getSession } from "./auth.js"; +import { getBrowserCookieSession } from "./auth.js"; +import { hasPresentedEmbedSessionCredential } from "./embed-session.js"; import { getH3App } from "./framework-request-handler.js"; import { publicFrameworkPath } from "./framework-route-prefix.js"; import { isHttpsRequest } from "./https-request.js"; +import { isSameOriginRequest } from "./request-origin.js"; import { signShortLivedToken, verifyShortLivedToken, @@ -60,13 +62,23 @@ export function hasUiActionCapability( } async function issueUiActionCapability(event: H3Event, appBasePath?: string) { - const session = await getSession(event); + if (hasPresentedEmbedSessionCredential(event)) { + setResponseStatus(event, 403); + return { error: "Browser cookie session required" }; + } + + const session = await getBrowserCookieSession(event); const ownerEmail = normalizedEmail(session?.email); if (!ownerEmail) { setResponseStatus(event, 401); return { error: "Authentication required" }; } + if (!isSameOriginRequest(event)) { + setResponseStatus(event, 403); + return { error: "Same-origin request required" }; + } + const token = signShortLivedToken({ resourceId: UI_ACTION_CAPABILITY_RESOURCE, viewerEmail: ownerEmail, diff --git a/templates/content/e2e/helpers.ts b/templates/content/e2e/helpers.ts index e405c6e32df..5c89b6bd0b3 100644 --- a/templates/content/e2e/helpers.ts +++ b/templates/content/e2e/helpers.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { expect, + test, type APIRequestContext, type BrowserContext, type Page, @@ -180,7 +181,11 @@ export async function signInAccount( * mints it first, as the app's client does before its writes. */ export async function presentUiCapability(page: Page): Promise { - const response = await page.request.get("/_agent-native/ui-capability"); + const response = await page.request.get("/_agent-native/ui-capability", { + headers: { + Origin: new URL(test.info().project.use.baseURL ?? page.url()).origin, + }, + }); expect(response.ok(), `ui-capability (${response.status()})`).toBe(true); } @@ -192,7 +197,10 @@ export async function postAction>( ): Promise { const response = await page.request.post(`/_agent-native/actions/${name}`, { data, - headers, + headers: { + Origin: new URL(test.info().project.use.baseURL ?? page.url()).origin, + ...headers, + }, }); const text = await response.text(); expect(response.ok(), `${name} (${response.status()}): ${text}`).toBe(true); @@ -211,7 +219,10 @@ export async function postUiAction( data: Record, ) { const capability = await page.request.get("/_agent-native/ui-capability", { - headers: ACTION_HEADERS, + headers: { + ...ACTION_HEADERS, + Origin: new URL(test.info().project.use.baseURL ?? page.url()).origin, + }, }); expect( capability.ok(), diff --git a/templates/design/actions/open-visual-edit.spec.ts b/templates/design/actions/open-visual-edit.spec.ts index 7a97bcd16df..6249659e06c 100644 --- a/templates/design/actions/open-visual-edit.spec.ts +++ b/templates/design/actions/open-visual-edit.spec.ts @@ -930,6 +930,26 @@ describe("open-visual-edit", () => { ); }); + it("returns the app link after saving an originless local visual-edit request", async () => { + mocks.getRequestContext.mockReturnValue({ + userEmail: "owner@example.com", + orgId: "org_1", + }); + const result = await action.run({ + designId: "design_1", + devServerUrl: "http://localhost:5173", + paths: ["/"], + navigate: false, + }); + + expect(mocks.addLocalhostScreensRun).toHaveBeenCalled(); + expect(mocks.createEmbedSessionTicket).not.toHaveBeenCalled(); + expect(result).not.toHaveProperty("embedStartUrl"); + expect(result.openUrl).toBe( + "agent-native://open/visual-edit/design_1?editorView=overview&embedChrome=1", + ); + }); + it("exposes the handoff only to the same-origin frontend transport", async () => { const result = await action.run( { diff --git a/templates/design/actions/open-visual-edit.ts b/templates/design/actions/open-visual-edit.ts index 9a1f80bd072..2563c613177 100644 --- a/templates/design/actions/open-visual-edit.ts +++ b/templates/design/actions/open-visual-edit.ts @@ -302,7 +302,8 @@ async function createCallerHandoff( targetPath: string, ownerEmail: string, designId: string, -): Promise { +): Promise { + if (!getRequestContext()?.requestOrigin) return undefined; const ticket = await createEmbedSessionTicket({ ownerEmail, orgId: getRequestOrgId(), diff --git a/templates/design/changelog/2026-10-22-local-visual-editing-returns-an-app-link-when-no-browser-han.md b/templates/design/changelog/2026-10-22-local-visual-editing-returns-an-app-link-when-no-browser-han.md new file mode 100644 index 00000000000..00691e4e852 --- /dev/null +++ b/templates/design/changelog/2026-10-22-local-visual-editing-returns-an-app-link-when-no-browser-han.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-22 +--- + +Local visual editing returns an app link when no browser handoff is available diff --git a/templates/design/e2e/public-visual-edit.spec.ts b/templates/design/e2e/public-visual-edit.spec.ts index 0c9351082a1..1af9d213d5d 100644 --- a/templates/design/e2e/public-visual-edit.spec.ts +++ b/templates/design/e2e/public-visual-edit.spec.ts @@ -1940,7 +1940,10 @@ async function setDesignVisibility( try { // Making a resource public needs approval, which over HTTP only the // signed-in app UI gives: a frontend call holding the UI capability. - await context.request.get(`${BASE_URL}/_agent-native/ui-capability`); + const origin = new URL(BASE_URL).origin; + await context.request.get(`${BASE_URL}/_agent-native/ui-capability`, { + headers: { Origin: origin }, + }); const response = await context.request.post( `${BASE_URL}/_agent-native/actions/set-resource-visibility`, { @@ -1949,7 +1952,7 @@ async function setDesignVisibility( resourceId: id, visibility, }, - headers: { "X-Agent-Native-Frontend": "1" }, + headers: { "X-Agent-Native-Frontend": "1", Origin: origin }, }, ); if (!response.ok()) { diff --git a/templates/plan/e2e/concurrent-edit-durability.spec.ts b/templates/plan/e2e/concurrent-edit-durability.spec.ts index 01d177377d7..608fd727253 100644 --- a/templates/plan/e2e/concurrent-edit-durability.spec.ts +++ b/templates/plan/e2e/concurrent-edit-durability.spec.ts @@ -165,7 +165,10 @@ async function registerUser( async function shareAsEditor(page: Page, planId: string, email: string) { // Sharing outside the organization needs approval, which over HTTP only the // signed-in app UI gives: a frontend call holding the UI capability. - await page.request.get("/_agent-native/ui-capability"); + const origin = new URL(test.info().project.use.baseURL ?? page.url()).origin; + await page.request.get("/_agent-native/ui-capability", { + headers: { Origin: origin }, + }); const res = await page.request.post("/_agent-native/actions/share-resource", { data: { resourceType: "plan", @@ -174,7 +177,7 @@ async function shareAsEditor(page: Page, planId: string, email: string) { principalId: email, role: "editor", }, - headers: { "X-Agent-Native-Frontend": "1" }, + headers: { "X-Agent-Native-Frontend": "1", Origin: origin }, }); expect(res.ok(), `share-resource: ${await res.text()}`).toBeTruthy(); } diff --git a/templates/plan/e2e/guest-mode.guest.spec.ts b/templates/plan/e2e/guest-mode.guest.spec.ts index 0f57795dad8..418b0c7c143 100644 --- a/templates/plan/e2e/guest-mode.guest.spec.ts +++ b/templates/plan/e2e/guest-mode.guest.spec.ts @@ -74,12 +74,15 @@ async function createPlanAs( async function makePublic(request: APIRequestContext, planId: string) { // Making a plan public needs approval, which over HTTP only the signed-in // app UI gives: a frontend call holding the UI capability. - await request.get("/_agent-native/ui-capability"); + const origin = new URL(test.info().project.use.baseURL ?? APP_ORIGIN).origin; + await request.get("/_agent-native/ui-capability", { + headers: { Origin: origin }, + }); const res = await request.post( "/_agent-native/actions/set-resource-visibility", { data: { resourceType: "plan", resourceId: planId, visibility: "public" }, - headers: { "X-Agent-Native-Frontend": "1" }, + headers: { "X-Agent-Native-Frontend": "1", Origin: origin }, }, ); expect( From 0de5ecec5a764f9f6cb4d474eb00111bb6e2d832 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:01:39 -0400 Subject: [PATCH 02/15] Require browser sessions for UI approvals --- packages/core/src/a2a/server.spec.ts | 107 +++++++++++++++++- packages/core/src/a2a/server.ts | 8 +- packages/core/src/deploy/build.spec.ts | 21 +++- packages/core/src/deploy/build.ts | 9 +- packages/core/src/mcp/build-server.ts | 4 +- .../legacy-unreviewed-destructive-hint.json | 1 - packages/core/src/mcp/server.spec.ts | 35 ++++++ .../core/src/org/admin-action-errors.spec.ts | 11 ++ packages/core/src/server/action-approval.ts | 13 ++- .../core/src/server/action-routes.spec.ts | 41 ++++++- packages/core/src/server/action-routes.ts | 14 ++- packages/core/src/server/edge.ts | 8 +- .../core/src/server/request-origin.spec.ts | 21 +++- packages/core/src/server/request-origin.ts | 18 +-- .../core/src/server/transcribe-voice.spec.ts | 25 +++- .../src/server/ui-action-capability.spec.ts | 5 + .../core/src/server/ui-action-capability.ts | 4 +- .../e2e/chatgpt-widget-body-paint.spec.ts | 1 + templates/content/e2e/global-teardown.ts | 2 +- templates/content/e2e/helpers.ts | 4 +- templates/content/e2e/live-body-gate.ts | 29 +++-- 21 files changed, 326 insertions(+), 55 deletions(-) diff --git a/packages/core/src/a2a/server.spec.ts b/packages/core/src/a2a/server.spec.ts index 0aa491a7147..3c29d869281 100644 --- a/packages/core/src/a2a/server.spec.ts +++ b/packages/core/src/a2a/server.spec.ts @@ -17,6 +17,7 @@ const setResponseStatusMock = vi.hoisted(() => ); const setResponseHeaderMock = vi.hoisted(() => vi.fn()); const getSessionMock = vi.hoisted(() => vi.fn()); +const getBrowserCookieSessionMock = vi.hoisted(() => vi.fn()); const runWithRequestContextMock = vi.hoisted(() => vi.fn((_context: unknown, fn: () => unknown) => fn()), ); @@ -92,7 +93,10 @@ vi.mock("../org/service-principal-guard.js", async (importActual) => ({ recordServicePrincipalDenial: recordServicePrincipalDenialMock, })); -vi.mock("../server/auth.js", () => ({ getSession: getSessionMock })); +vi.mock("../server/auth.js", () => ({ + getSession: getSessionMock, + getBrowserCookieSession: getBrowserCookieSessionMock, +})); vi.mock("../server/request-context.js", () => ({ runWithRequestContext: runWithRequestContextMock, @@ -142,6 +146,7 @@ describe("mountA2A auth", () => { evaluateServicePrincipalMock.mockResolvedValue({ status: "not-service" }); recordServicePrincipalDenialMock.mockReset(); getSessionMock.mockReset(); + getBrowserCookieSessionMock.mockReset().mockResolvedValue(null); runWithRequestContextMock.mockClear(); getApprovalMock.mockReset(); claimApprovalMock.mockReset(); @@ -623,11 +628,83 @@ describe("mountA2A auth", () => { expect(getApprovalMock).not.toHaveBeenCalled(); }); - it("prevents approval pages from being framed", async () => { + it.each(["GET", "POST"])( + "refuses a bearer-only approval %s without executing", + async (method) => { + getSessionMock.mockResolvedValue({ + email: "owner@example.com", + orgId: "org-1", + }); + const approval = { + id: "approval-1", + taskId: "task-1", + ownerEmail: "owner@example.com", + orgId: "org-1", + tool: "send-email", + input: {}, + status: "pending", + expiresAt: Date.now() + 10_000, + }; + getApprovalMock.mockResolvedValue(approval); + claimApprovalMock.mockResolvedValue(approval); + const executeApproval = vi.fn(async () => ({ + status: "completed" as const, + output: "Email sent", + })); + const handler = await mountedA2AApprovalHandler({ + ...config, + executeApproval, + }); + const event = { + method, + path: "/approval-1", + headers: { authorization: "Bearer test-login-session" }, + context: {}, + }; + const response = await handler(event); + expect(executeApproval).not.toHaveBeenCalled(); + expect(response).toEqual({ + error: "Sign in to review this approval", + }); + expect(event).toMatchObject({ _status: 401 }); + expect(getApprovalMock).not.toHaveBeenCalled(); + expect(claimApprovalMock).not.toHaveBeenCalled(); + }, + ); + + it("refuses an approval POST without browser origin evidence", async () => { + getBrowserCookieSessionMock.mockResolvedValue({ + email: "owner@example.com", + orgId: "org-1", + }); getSessionMock.mockResolvedValue({ email: "owner@example.com", orgId: "org-1", }); + const executeApproval = vi.fn(); + const handler = await mountedA2AApprovalHandler({ + ...config, + executeApproval, + }); + const event = { + method: "POST", + path: "/approval-1", + headers: { cookie: "an_session=test-browser-session" }, + context: {}, + }; + await expect(handler(event)).resolves.toEqual({ + error: "Cross-origin approval denied", + }); + expect(event).toMatchObject({ _status: 403 }); + expect(claimApprovalMock).not.toHaveBeenCalled(); + expect(executeApproval).not.toHaveBeenCalled(); + }); + + it("prevents approval pages from being framed", async () => { + getBrowserCookieSessionMock.mockResolvedValue({ + email: "owner@example.com", + orgId: "org-1", + }); getApprovalMock.mockResolvedValue({ id: "approval-1", ownerEmail: "owner@example.com", @@ -642,6 +719,7 @@ describe("mountA2A auth", () => { const response = await handler({ method: "GET", path: "/approval-1", + headers: { cookie: "an_session=test-browser-session" }, context: {}, }); @@ -664,7 +742,7 @@ describe("mountA2A auth", () => { }); it("makes an interrupted processing approval available for safe recovery", async () => { - getSessionMock.mockResolvedValue({ + getBrowserCookieSessionMock.mockResolvedValue({ email: "owner@example.com", orgId: "org-1", }); @@ -683,6 +761,7 @@ describe("mountA2A auth", () => { const response = await handler({ method: "GET", path: "/approval-1", + headers: { cookie: "an_session=test-browser-session" }, context: {}, }); @@ -706,11 +785,13 @@ describe("mountA2A auth", () => { result: null, expiresAt: Date.now() + 10_000, }; - getSessionMock.mockResolvedValue({ + getBrowserCookieSessionMock.mockResolvedValue({ email: "owner@example.com", orgId: "org-1", }); - claimApprovalMock.mockResolvedValue(approval); + claimApprovalMock + .mockResolvedValueOnce(approval) + .mockResolvedValueOnce(null); getApprovalMock.mockResolvedValue(approval); const executeApproval = vi.fn(async () => ({ status: "completed" as const, @@ -730,6 +811,7 @@ describe("mountA2A auth", () => { host: "mail.example", origin: "https://mail.example", "sec-fetch-site": "same-origin", + cookie: "an_session=test-browser-session", }, }), ).resolves.toEqual({ status: "completed", output: "Email sent" }); @@ -752,6 +834,21 @@ describe("mountA2A auth", () => { "Email sent", ); expect(runLeaseMocks.releaseTurnExecutionLease).toHaveBeenCalledTimes(1); + + const replay = { + method: "POST", + path: "/approval-1", + context: {}, + headers: { + cookie: "an_session=test-browser-session", + "sec-fetch-site": "same-origin", + }, + }; + await expect(handler(replay)).resolves.toMatchObject({ + error: expect.stringContaining("cannot be run again"), + }); + expect(replay).toMatchObject({ _status: 409 }); + expect(executeApproval).toHaveBeenCalledOnce(); }); it("allows legacy apiKeyEnv bearer auth even when A2A_SECRET is configured", async () => { diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index b7757fdf4f1..a446c810402 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -37,7 +37,7 @@ import { readDeployCredentialEnv } from "../server/credential-provider.js"; import { getH3App } from "../server/framework-request-handler.js"; import { publicFrameworkPath } from "../server/framework-route-prefix.js"; import { readBody } from "../server/h3-helpers.js"; -import { isSameOriginRequest } from "../server/request-origin.js"; +import { hasSameOriginBrowserEvidence } from "../server/request-origin.js"; import type { AgentProfileMetadata } from "../shared/agent-profile.js"; import { generateAgentCard } from "./agent-card.js"; import { addAgentProfileExamples } from "./app-profile.js"; @@ -834,8 +834,8 @@ export function mountA2A( return { error: "Approval not found" }; } - const { getSession } = await import("../server/auth.js"); - const session = await getSession(event); + const { getBrowserCookieSession } = await import("../server/auth.js"); + const session = await getBrowserCookieSession(event); if (!session?.email) { setResponseStatus(event, 401); return { error: "Sign in to review this approval" }; @@ -885,7 +885,7 @@ export function mountA2A( setResponseStatus(event, 405); return { error: "Method not allowed" }; } - if (!isSameOriginRequest(event)) { + if (!hasSameOriginBrowserEvidence(event)) { setResponseStatus(event, 403); return { error: "Cross-origin approval denied" }; } diff --git a/packages/core/src/deploy/build.spec.ts b/packages/core/src/deploy/build.spec.ts index 4f636151373..26541d69937 100644 --- a/packages/core/src/deploy/build.spec.ts +++ b/packages/core/src/deploy/build.spec.ts @@ -2298,13 +2298,9 @@ export default { run: async () => ({ ok: true }) }; expect(source).toContain( "hasUiActionCapability as hasGeneratedUiActionCapability", ); - expect(source).toContain( - "isSameOriginRequest as isGeneratedSameOriginRequest", - ); expect(source).toContain( "mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute", ); - expect(source).toContain("!isGeneratedSameOriginRequest(event)"); expect(source).toContain( 'setResponseHeader(event, "Cache-Control", "no-" + "store");', ); @@ -2418,6 +2414,18 @@ export default { it.each([ ["needsApproval: true", "frontend", 403, "ui_capability_required"], ["needsApproval: true", "http", 409, "approval_required"], + [ + 'needsApproval: (_args, ctx) => ctx.caller !== "frontend"', + "frontend bearer", + 409, + "approval_required", + ], + [ + 'needsApproval: (_args, ctx) => ctx.caller !== "frontend"', + "frontend", + 403, + "ui_capability_required", + ], [ 'needsApproval: () => { throw new Error("x"); }', "http", @@ -2453,9 +2461,12 @@ export default { method: "POST", headers: { "content-type": "application/json", - ...(caller === "frontend" + ...(caller.startsWith("frontend") ? { "x-agent-native-frontend": "1" } : {}), + ...(caller === "frontend bearer" + ? { authorization: "Bearer test-session" } + : {}), }, body: JSON.stringify({ id: "thing-1" }), }), diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index 260c6b1cda4..a3c5dd9952a 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -1602,7 +1602,7 @@ ${["post", "put", "delete"] getFrameworkRoutePrefix as getAgentNativeFrameworkRoutePrefix, getSsrAuthRedirectScript as getAgentNativeSsrAuthRedirectScript, resolveAppHomePath as resolveAgentNativeAppHomePath, -${hasActions ? " directHttpActionApprovalRefusal as generatedActionApprovalRefusal,\n getSession as getGeneratedSession,\n hasUiActionCapability as hasGeneratedUiActionCapability,\n isSameOriginRequest as isGeneratedSameOriginRequest,\n mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute,\n resolveOrgIdForEmailViaEvent as resolveGeneratedOrgId,\n runWithRequestContext as runWithGeneratedRequestContext,\n" : ""} +${hasActions ? " directHttpActionApprovalRefusal as generatedActionApprovalRefusal,\n getSession as getGeneratedSession,\n getBrowserCookieSession as getGeneratedBrowserCookieSession,\n hasPresentedEmbedSessionCredential as hasGeneratedEmbedCredential,\n hasUiActionCapability as hasGeneratedUiActionCapability,\n hasSameOriginBrowserEvidence as hasGeneratedSameOriginBrowserEvidence,\n mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute,\n resolveOrgIdForEmailViaEvent as resolveGeneratedOrgId,\n runWithRequestContext as runWithGeneratedRequestContext,\n" : ""} } from "${EDGE_SERVER_ENTRYPOINT}";`, ); @@ -2558,12 +2558,15 @@ ${pluginCalls.join("\n")} ${ hasActions ? ` async function getGeneratedUiActionContext(event) { - const session = await getGeneratedSession(event); + if (event.req.headers.has("authorization") || hasGeneratedEmbedCredential(event)) { + return null; + } + const session = await getGeneratedBrowserCookieSession(event); const userEmail = typeof session?.email === "string" ? session.email.trim().toLowerCase() : undefined; if ( !userEmail || - !isGeneratedSameOriginRequest(event) || + !hasGeneratedSameOriginBrowserEvidence(event) || !hasGeneratedUiActionCapability(event, userEmail) ) { return null; diff --git a/packages/core/src/mcp/build-server.ts b/packages/core/src/mcp/build-server.ts index e0de26ac756..0b067b424a4 100644 --- a/packages/core/src/mcp/build-server.ts +++ b/packages/core/src/mcp/build-server.ts @@ -617,7 +617,7 @@ export function validateMcpDirectoryProfile( `[agent-native] MCP directory widget action "${deniedWidgetAction}" is listed in externalAgents.denyActions.`, ); } - const widgetActionNames = names.filter((name) => + const widgetActionNames = Object.keys(actions).filter((name) => Boolean(actions[name]?.mcpApp?.resource), ); const widgetReadOnlyActions = new Set(profile?.widgetReadOnlyActions ?? []); @@ -648,7 +648,7 @@ export function validateMcpDirectoryProfile( ); if (unknownTarget) { throw new McpDirectoryProfileValidationError( - `[agent-native] MCP directory widget target "${unknownTarget}" must name a listed action with an mcpApp resource. Listed widget actions without a target resolver serve as plain tools.`, + `[agent-native] MCP directory widget target "${unknownTarget}" must name an MCP action with an mcpApp resource. Listed widget actions without a target resolver serve as plain tools.`, ); } } diff --git a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json index 1f1580d53d0..8e03ecbdb91 100644 --- a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json +++ b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json @@ -25,7 +25,6 @@ "create-vault-secret", "create-workspace-resource", "create-workspace-resource-grant", - "create_embed_session", "create_workspace_app", "creative-context-a2a", "delete-data-program", diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 39143912a37..4d8c6206e77 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -1102,6 +1102,41 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", ); }); + it("validates a widget target on an ordinary MCP action outside the directory catalog", () => { + const entry = defineAction({ + description: "Read one record.", + parameters: {}, + readOnly: true, + mcpAnnotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + mcpApp: { + resource: { + title: "Record", + html: "Record", + }, + }, + run: async () => ({ id: "record-1" }), + }); + expect(() => + mcpBuildServer.validateMcpDirectoryProfile({ + ...config, + actions: { "directory-record": entry, "ordinary-record": entry }, + directoryProfile: { + connectorCatalog: ["directory-record"], + widgetTargets: { + "ordinary-record": () => ({ + targetPath: "/record/record-1", + resourceIds: { documentId: "record-1" }, + }), + }, + }, + }), + ).not.toThrow(); + }); + it("keeps a legacy directory catalog discoverable without widgets when widgetTargets is absent", async () => { const legacyWidget = defineAction({ description: "Read one legacy directory record.", diff --git a/packages/core/src/org/admin-action-errors.spec.ts b/packages/core/src/org/admin-action-errors.spec.ts index b3ece180d3b..e412cc3975b 100644 --- a/packages/core/src/org/admin-action-errors.spec.ts +++ b/packages/core/src/org/admin-action-errors.spec.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ execute: vi.fn(), insertAuditEvent: vi.fn(), checkAction: vi.fn(), + getBrowserCookieSession: vi.fn(), })); vi.mock("../db/client.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -18,6 +19,10 @@ vi.mock("../audit/store.js", () => ({ vi.mock("../authorization/check-action.js", () => ({ checkAction: mocks.checkAction, })); +vi.mock("../server/auth.js", async (importOriginal) => ({ + ...(await importOriginal()), + getBrowserCookieSession: mocks.getBrowserCookieSession, +})); // The calls come from the signed-in app, which is where approval is given. vi.mock("../server/ui-action-capability.js", async (importOriginal) => ({ ...(await importOriginal< @@ -73,6 +78,9 @@ async function invoke( headers: { "content-type": "application/json", "x-agent-native-frontend": "1", + host: url.host, + origin: url.origin, + cookie: "an_session=test-browser-session", }, body: JSON.stringify(args), }), @@ -87,6 +95,9 @@ describe("admin-only action errors", () => { mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] }); mocks.insertAuditEvent.mockResolvedValue(undefined); mocks.checkAction.mockResolvedValue({ allowed: true }); + mocks.getBrowserCookieSession.mockResolvedValue({ + email: member.userEmail, + }); }); it.each(["frontend", "http", "tool", "mcp"] satisfies ActionCaller[])( diff --git a/packages/core/src/server/action-approval.ts b/packages/core/src/server/action-approval.ts index 3a002a4f2da..1e081d2fcea 100644 --- a/packages/core/src/server/action-approval.ts +++ b/packages/core/src/server/action-approval.ts @@ -56,7 +56,16 @@ export async function directHttpActionApprovalRefusal( } const prepared = await actionCallIntent(entry, params); if (!prepared.valid) return null; - return (await preparedCallNeedsApproval(entry, prepared, ctx)) - ? actionApprovalRequiredError(ctx.actionName, ctx.caller, false) + return (await preparedCallNeedsApproval(entry, prepared, { + ...ctx, + caller: ctx.caller === "frontend" ? "http" : ctx.caller, + })) + ? actionApprovalRequiredError( + ctx.actionName, + ctx.caller === "frontend" && ctx.requestHeaders?.has("authorization") + ? "http" + : ctx.caller, + false, + ) : null; } diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 43f193cde1d..5472b0c6490 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -478,6 +478,8 @@ describe("mountActionRoutes", () => { type Caller = | "proven UI" | "frontend header without UI proof" + | "browser cookie without capability" + | "bearer with frontend header" | "plain HTTP" | "MCP OAuth bearer" | "org service token" @@ -486,6 +488,7 @@ describe("mountActionRoutes", () => { | "needsApproval true" | "predicate false" | "predicate throws" + | "predicate outside UI" | "no needsApproval"; type Outcome = "runs" | "ui-capability" | "approval-required"; @@ -497,12 +500,18 @@ describe("mountActionRoutes", () => { throw new Error("cannot decide"); }, }, + "predicate outside UI": { + needsApproval: (_args: unknown, ctx: { caller: string }) => + ctx.caller !== "frontend", + }, "no needsApproval": {}, }; const refusedBy: Record = { "proven UI": "runs", "frontend header without UI proof": "ui-capability", + "browser cookie without capability": "ui-capability", + "bearer with frontend header": "approval-required", "plain HTTP": "approval-required", "MCP OAuth bearer": "approval-required", "org service token": "approval-required", @@ -512,6 +521,8 @@ describe("mountActionRoutes", () => { const labelled: Record = { "proven UI": "frontend", "frontend header without UI proof": "frontend", + "browser cookie without capability": "frontend", + "bearer with frontend header": "frontend", "plain HTTP": "http", "MCP OAuth bearer": "mcp", "org service token": "mcp", @@ -550,6 +561,11 @@ describe("mountActionRoutes", () => { const owner = "owner@example.com"; if (caller === "proven UI") { mockHasUiActionCapability.mockReturnValue(true); + } + const browser = + caller === "proven UI" || + caller === "browser cookie without capability"; + if (browser) { mockGetBrowserCookieSession.mockResolvedValue({ email: owner } as any); } @@ -585,6 +601,10 @@ describe("mountActionRoutes", () => { _headers: { host: "app.example.com", "sec-fetch-site": "same-origin", + ...(browser ? { cookie: "an_session=test-browser-session" } : {}), + ...(caller === "bearer with frontend header" + ? { authorization: "Bearer test-token" } + : {}), ...(caller === "plain HTTP" ? {} : { "x-agent-native-frontend": "1" }), @@ -644,6 +664,18 @@ describe("mountActionRoutes", () => { headers: {}, runs: false, }, + { + name: "browser cookie with navigation fetch metadata", + browser: "owner@example.com", + headers: { "sec-fetch-site": "none" }, + runs: false, + }, + { + name: "browser cookie with desktop shell origin", + browser: "owner@example.com", + headers: { origin: "tauri://localhost" }, + runs: false, + }, { name: "general custom session and forged same-origin fetch", headers: { "sec-fetch-site": "same-origin" }, @@ -732,7 +764,10 @@ describe("mountActionRoutes", () => { "better-auth.session_token=test-session; an_ui_capability=test-capability", ...testCase.headers, }, - req: { json: async () => ({}) }, + req: { + url: "https://app.example.com/_agent-native/actions/send-message", + json: async () => ({}), + }, }; const result = await mounted[0]!.handler(event); expect(mockGetSession).toHaveBeenCalled(); @@ -743,7 +778,9 @@ describe("mountActionRoutes", () => { } expect(run).not.toHaveBeenCalled(); expect(result.errorCode).toBe( - testCase.embedded ? "approval_required" : "ui_capability_required", + testCase.embedded || testCase.headers.authorization + ? "approval_required" + : "ui_capability_required", ); if (testCase.embedded) { expect(event._status).toBe(409); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index ccc764bea1d..6e678d268d9 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -127,7 +127,7 @@ import { import { consumeOneTimeJti } from "./identity-sso-store.js"; import { getForwardedRequestOrigin, - isSameOriginRequest, + hasSameOriginBrowserEvidence, } from "./request-origin.js"; import { hasUiActionCapability } from "./ui-action-capability.js"; @@ -294,7 +294,7 @@ async function isProvenUiRequest( !userEmail || embedded || getHeader(event, "authorization") || - !isSameOriginRequest(event) || + !hasSameOriginBrowserEvidence(event) || !hasUiActionCapability(event, userEmail) ) { return false; @@ -1604,7 +1604,11 @@ function mountActionRoutesInternal( !provenUiRequest && (await preparedCallNeedsApproval(entry, prepared, { ...runContext, - caller: embedded ? "mcp" : caller, + caller: embedded + ? "mcp" + : caller === "frontend" + ? "http" + : caller, })) ) { if (embedded && entry.access) { @@ -1616,7 +1620,9 @@ function mountActionRoutesInternal( } throw actionApprovalRequiredError( name, - caller, + caller === "frontend" && getHeader(event, "authorization") + ? "http" + : caller, Boolean(verifiedServiceIdentity), embedded, ); diff --git a/packages/core/src/server/edge.ts b/packages/core/src/server/edge.ts index 06e7750f87d..630bbfa9cd8 100644 --- a/packages/core/src/server/edge.ts +++ b/packages/core/src/server/edge.ts @@ -80,10 +80,14 @@ export { } from "./framework-request-handler.js"; export { directHttpActionApprovalRefusal } from "./action-approval.js"; export { runWithRequestContext } from "./request-context.js"; -export { isSameOriginRequest } from "./request-origin.js"; +export { + hasSameOriginBrowserEvidence, + isSameOriginRequest, +} from "./request-origin.js"; export { hasUiActionCapability, mountUiActionCapabilityRoute, } from "./ui-action-capability.js"; -export { getSession } from "./auth.js"; +export { getBrowserCookieSession, getSession } from "./auth.js"; +export { hasPresentedEmbedSessionCredential } from "./embed-session.js"; export { resolveOrgIdForEmailViaEvent } from "../org/context.js"; diff --git a/packages/core/src/server/request-origin.spec.ts b/packages/core/src/server/request-origin.spec.ts index 893a42e674a..1224bbc72f8 100644 --- a/packages/core/src/server/request-origin.spec.ts +++ b/packages/core/src/server/request-origin.spec.ts @@ -14,6 +14,8 @@ import { getForwardedRequestHostnameFromHeaders, getForwardedRequestOrigin, getForwardedRequestURL, + hasSameOriginBrowserEvidence, + isSameOriginPost, isSameOriginRequest, } from "./request-origin.js"; @@ -328,7 +330,7 @@ describe("isSameOriginRequest", () => { { name: "non-browser client without browser headers", headers: {}, - expected: false, + expected: true, }, { name: "opaque iframe origin", @@ -384,3 +386,20 @@ describe("isSameOriginRequest", () => { expect(isSameOriginRequest(fakeEvent(headers))).toBe(expected); }); }); + +describe("hasSameOriginBrowserEvidence", () => { + it.each([ + [{}, false], + [{ "sec-fetch-site": "same-origin" }, true], + [{ "sec-fetch-site": "none" }, false], + [{ "sec-fetch-site": "cross-site" }, false], + [{ origin: "https://app.example.com" }, true], + [{ origin: "https://other.example.com" }, false], + [{ origin: "null" }, false], + [{ origin: "tauri://localhost" }, false], + ] as const)("checks %j", (headers, expected) => { + const event = fakeEvent(headers, { url: "https://app.example.com/action" }); + expect(hasSameOriginBrowserEvidence(event)).toBe(expected); + expect(isSameOriginPost(event)).toBe(expected); + }); +}); diff --git a/packages/core/src/server/request-origin.ts b/packages/core/src/server/request-origin.ts index b0aad2649c2..c641dff0883 100644 --- a/packages/core/src/server/request-origin.ts +++ b/packages/core/src/server/request-origin.ts @@ -124,25 +124,27 @@ function isLoopbackHost(host: string): boolean { return host.startsWith("localhost:") || host.startsWith("127.0.0.1:"); } -/** - * Stricter than `isSameOriginRequest`, for a POST that writes cookies: a - * request with neither header, a navigation, or a desktop shell's origin - * can't prove it came from this site's pages. - */ -export function isSameOriginPost(event: H3Event): boolean { +/** Positive origin evidence must be paired with browser-cookie identity for UI proof. */ +export function hasSameOriginBrowserEvidence(event: H3Event): boolean { const fetchSite = getRequestHeader(event, "sec-fetch-site"); if (fetchSite) return fetchSite === "same-origin"; const origin = getRequestHeader(event, "origin"); return !!origin && origin === getForwardedRequestOrigin(event); } +/** A cookie-writing POST requires positive origin evidence. */ +export function isSameOriginPost(event: H3Event): boolean { + return hasSameOriginBrowserEvidence(event); +} + export function isSameOriginRequest(event: H3Event): boolean { const fetchSite = getRequestHeader(event, "sec-fetch-site"); if (fetchSite) return fetchSite === "same-origin" || fetchSite === "none"; const host = getRequestHeader(event, "host"); const origin = getRequestHeader(event, "origin"); - if (origin && host) { + if (origin) { + if (!host) return false; try { const parsed = new URL(origin); const forwardedProto = getRequestHeader(event, "x-forwarded-proto"); @@ -178,5 +180,5 @@ export function isSameOriginRequest(event: H3Event): boolean { return false; } } - return false; + return true; } diff --git a/packages/core/src/server/transcribe-voice.spec.ts b/packages/core/src/server/transcribe-voice.spec.ts index bd2da788eb5..6c8edc00d30 100644 --- a/packages/core/src/server/transcribe-voice.spec.ts +++ b/packages/core/src/server/transcribe-voice.spec.ts @@ -12,10 +12,14 @@ const state = vi.hoisted(() => ({ orgVoiceProvider: null as string | null, orgVoiceProviderError: null as Error | null, })); +const getSession = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => null as { email: string } | null), +); vi.mock("h3", () => ({ defineEventHandler: (handler: any) => handler, getMethod: () => "POST", + getRequestHeader: (event: any, name: string) => event.headers?.[name], readMultipartFormData: vi.fn(async (event: any) => { // Node's IncomingMessage emits `close` as soon as its body is fully // read, while the client is still waiting for the response. @@ -30,8 +34,7 @@ vi.mock("h3", () => ({ }, })); -vi.mock("./request-origin.js", () => ({ isSameOriginRequest: () => true })); -vi.mock("./auth.js", () => ({ getSession: async () => null })); +vi.mock("./auth.js", () => ({ getSession })); vi.mock("../org/context.js", () => ({ getOrgContext: async () => null })); vi.mock("../application-state/store.js", () => ({ appStateGet: async () => null, @@ -111,6 +114,24 @@ describe("transcribe-voice Builder provider gate", () => { expect(transcribeWithBuilder).toHaveBeenCalledTimes(1); }); + it("accepts a native session bearer without browser origin headers", async () => { + state.hasGatewayCredential = true; + getSession.mockResolvedValue({ email: "native@example.com" }); + vi.stubEnv("NODE_ENV", "production"); + const event = { + headers: { authorization: "Bearer test-native-session" }, + node: {}, + }; + try { + await expect(post(event)).resolves.toEqual({ text: "hello" }); + expect(getSession).toHaveBeenCalledWith(event); + expect(transcribeWithBuilder).toHaveBeenCalledOnce(); + } finally { + getSession.mockResolvedValue(null); + vi.unstubAllEnvs(); + } + }); + it("gives a visitor the one line when no lane resolves on a credits site", async () => { state.hasGatewayCredential = false; process.env.BUILDER_GATEWAY_TOKEN = "btk-site-token"; diff --git a/packages/core/src/server/ui-action-capability.spec.ts b/packages/core/src/server/ui-action-capability.spec.ts index 16c1dbfce3d..46724f1e288 100644 --- a/packages/core/src/server/ui-action-capability.spec.ts +++ b/packages/core/src/server/ui-action-capability.spec.ts @@ -137,6 +137,11 @@ describe("UI action capability", () => { it.each([ { name: "no browser headers", headers: {} }, + { + name: "navigation fetch metadata", + headers: { "sec-fetch-site": "none" }, + }, + { name: "desktop shell origin", headers: { origin: "tauri://localhost" } }, { name: "cross-site fetch", headers: { "sec-fetch-site": "cross-site" } }, { name: "same-site fetch", headers: { "sec-fetch-site": "same-site" } }, { name: "different origin", headers: { origin: "https://other.test" } }, diff --git a/packages/core/src/server/ui-action-capability.ts b/packages/core/src/server/ui-action-capability.ts index 3f43e844275..fa5d9442603 100644 --- a/packages/core/src/server/ui-action-capability.ts +++ b/packages/core/src/server/ui-action-capability.ts @@ -17,7 +17,7 @@ import { hasPresentedEmbedSessionCredential } from "./embed-session.js"; import { getH3App } from "./framework-request-handler.js"; import { publicFrameworkPath } from "./framework-route-prefix.js"; import { isHttpsRequest } from "./https-request.js"; -import { isSameOriginRequest } from "./request-origin.js"; +import { hasSameOriginBrowserEvidence } from "./request-origin.js"; import { signShortLivedToken, verifyShortLivedToken, @@ -74,7 +74,7 @@ async function issueUiActionCapability(event: H3Event, appBasePath?: string) { return { error: "Authentication required" }; } - if (!isSameOriginRequest(event)) { + if (!hasSameOriginBrowserEvidence(event)) { setResponseStatus(event, 403); return { error: "Same-origin request required" }; } diff --git a/templates/content/e2e/chatgpt-widget-body-paint.spec.ts b/templates/content/e2e/chatgpt-widget-body-paint.spec.ts index 4565241273c..4e587f2b172 100644 --- a/templates/content/e2e/chatgpt-widget-body-paint.spec.ts +++ b/templates/content/e2e/chatgpt-widget-body-paint.spec.ts @@ -138,6 +138,7 @@ test("a scoped read-only Content widget body paints in a nested frame", async ({ } const reviewerEmail = readFileSync(AUTH_FILES.email, "utf8").trim(); const ticket = await createEmbedSessionTicket({ + audienceHost: new URL(baseURL).hostname, ownerEmail: reviewerEmail, targetPath: `/page/${encodeURIComponent(documentId)}`, scope, diff --git a/templates/content/e2e/global-teardown.ts b/templates/content/e2e/global-teardown.ts index 9f1ded31dd3..858ede3fc09 100644 --- a/templates/content/e2e/global-teardown.ts +++ b/templates/content/e2e/global-teardown.ts @@ -22,7 +22,7 @@ async function globalTeardown(config: FullConfig) { // cleanup touches that server's flags or connectors. await expectRunServer(page.request, baseURL); try { - await leaveGateArm(page); + await leaveGateArm(page, baseURL); } finally { await AgentClient.revokeShared(page); } diff --git a/templates/content/e2e/helpers.ts b/templates/content/e2e/helpers.ts index 5c89b6bd0b3..6307cedb496 100644 --- a/templates/content/e2e/helpers.ts +++ b/templates/content/e2e/helpers.ts @@ -194,11 +194,13 @@ export async function postAction>( name: string, data: Record, headers: Record = ACTION_HEADERS, + baseURL?: string, ): Promise { const response = await page.request.post(`/_agent-native/actions/${name}`, { data, headers: { - Origin: new URL(test.info().project.use.baseURL ?? page.url()).origin, + Origin: new URL(baseURL ?? test.info().project.use.baseURL ?? page.url()) + .origin, ...headers, }, }); diff --git a/templates/content/e2e/live-body-gate.ts b/templates/content/e2e/live-body-gate.ts index d865eebb899..bf611f0f21f 100644 --- a/templates/content/e2e/live-body-gate.ts +++ b/templates/content/e2e/live-body-gate.ts @@ -83,7 +83,10 @@ interface FlagRules { * outlives the run, so a later gate-off run signed in as the same account * would find the flags on. Other entries in the rules stay as they are. */ -export async function leaveGateArm(page: Page): Promise { +export async function leaveGateArm( + page: Page, + baseURL?: string, +): Promise { if (GATE_ARM !== "on") return; const session = await page.request.get("/_agent-native/auth/session", { headers: { Accept: "application/json" }, @@ -113,16 +116,22 @@ export async function leaveGateArm(page: Page): Promise { if (flag.key !== SHADOW_FLAG && flag.key !== WRITER_FLAG) continue; if (!flag.rules.emails.includes(email)) continue; const { mode, emails, orgIds, percentage } = flag.rules; - await postAction(page, "set-feature-flag", { - operation: "replace-rules", - key: flag.key, - rules: { - mode, - emails: emails.filter((entry) => entry !== email), - orgIds, - percentage, + await postAction( + page, + "set-feature-flag", + { + operation: "replace-rules", + key: flag.key, + rules: { + mode, + emails: emails.filter((entry) => entry !== email), + orgIds, + percentage, + }, }, - }); + ACTION_HEADERS, + baseURL, + ); } const left = await flagValues(page); for (const key of [SHADOW_FLAG, WRITER_FLAG]) From 978d6aa0b75297a71f0e326823bd8406a103e727 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:36:43 -0400 Subject: [PATCH 03/15] Refine browser and embed session boundaries --- .changeset/tidy-embed-session-scope.md | 2 + packages/core/docs/content/authentication.mdx | 2 + .../content/locales/ar-SA/authentication.mdx | 2 + .../content/locales/de-DE/authentication.mdx | 2 + .../content/locales/es-ES/authentication.mdx | 2 + .../content/locales/fr-FR/authentication.mdx | 2 + .../content/locales/hi-IN/authentication.mdx | 2 + .../content/locales/ja-JP/authentication.mdx | 2 + .../content/locales/ko-KR/authentication.mdx | 2 + .../content/locales/pt-BR/authentication.mdx | 2 + .../content/locales/zh-CN/authentication.mdx | 2 + .../content/locales/zh-TW/authentication.mdx | 2 + .../mcp/directory-template-profiles.spec.ts | 4 +- packages/core/src/mcp/server.spec.ts | 166 +++++++++++++++++- packages/core/src/mcp/server.ts | 19 +- .../core/src/server/action-routes.spec.ts | 45 +++-- packages/core/src/server/auth.spec.ts | 150 +++++++++++++++- packages/core/src/server/auth.ts | 54 ++++-- .../core/src/server/embed-session.spec.ts | 107 ++++++++++- packages/core/src/server/embed-session.ts | 32 +++- packages/core/src/server/embedded.spec.ts | 21 +++ packages/core/src/server/embedded.ts | 20 ++- .../src/server/ui-action-capability.spec.ts | 67 +++++-- .../core/src/server/ui-action-capability.ts | 14 +- packages/docs/server/plugins/auth.ts | 7 + packages/docs/tests/auth.spec.ts | 28 +++ ...configure-first-party-events-model.spec.ts | 26 ++- ...n-keeps-design-export-actions-available.md | 6 + templates/design/server/plugins/auth.spec.ts | 20 +++ templates/design/server/plugins/auth.ts | 1 + 30 files changed, 728 insertions(+), 83 deletions(-) create mode 100644 templates/design/changelog/2026-10-22-builder-sign-in-keeps-design-export-actions-available.md diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md index 75aa93a4192..b1dff904e3d 100644 --- a/.changeset/tidy-embed-session-scope.md +++ b/.changeset/tidy-embed-session-scope.md @@ -3,3 +3,5 @@ --- Scope MCP Apps embed sessions to their resource and declared actions, and require browser cookie sessions for UI capabilities and approvals. + +Apps with a custom `AuthOptions.getSession` resolver must also configure the cookie-only `getBrowserSession` hook to use UI capabilities and browser approvals. Without it, these operations return `401` and the server logs a migration warning. diff --git a/packages/core/docs/content/authentication.mdx b/packages/core/docs/content/authentication.mdx index 16e090bd68d..edb298a0177 100644 --- a/packages/core/docs/content/authentication.mdx +++ b/packages/core/docs/content/authentication.mdx @@ -395,6 +395,8 @@ export default createAuthPlugin({ }); ``` +Custom authentication must also configure the optional `AuthOptions.getBrowserSession` callback for UI capabilities and browser approvals. It accepts an `H3Event` and returns `AuthSession | null`, like `getSession`, but must read browser cookies only, never bearer tokens, query tokens, or other headers. Without this explicit hook, those browser-only operations return `401`. + ## Public Workspace Apps {#public-workspace-apps} Workspace apps are internal by default. To let anonymous visitors load a public diff --git a/packages/core/docs/content/locales/ar-SA/authentication.mdx b/packages/core/docs/content/locales/ar-SA/authentication.mdx index c688ab5a3e1..c38847eec4b 100644 --- a/packages/core/docs/content/locales/ar-SA/authentication.mdx +++ b/packages/core/docs/content/locales/ar-SA/authentication.mdx @@ -337,6 +337,8 @@ export default createAuthPlugin({ }); ``` +يجب أيضًا تهيئة رد الاتصال الاختياري `AuthOptions.getBrowserSession` للمصادقة المخصصة من أجل إمكانات واجهة المستخدم وموافقات المتصفح. يقبل `H3Event` ويعيد `AuthSession | null`، مثل `getSession`، لكن يجب أن يقرأ ملفات تعريف ارتباط المتصفح فقط، ولا يقرأ مطلقًا رموز الحامل أو رموز الاستعلام أو أي ترويسات أخرى. من دون هذا الخطاف الصريح، تُرجع هذه العمليات المخصصة للمتصفح `401`. + ## تطبيقات مساحة العمل العامة {#public-workspace-apps} تعتبر تطبيقات مساحة العمل داخلية بشكل افتراضي. للسماح للزائرين المجهولين بتحميل ملف عام diff --git a/packages/core/docs/content/locales/de-DE/authentication.mdx b/packages/core/docs/content/locales/de-DE/authentication.mdx index 79e68d7adb1..98e17dc58fb 100644 --- a/packages/core/docs/content/locales/de-DE/authentication.mdx +++ b/packages/core/docs/content/locales/de-DE/authentication.mdx @@ -342,6 +342,8 @@ export default createAuthPlugin({ }); ``` +Bei benutzerdefinierter Authentifizierung muss auch der optionale Rückruf `AuthOptions.getBrowserSession` für UI-Funktionen und Browserfreigaben konfiguriert werden. Er akzeptiert ein `H3Event` und gibt wie `getSession` `AuthSession | null` zurück, darf aber ausschließlich Browser-Cookies und niemals Bearer-Token, Query-Token oder andere Header lesen. Ohne diesen expliziten Hook geben diese reinen Browseroperationen `401` zurück. + ## Öffentliche Workspace-Apps {#public-workspace-apps} Workspace-Apps sind standardmäßig intern. Damit anonyme Besucher eine öffentliche laden können diff --git a/packages/core/docs/content/locales/es-ES/authentication.mdx b/packages/core/docs/content/locales/es-ES/authentication.mdx index abe047c5763..361df1ed88d 100644 --- a/packages/core/docs/content/locales/es-ES/authentication.mdx +++ b/packages/core/docs/content/locales/es-ES/authentication.mdx @@ -341,6 +341,8 @@ export default createAuthPlugin({ }); ``` +La autenticación personalizada también debe configurar la devolución de llamada opcional `AuthOptions.getBrowserSession` para las funciones de la interfaz de usuario y las aprobaciones del navegador. Acepta un `H3Event` y devuelve `AuthSession | null`, como `getSession`, pero solo debe leer cookies del navegador, nunca tokens bearer, tokens de consulta ni otros encabezados. Sin este hook explícito, esas operaciones exclusivas del navegador devuelven `401`. + ## Aplicaciones de espacios de trabajo públicos {#public-workspace-apps} Las aplicaciones de Workspace son internas de forma predeterminada. Para permitir que visitantes anónimos carguen un público diff --git a/packages/core/docs/content/locales/fr-FR/authentication.mdx b/packages/core/docs/content/locales/fr-FR/authentication.mdx index 5f6e8e1cde4..2d45ef3eaa5 100644 --- a/packages/core/docs/content/locales/fr-FR/authentication.mdx +++ b/packages/core/docs/content/locales/fr-FR/authentication.mdx @@ -341,6 +341,8 @@ export default createAuthPlugin({ }); ``` +L’authentification personnalisée doit également configurer le rappel facultatif `AuthOptions.getBrowserSession` pour les fonctionnalités de l’interface et les approbations du navigateur. Il accepte un `H3Event` et renvoie `AuthSession | null`, comme `getSession`, mais doit lire uniquement les cookies du navigateur, jamais les jetons bearer, les jetons de requête ni d’autres en-têtes. Sans ce hook explicite, ces opérations réservées au navigateur renvoient `401`. + ## Applications de l'espace de travail public {#public-workspace-apps} Les applications Workspace sont internes par défaut. Pour permettre aux visiteurs anonymes de charger un public diff --git a/packages/core/docs/content/locales/hi-IN/authentication.mdx b/packages/core/docs/content/locales/hi-IN/authentication.mdx index 388639dd5ec..3ea707dda25 100644 --- a/packages/core/docs/content/locales/hi-IN/authentication.mdx +++ b/packages/core/docs/content/locales/hi-IN/authentication.mdx @@ -340,6 +340,8 @@ export default createAuthPlugin({ }); ``` +कस्टम प्रमाणीकरण में UI क्षमताओं और ब्राउज़र अनुमतियों के लिए वैकल्पिक `AuthOptions.getBrowserSession` कॉलबैक भी कॉन्फ़िगर करना होगा। यह `H3Event` स्वीकार करता है और `getSession` की तरह `AuthSession | null` लौटाता है, लेकिन इसे केवल ब्राउज़र कुकीज़ पढ़नी चाहिए; bearer token, query token या अन्य headers कभी नहीं। इस स्पष्ट hook के बिना, केवल ब्राउज़र के लिए उपलब्ध ये कार्रवाइयाँ `401` लौटाती हैं। + ## सार्वजनिक कार्यक्षेत्र ऐप्स {#public-workspace-apps} वर्कस्पेस ऐप्स डिफ़ॉल्ट रूप से आंतरिक होते हैं। अनाम आगंतुकों को सार्वजनिक रूप से लोड करने देने के लिए diff --git a/packages/core/docs/content/locales/ja-JP/authentication.mdx b/packages/core/docs/content/locales/ja-JP/authentication.mdx index 49a2a897c58..c2e2ef2c7eb 100644 --- a/packages/core/docs/content/locales/ja-JP/authentication.mdx +++ b/packages/core/docs/content/locales/ja-JP/authentication.mdx @@ -339,6 +339,8 @@ export default createAuthPlugin({ }); ``` +カスタム認証では、UI 機能とブラウザー承認のために、オプションの `AuthOptions.getBrowserSession` コールバックも設定する必要があります。これは `H3Event` を受け取り、`getSession` と同様に `AuthSession | null` を返しますが、読み取るのはブラウザーの Cookie のみで、Bearer トークン、クエリトークン、その他のヘッダーは読み取ってはいけません。この明示的なフックがない場合、ブラウザー専用のこれらの操作は `401` を返します。 + ## パブリック ワークスペース アプリ {#public-workspace-apps} ワークスペース アプリはデフォルトで内部にあります。匿名の訪問者がパブリックをロードできるようにするには diff --git a/packages/core/docs/content/locales/ko-KR/authentication.mdx b/packages/core/docs/content/locales/ko-KR/authentication.mdx index ad02356b033..e821b4d08e4 100644 --- a/packages/core/docs/content/locales/ko-KR/authentication.mdx +++ b/packages/core/docs/content/locales/ko-KR/authentication.mdx @@ -339,6 +339,8 @@ export default createAuthPlugin({ }); ``` +사용자 지정 인증은 UI 기능과 브라우저 승인에 사용할 선택적 `AuthOptions.getBrowserSession` 콜백도 구성해야 합니다. 이 콜백은 `H3Event`를 받고 `getSession`처럼 `AuthSession | null`을 반환하지만, 브라우저 쿠키만 읽어야 하며 bearer 토큰, 쿼리 토큰 또는 다른 헤더는 절대 읽으면 안 됩니다. 이 명시적 훅이 없으면 브라우저 전용 작업은 `401`을 반환합니다. + ## 공용 작업 공간 앱 {#public-workspace-apps} Workspace 앱은 기본적으로 내부용입니다. 익명의 방문자가 공개를 로드할 수 있도록 하려면 diff --git a/packages/core/docs/content/locales/pt-BR/authentication.mdx b/packages/core/docs/content/locales/pt-BR/authentication.mdx index 16e22b3185b..665f1d7cdd2 100644 --- a/packages/core/docs/content/locales/pt-BR/authentication.mdx +++ b/packages/core/docs/content/locales/pt-BR/authentication.mdx @@ -341,6 +341,8 @@ export default createAuthPlugin({ }); ``` +A autenticação personalizada também deve configurar o retorno de chamada opcional `AuthOptions.getBrowserSession` para recursos da interface e aprovações do navegador. Ele aceita um `H3Event` e retorna `AuthSession | null`, como `getSession`, mas deve ler apenas cookies do navegador, nunca tokens bearer, tokens de consulta ou outros cabeçalhos. Sem esse hook explícito, essas operações exclusivas do navegador retornam `401`. + ## Aplicativos de espaço de trabalho público {#public-workspace-apps} Os aplicativos do Workspace são internos por padrão. Para permitir que visitantes anônimos carreguem um arquivo público diff --git a/packages/core/docs/content/locales/zh-CN/authentication.mdx b/packages/core/docs/content/locales/zh-CN/authentication.mdx index 6392aaa7609..3e2388490b9 100644 --- a/packages/core/docs/content/locales/zh-CN/authentication.mdx +++ b/packages/core/docs/content/locales/zh-CN/authentication.mdx @@ -338,6 +338,8 @@ export default createAuthPlugin({ }); ``` +自定义身份验证还必须配置可选回调 `AuthOptions.getBrowserSession`,用于 UI 功能和浏览器审批。它接收 `H3Event` 并返回 `AuthSession | null`,与 `getSession` 相同,但只能读取浏览器 Cookie,绝不能读取 bearer token、查询 token 或其他标头。没有此显式 hook 时,这些仅限浏览器的操作会返回 `401`。 + ## 公共工作区应用 {#public-workspace-apps} 工作区应用程序默认为内部应用程序。让匿名访问者加载公共 diff --git a/packages/core/docs/content/locales/zh-TW/authentication.mdx b/packages/core/docs/content/locales/zh-TW/authentication.mdx index 5f28d1f9282..9a578801559 100644 --- a/packages/core/docs/content/locales/zh-TW/authentication.mdx +++ b/packages/core/docs/content/locales/zh-TW/authentication.mdx @@ -338,6 +338,8 @@ export default createAuthPlugin({ }); ``` +自訂驗證也必須設定選用回呼 `AuthOptions.getBrowserSession`,供 UI 功能和瀏覽器核准使用。它接受 `H3Event` 並傳回 `AuthSession | null`,與 `getSession` 相同,但只能讀取瀏覽器 Cookie,絕不可讀取 bearer token、查詢 token 或其他標頭。沒有這個明確的 hook 時,這些僅限瀏覽器的操作會傳回 `401`。 + ## 公開工作區應用 {#public-workspace-apps} 工作區應用程式預設為內部應用程式。讓匿名訪客載入公開 diff --git a/packages/core/src/mcp/directory-template-profiles.spec.ts b/packages/core/src/mcp/directory-template-profiles.spec.ts index ef54487331b..23e592ba97d 100644 --- a/packages/core/src/mcp/directory-template-profiles.spec.ts +++ b/packages/core/src/mcp/directory-template-profiles.spec.ts @@ -1522,7 +1522,9 @@ describe("ChatGPT directory template profiles", () => { widgetTargets: { "create-document": target, "get-document": target }, }, }), - ).toThrow(/widget target "get-document" must name a listed action/); + ).toThrow( + /widget target "get-document" must name an MCP action with an mcpApp resource/, + ); }); it("keeps a denied create_embed_session out of tools/list and tools/call", async () => { diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 4d8c6206e77..79fff2dffde 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -376,14 +376,19 @@ function makeWebEvent(opts: MakeEventOpts): any { authorization: "Bearer test-access-token", ...(opts.headers ?? {}), }; - const reqUrl = `https://mail.agent-native.com${opts.path ?? "/"}`; + const host = headers.host || "mail.agent-native.com"; + const protocol = /^(localhost|127\.0\.0\.1)(:|$)/.test(host) + ? "http" + : "https"; + const reqUrl = `${protocol}://${host}${opts.path ?? "/"}`; const webReq = new Request(reqUrl, { method: opts.method ?? "POST", headers, }); + Object.assign(webReq, { context: { clientAddress: opts.ip } }); const event: any = { method: opts.method ?? "POST", - url: { pathname: (opts.path ?? "/").split("?")[0] }, + url: new URL(reqUrl), path: opts.path ?? "/", req: webReq, _headers: headers, @@ -428,13 +433,13 @@ function makeWebEvent(opts: MakeEventOpts): any { return event; } -vi.mock("h3", () => ({ +vi.mock("h3", async (importOriginal) => ({ + ...(await importOriginal()), defineEventHandler: (fn: any) => fn, getMethod: (event: any) => event.method ?? "GET", getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], getRequestHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], - getRequestIP: (event: any) => event._ip, getQuery: (event: any) => event._query ?? {}, setResponseStatus: (event: any, code: number) => { event._status = code; @@ -722,7 +727,10 @@ async function callWeb( opts.routePath, ); expect(res).toBeInstanceOf(Response); - const response = res as Response; + return readWebResponse(res as Response); +} + +async function readWebResponse(response: Response): Promise { const ct = response.headers.get("content-type") || ""; const text = await response.text(); if (ct.includes("text/event-stream")) { @@ -2381,6 +2389,154 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", ).rejects.toThrow(/readOnlyHint must match its readOnly action setting/); }); + describe("trusted widget request origin", () => { + beforeEach(() => { + vi.stubEnv("APP_URL", "https://mail.agent-native.com"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([false, true])( + "uses the raw request origin for widget tickets despite untrusted forwarded headers (Node: %s)", + async (node) => { + const actual = await vi.importActual< + typeof import("../server/embed-session.js") + >("../server/embed-session.js"); + embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce( + async (input) => ({ + ...(await actual.createEmbedSessionTicket( + input as Parameters[0], + )), + targetPath: input.targetPath, + }), + ); + const event = makeWebEvent({ + node, + ip: "203.0.113.20", + headers: { + ...(await mcpAppsAuthHeaders()), + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "http", + "x-forwarded-for": "127.0.0.1", + }, + body: { + jsonrpc: "2.0", + id: 138, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + const response = await handleMcpRequest( + event, + scopedWidgetConfig(config), + ); + expect(response).toBeInstanceOf(Response); + const called = await readWebResponse(response as Response); + expect(called.result.isError).not.toBe(true); + const startUrl = new URL( + called.result._meta["agent-native/embedStart"].startUrl, + ); + expect(startUrl.origin).toBe("https://mail.agent-native.com"); + expect(startUrl.pathname).toBe("/_agent-native/embed/start"); + expect(startUrl.searchParams.get("__an_mcp_chat_bridge")).toBe("1"); + const ticket = startUrl.searchParams.get("ticket"); + expect(ticket).toBeTruthy(); + expect( + embedSessionMocks.createEmbedSessionTicket, + ).toHaveBeenCalledOnce(); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket(ticket!, { + audienceHost: "mail.agent-native.com", + }), + ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" }); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket(ticket!, { + audienceHost: "attacker.example", + }), + ).resolves.toBeNull(); + expect(JSON.stringify(called)).not.toContain("attacker.example"); + }, + ); + + it("uses validated forwarded authority from a loopback proxy for widget tickets", async () => { + const event = makeWebEvent({ + ip: "::ffff:127.0.0.1", + headers: { + ...(await mcpAppsAuthHeaders()), + host: "127.0.0.1:8100", + "x-forwarded-host": "mail.agent-native.com, internal.gateway:3000", + "x-forwarded-proto": "https, http", + }, + body: { + jsonrpc: "2.0", + id: 138, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + const response = await handleMcpRequest( + event, + scopedWidgetConfig(config), + ); + expect(response).toBeInstanceOf(Response); + const called = await readWebResponse(response as Response); + expect(called.result._meta["agent-native/embedStart"].startUrl).toBe( + "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1", + ); + expect(embedSessionMocks.createEmbedSessionTicket).toHaveBeenCalledOnce(); + }); + + it.each([ + { "x-forwarded-host": "mail.agent-native.com/path" }, + { "x-forwarded-host": "", "x-forwarded-proto": "https" }, + { "x-forwarded-proto": "javascript" }, + ])( + "rejects invalid trusted proxy authority before minting: %j", + async (headers) => { + const event = makeWebEvent({ + ip: "127.0.0.1", + headers: { ...(await mcpAppsAuthHeaders()), ...headers }, + body: { + jsonrpc: "2.0", + id: 138, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + await expect( + handleMcpRequest(event, scopedWidgetConfig(config)), + ).rejects.toMatchObject({ statusCode: 400 }); + expect( + embedSessionMocks.createEmbedSessionTicket, + ).not.toHaveBeenCalled(); + }, + ); + + it("does not recover missing raw authority from untrusted forwarded headers", async () => { + const event = makeWebEvent({ + ip: "203.0.113.20", + headers: { + ...(await mcpAppsAuthHeaders()), + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "https", + }, + body: { + jsonrpc: "2.0", + id: 138, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + delete event.url; + event.req = { headers: event.req.headers, url: "/" }; + await expect( + handleMcpRequest(event, scopedWidgetConfig(config)), + ).rejects.toThrow(); + expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled(); + }); + }); + it("mints a regular MCP widget embed ticket from an action link", async () => { const createArtifact = defineAction({ description: "Create one editable document.", diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index dd70032e7c8..96d1498bf88 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -18,6 +18,7 @@ import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../server/credential- import { getH3App } from "../server/framework-request-handler.js"; import { getOrigin } from "../server/google-oauth.js"; import { readBody } from "../server/h3-helpers.js"; +import { getForwardedRequestOrigin } from "../server/request-origin.js"; import { verifyAuth, McpIdentityVerificationUnavailableError, @@ -63,14 +64,7 @@ export { export type { MCPConfig, MCPCallerIdentity, MCPRequestMeta }; function deriveRequestMeta(event: H3Event): MCPRequestMeta { - const forwardedProto = getRequestHeader(event, "x-forwarded-proto"); - const host = - getRequestHeader(event, "x-forwarded-host") || - getRequestHeader(event, "host"); - const proto = - forwardedProto?.split(",")[0]?.trim() || - (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); - const origin = host ? `${proto}://${host}` : undefined; + const origin = getForwardedRequestOrigin(event); const targetHeader = getRequestHeader( event, "x-agent-native-open-target", @@ -155,14 +149,9 @@ function buildWebRequest( } } - const host = - headers.get("x-forwarded-host") || headers.get("host") || "localhost"; - const forwardedProto = headers.get("x-forwarded-proto"); - const proto = - forwardedProto?.split(",")[0]?.trim() || - (/^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); + const origin = getForwardedRequestOrigin(event); const basePath = getConfiguredAppBasePath(); - const url = `${proto}://${host}${basePath}${routePath}`; + const url = `${origin}${basePath}${routePath}`; return new Request(url, { method, headers }); } diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 5472b0c6490..4c91ae516b8 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -789,8 +789,18 @@ describe("mountActionRoutes", () => { }); it.each( - (["valid", "expired", "invalid"] as const).flatMap((credential) => - [true, false].map((needsApproval) => ({ credential, needsApproval })), + ( + [ + "other-target", + "expired", + "invalid", + "marked", + "live-capability", + ] as const + ).flatMap((credential) => + (credential === "live-capability" ? [true] : [true, false]).map( + (needsApproval) => ({ credential, needsApproval }), + ), ), )( "keeps a real $credential embed cookie separate from browser authority (approval: $needsApproval)", @@ -815,6 +825,7 @@ describe("mountActionRoutes", () => { embedSessions.hasPresentedEmbedSessionCredential, ); const owner = "owner@example.com"; + mockResolveOrgScopeForEmail.mockResolvedValue({ status: "unknown" }); mockGetBrowserCookieSession.mockResolvedValue({ email: owner } as any); mockHasUiActionCapability.mockReturnValue(true); const issuedAt = @@ -829,6 +840,9 @@ describe("mountActionRoutes", () => { audienceHost: "app.example.com", targetPath: "/page/doc-1", ttlSeconds: 60, + ...(credential === "live-capability" + ? { scope: "capability:visual-edit:design:doc-1" } + : {}), }); issuedAt?.mockRestore(); const mounted: Array<{ path: string; handler: any }> = []; @@ -838,7 +852,13 @@ describe("mountActionRoutes", () => { { use: (path: string, handler: any) => mounted.push({ path, handler }), }, - { "update-document": { run, needsApproval } as any }, + { + "update-document": { + run, + needsApproval, + capabilityScopes: ["visual-edit"], + } as any, + }, { getOwnerFromEvent }, ); const event: any = { @@ -848,21 +868,24 @@ describe("mountActionRoutes", () => { origin: "https://app.example.com", "sec-fetch-site": "same-origin", "x-agent-native-frontend": "1", + ...(credential === "marked" + ? { "x-agent-native-embed-target": "/page/doc-1" } + : {}), cookie: `better-auth.session_token=test-session; an_ui_capability=test-capability; ${EMBED_SESSION_COOKIE}=${token}`, }, req: { method: "POST", url: "https://app.example.com/_agent-native/actions/update-document", - json: async () => ({ id: "doc-1" }), + json: async () => ({ id: "doc-1", designId: "doc-1" }), }, }; - await expect( - embedSessions.resolveEmbedSessionFromRequest(event), - ).resolves.toBeNull(); - expect(embedSessions.hasPresentedEmbedSessionCredential(event)).toBe( - credential !== "invalid", - ); - if (credential === "invalid") { + if (credential === "live-capability") { + await expect(mounted[0]!.handler(event)).resolves.toMatchObject({ + errorCode: "approval_required", + }); + expect(event._status).toBe(409); + expect(run).not.toHaveBeenCalled(); + } else if (credential !== "marked") { await expect(mounted[0]!.handler(event)).resolves.toEqual({ ok: true }); expect(run).toHaveBeenCalledOnce(); expect(getOwnerFromEvent).toHaveBeenCalledOnce(); diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index d49072ae93b..8eb837bc625 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -113,6 +113,11 @@ describe("server/auth", () => { vi.doUnmock("../org/auth-policy.js"); vi.doUnmock("../org/workspace-app-access.js"); vi.doUnmock("./embed-session.js"); + vi.doUnmock("./framework-request-handler.js"); + vi.doUnmock("@agent-native/core/action"); + vi.doUnmock("@agent-native/core/server"); + vi.doUnmock("@agent-native/core/labs/server"); + vi.doUnmock("@agent-native/core/sharing"); vi.doUnmock("./email.js"); vi.doUnmock("./request-origin.js"); vi.doUnmock("./sentry.js"); @@ -9002,7 +9007,8 @@ describe("server/auth", () => { vi.doMock("./legacy-auth-migration.js", () => ({ resolveCanonicalUserForLegacySession: vi.fn(async () => null), })); - vi.doMock("../org/context.js", () => ({ + vi.doMock("../org/context.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveOrgIdForEmailViaEvent: vi.fn(async () => "org-cookie"), })); vi.doMock("./embed-session.js", async (importOriginal) => ({ @@ -9151,6 +9157,148 @@ describe("server/auth", () => { }, ); + it("names the missing custom browser hook and warns only once", async () => { + vi.doMock("./framework-request-handler.js", () => ({ + getH3App: (app: any) => app, + })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { autoMountAuth } = await import("./auth.js"); + await autoMountAuth(createMockApp(), { + getSession: async () => ({ email: credentialOwner }), + }); + const { mountUiActionCapabilityRoute } = + await import("./ui-action-capability.js"); + const mounted = createMockApp(); + mountUiActionCapabilityRoute(mounted); + const handler = mounted.use.mock.calls[0][1]; + for (let i = 0; i < 2; i++) { + const event = createMockEvent({ + headers: { + cookie: "custom-login=browser", + "sec-fetch-site": "same-origin", + }, + }); + await expect(handler(event)).resolves.toEqual({ + error: expect.stringContaining("AuthOptions.getBrowserSession"), + }); + expect(event.res.status).toBe(401); + } + expect( + warn.mock.calls.filter(([message]) => + String(message).includes("AuthOptions.getBrowserSession"), + ), + ).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); + + it.each([false, true])( + "refuses bearer capability minting with an explicit browser hook (cookie=%s)", + async (cookie) => { + vi.doMock("./framework-request-handler.js", () => ({ + getH3App: (app: any) => app, + })); + const { autoMountAuth } = await import("./auth.js"); + const getBrowserSession = vi.fn(async () => ({ email: cookieOwner })); + await autoMountAuth(createMockApp(), { + getSession: async () => ({ email: credentialOwner }), + getBrowserSession, + }); + const { mountUiActionCapabilityRoute } = + await import("./ui-action-capability.js"); + const mounted = createMockApp(); + mountUiActionCapabilityRoute(mounted); + const event = createMockEvent({ + headers: { + authorization: "Bearer bearer-login", + "sec-fetch-site": "same-origin", + ...(cookie ? { cookie: "custom-login=browser" } : {}), + }, + }); + await expect(mounted.use.mock.calls[0][1](event)).resolves.toEqual({ + error: "Authentication required", + }); + expect(event.res.status).toBe(401); + expect(event.res.headers.get("set-cookie")).toBeNull(); + expect(getBrowserSession).not.toHaveBeenCalled(); + }, + ); + + it("uses Design's signed Builder cookie to mint a capability and run a UI-only action", async () => { + vi.stubEnv("BUILDER_IDENTITY", "1"); + vi.stubEnv("OAUTH_STATE_SECRET", "test-builder-ui-capability-secret"); + vi.doMock("@agent-native/core/action", () => import("../action.js")); + vi.doMock("@agent-native/core/server", () => ({ + registerDelegatedBuilderCredentials: vi.fn(), + })); + vi.doMock("@agent-native/core/labs/server", () => ({ + registerHostLabOverrides: vi.fn(), + })); + vi.doMock("@agent-native/core/sharing", () => ({ + registerHostAccessResolver: vi.fn(), + })); + vi.doMock("./framework-request-handler.js", () => ({ + getH3App: (app: any) => app, + })); + const { + BUILDER_IDENTITY_COOKIE, + encodeIdentityCookie, + getBuilderIdentitySession, + } = + await import("../../../../templates/design/server/lib/builder-identity.js"); + const { autoMountAuth, getSession } = await import("./auth.js"); + await autoMountAuth(createMockApp(), { + getSession: getBuilderIdentitySession, + getBrowserSession: getBuilderIdentitySession, + trustCustomEmailVerification: true, + }); + const { mountUiActionCapabilityRoute } = + await import("./ui-action-capability.js"); + const mounted = createMockApp(); + mountUiActionCapabilityRoute(mounted); + const cookie = `${BUILDER_IDENTITY_COOKIE}=${encodeIdentityCookie({ uid: "builder-user", email: cookieOwner })}`; + const event = createMockEvent({ + path: "/_agent-native/ui-capability", + headers: { cookie, "sec-fetch-site": "same-origin" }, + }); + await expect(mounted.use.mock.calls[0][1](event)).resolves.toEqual({ + ok: true, + }); + expect(event.res.status).toBe(200); + const capabilityCookie = event.res.headers + .get("set-cookie")! + .split(";")[0]; + expect(capabilityCookie).toMatch(/^agent-native-ui-capability=/); + + const { mountActionRoutes } = await import("./action-routes.js"); + const run = vi.fn(async () => ({ exported: true })); + const actions = createMockApp(); + mountActionRoutes( + actions, + { "render-export-png": { uiOnly: true, run } as any }, + { + getOwnerFromEvent: async (request) => + (await getSession(request))?.email, + resolveOrgId: async () => "org-cookie", + }, + ); + const request = createJsonPostEvent( + "/_agent-native/actions/render-export-png", + {}, + { + cookie: `${cookie}; ${capabilityCookie}`, + "x-agent-native-frontend": "1", + "sec-fetch-site": "same-origin", + }, + ); + await expect(actions.use.mock.calls[0][1](request)).resolves.toEqual({ + exported: true, + }); + expect(run).toHaveBeenCalledOnce(); + }, 30_000); + it("does not use the desktop SSO fallback as a browser cookie session", async () => { vi.stubEnv("NODE_ENV", "development"); vi.stubEnv("AGENT_NATIVE_DISABLE_DESKTOP_SSO_FALLBACK", "0"); diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index cf83ab7f3fb..a61d5526068 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -397,6 +397,11 @@ export interface AuthSessionResponse extends AuthSession { export interface AuthOptions { maxAge?: number; getSession?: (event: H3Event) => Promise; + /** + * With custom authentication, resolve browser identity from cookies only. + * Never accept bearer tokens, query tokens, or other request headers here. + */ + getBrowserSession?: (event: H3Event) => Promise; /** * Set only when the custom provider independently verifies email ownership. * Without this opt-in, an omitted `emailVerified` value remains unknown. @@ -2323,6 +2328,8 @@ async function mapLegacySession( let customGetSession: ((event: H3Event) => Promise) | null = null; +let customGetBrowserSession: AuthOptions["getBrowserSession"] | null = null; +let warnedMissingBrowserSession = false; let trustCustomEmailVerification = false; interface AuthGuardConfig { @@ -4822,11 +4829,12 @@ export async function getSession(event: H3Event): Promise { /** * The browser's signed-in user, read only from the session cookies sign-in * sets: Better Auth's and the framework's own. No other credential counts: - * not a bearer or embed token, a query token, a custom resolver, or the + * not a bearer or embed token, a query token, a generic custom resolver, or the * desktop fallback, nor any credential `getSession` accepts in future. For * pages whose decisions only the person may make. An app with a custom - * resolver has no session here, since the framework can't tell which - * credentials that resolver reads. The framework cookie takes a raw login + * resolver must supply the explicit cookie-only `getBrowserSession` hook, + * since the framework can't tell which credentials `getSession` reads. + * The framework cookie takes a raw login * token, so a desktop or mobile app can present its own login here; that app * is the user. With AUTH_DISABLED every request is the dev account, here too. */ @@ -4840,10 +4848,28 @@ export async function getBrowserCookieSession( return resolved; } +export function getBrowserSessionConfigurationError(): string | undefined { + if (customGetSession && !customGetBrowserSession) { + return "Custom authentication requires AuthOptions.getBrowserSession for browser UI capabilities and approvals."; + } + return undefined; +} + async function resolveBrowserCookieSession( event: H3Event, ): Promise { - if (customGetSession) return null; + if (customGetSession) { + if (!customGetBrowserSession) { + if (!warnedMissingBrowserSession) { + console.warn(`[auth] ${getBrowserSessionConfigurationError()}`); + warnedMissingBrowserSession = true; + } + return null; + } + if (!getHeader(event, "cookie")) return null; + const session = await customGetBrowserSession(event); + return session ? normalizeCustomSession(session) : null; + } const cookie = getHeader(event, "cookie"); if (cookie) { try { @@ -4866,6 +4892,15 @@ async function resolveBrowserCookieSession( return getAuthDisabledSession(event); } +function normalizeCustomSession(session: AuthSession): AuthSession { + const safeSession = { ...session }; + delete safeSession.authUserId; + if (trustCustomEmailVerification && session.emailVerified === undefined) { + return { ...safeSession, emailVerified: true }; + } + return safeSession; +} + async function resolveSessionUncached( event: H3Event, options: { ignoreEmbedSession?: boolean } = {}, @@ -4899,12 +4934,7 @@ async function resolveSessionUncached( if (customGetSession) { const session = await customGetSession(event); if (session) { - const safeSession = { ...session }; - delete safeSession.authUserId; - if (trustCustomEmailVerification && session.emailVerified === undefined) { - return { ...safeSession, emailVerified: true }; - } - return safeSession; + return normalizeCustomSession(session); } const bearerSession = await getBearerSession(event); @@ -7300,6 +7330,7 @@ export async function autoMountAuth( } if (options.getSession) { customGetSession = options.getSession; + customGetBrowserSession = options.getBrowserSession ?? null; trustCustomEmailVerification = options.trustCustomEmailVerification === true; } @@ -7365,6 +7396,7 @@ export async function autoMountAuth( if (!app) { if (isDevEnvironment()) { customGetSession = null; + customGetBrowserSession = null; trustCustomEmailVerification = false; return false; } @@ -7374,6 +7406,7 @@ export async function autoMountAuth( } customGetSession = null; + customGetBrowserSession = null; trustCustomEmailVerification = false; sessionMaxAge = options.maxAge ?? DEFAULT_MAX_AGE; const publicPaths = resolveAuthPublicPaths(options.publicPaths); @@ -7395,6 +7428,7 @@ export async function autoMountAuth( if (options.getSession) { customGetSession = options.getSession; + customGetBrowserSession = options.getBrowserSession ?? null; trustCustomEmailVerification = options.trustCustomEmailVerification === true; } diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index 9eb5f855332..2333ac842dc 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -1581,7 +1581,7 @@ describe("requestMatchesEmbedTarget", () => { }); it.each(["bearer", "cookie"] as const)( - "recognizes a presented ordinary %s session independently of route eligibility and expiry", + "distinguishes explicit %s credentials from ambient cookies by route eligibility and expiry", async (transport) => { vi.useFakeTimers(); try { @@ -1599,13 +1599,22 @@ describe("requestMatchesEmbedTarget", () => { event.req.method = "POST"; await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); expect(hasExplicitEmbedSessionCredential(event)).toBe(false); - expect(hasPresentedEmbedSessionCredential(event)).toBe(true); + expect(hasPresentedEmbedSessionCredential(event)).toBe( + transport === "bearer", + ); + const document = credentialEvent("/inbox", token, transport); + expect(hasPresentedEmbedSessionCredential(document)).toBe(true); vi.advanceTimersByTime(2_000); expect(verifyEmbedSessionToken(token)).toMatchObject({ ok: false, reason: "expired", }); - expect(hasPresentedEmbedSessionCredential(event)).toBe(true); + expect(hasPresentedEmbedSessionCredential(event)).toBe( + transport === "bearer", + ); + expect(hasPresentedEmbedSessionCredential(document)).toBe( + transport === "bearer", + ); const invalid = credentialEvent( "/_agent-native/actions/get-document", "invalid-stale-token", @@ -1618,7 +1627,63 @@ describe("requestMatchesEmbedTarget", () => { }, ); + it.each([ + { + path: "/inbox?documentId=doc-1", + host: "mail.test", + base: "", + presented: true, + }, + { + path: "/inbox?documentId=doc-2", + host: "mail.test", + base: "", + presented: false, + }, + { + path: "/inbox?documentId=doc-1&extra=1", + host: "mail.test", + base: "", + presented: false, + }, + { path: "/settings", host: "mail.test", base: "", presented: false }, + { + path: "/inbox?documentId=doc-1", + host: "calendar.test", + base: "", + presented: false, + }, + { + path: "/calendar/inbox?documentId=doc-1", + host: "mail.test", + base: "/calendar", + presented: false, + }, + ])( + "classifies an ambient cookie from its signed target: $host $path", + async ({ path, host, base, presented }) => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox?documentId=doc-1", + }); + process.env.APP_BASE_PATH = base; + const event = credentialEvent(path, token, "cookie", { + host, + referer: "http://mail.test/inbox?documentId=doc-1", + }); + expect(hasPresentedEmbedSessionCredential(event)).toBe(presented); + if (!presented) + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + }, + ); + it("recognizes explicit embed markers without treating an unrelated cookie as embed credentials", () => { + expect( + hasPresentedEmbedSessionCredential( + fakeEvent("/_agent-native/actions/get-document?embedded=1"), + ), + ).toBe(true); expect( hasPresentedEmbedSessionCredential( fakeEvent( @@ -2536,13 +2601,45 @@ describe("requestMatchesEmbedTarget", () => { "x-forwarded-proto": "https", }); - setEmbedSessionCookie(event, "embed-token"); + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + targetPath: "/", + }); + setEmbedSessionCookie(event, token); const cookie = event.res.headers.get("set-cookie") ?? ""; - expect(cookie).toContain(`${EMBED_SESSION_COOKIE}=embed-token`); + expect(cookie).toContain(`${EMBED_SESSION_COOKIE}=${token}`); expect(cookie).not.toMatch(/Domain=\.agent-native\.com/i); }); + it("caps cookie Max-Age at the signed token's remaining lifetime", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-10-22T12:00:00.000Z")); + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + ttlSeconds: 60, + }); + vi.advanceTimersByTime(20_250); + const event = fakeEvent("/inbox"); + setEmbedSessionCookie(event, token); + expect(event.res.headers.get("set-cookie")).toMatch(/Max-Age=39(?:;|$)/i); + vi.advanceTimersByTime(39_750); + expect(verifyEmbedSessionToken(token)).toMatchObject({ + ok: false, + reason: "expired", + }); + const expired = fakeEvent("/inbox"); + setEmbedSessionCookie(expired, token); + expect(expired.res.headers.get("set-cookie")).toMatch( + /Max-Age=0(?:;|$)/i, + ); + } finally { + vi.useRealTimers(); + } + }); + it("allows Vite module runtime requests with the embed query token", async () => { process.env.OAUTH_STATE_SECRET = "embed-test-secret"; const token = signEmbedSessionToken({ diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index aca3ee1b8ac..0548ff145e9 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -1337,7 +1337,7 @@ export async function resolveEmbedSessionTokenForHost( return null; } scope = session.scope; - } else if (claims.exp < Math.floor(Date.now() / 1000)) { + } else if (claims.exp * 1000 <= Date.now()) { return null; } if (widgetCapability && !isMcpDirectoryWidgetCapabilityScope(scope)) { @@ -1786,7 +1786,7 @@ export function verifyEmbedSessionToken( ) { return { ok: false, reason: "claims" }; } - if (!options.allowExpired && claims.exp < Math.floor(Date.now() / 1000)) { + if (!options.allowExpired && claims.exp * 1000 <= Date.now()) { return { ok: false, reason: "expired" }; } claims.targetPath = normalizeEmbedTargetPath(claims.targetPath) ?? "/"; @@ -1830,9 +1830,18 @@ export function setEmbedSessionCookie(event: H3Event, token: string): void { setCookie(event, EMBED_SESSION_COOKIE, "", { ...attrs, maxAge: 0 }); return; } + const verified = verifyEmbedSessionToken(token); setCookie(event, EMBED_SESSION_COOKIE, token, { ...attrs, - maxAge: DEFAULT_TOKEN_TTL_SECONDS, + maxAge: verified.ok + ? Math.max( + 0, + Math.min( + DEFAULT_TOKEN_TTL_SECONDS, + Math.floor(verified.claims.exp - Date.now() / 1000), + ), + ) + : 0, }); } @@ -1924,13 +1933,26 @@ export function hasExplicitEmbedSessionCredential(event: H3Event): boolean { export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { if ( queryToken(event) || + isEmbedModeRequest(event) || getHeader(event, EMBED_TARGET_HEADER) !== undefined || getQuery(event)?.[EMBED_TARGET_QUERY_PARAM] !== undefined ) { return true; } - return [bearerToken(event), getCookie(event, EMBED_SESSION_COOKIE)].some( - (token) => verifyEmbedSessionToken(token, { allowExpired: true }).ok, + if (verifyEmbedSessionToken(bearerToken(event), { allowExpired: true }).ok) { + return true; + } + const cookie = verifyEmbedSessionToken( + getCookie(event, EMBED_SESSION_COOKIE), + ); + if (!cookie.ok || !embedTokenMatchesRequestAudience(event, cookie.claims)) { + return false; + } + // A live capability can restrict browser proof minting without authorizing it. + return ( + embedSessionAllowsRequest(event, cookie.claims) || + (isEmbedCapabilityScope(cookie.claims.scope) && + requestPathname(event) === "/_agent-native/ui-capability") ); } diff --git a/packages/core/src/server/embedded.spec.ts b/packages/core/src/server/embedded.spec.ts index c9c7a6f4030..2532a326ce5 100644 --- a/packages/core/src/server/embedded.spec.ts +++ b/packages/core/src/server/embedded.spec.ts @@ -179,6 +179,27 @@ describe("embedded Agent-Native helpers", () => { orgId: "org-2", }); expect(auth?.mountGoogleOAuthRoutes).toBe(false); + expect(auth?.getBrowserSession).toBeUndefined(); + }); + + it("normalizes an explicitly declared cookie-only host session separately", async () => { + const getSession = vi.fn(async () => ({ email: "agent@example.com" })); + const getBrowserSession = vi.fn(async () => ({ + userId: "browser-owner", + organizationId: "browser-org", + })); + const auth = createAgentNativeEmbeddedAuthOptions({ + getSession, + getBrowserSession, + }); + await expect(auth?.getBrowserSession?.({} as never)).resolves.toMatchObject( + { + email: "browser-owner", + orgId: "browser-org", + }, + ); + expect(getSession).not.toHaveBeenCalled(); + expect(getBrowserSession).toHaveBeenCalledOnce(); }); it("applies explicit embedded database environment", async () => { diff --git a/packages/core/src/server/embedded.ts b/packages/core/src/server/embedded.ts index ba4b9d2574b..2c49e2c21f8 100644 --- a/packages/core/src/server/embedded.ts +++ b/packages/core/src/server/embedded.ts @@ -54,9 +54,10 @@ export type AgentNativeEmbeddedGetSession = ( export interface AgentNativeEmbeddedAuthOptions extends Omit< AuthOptions, - "getSession" + "getSession" | "getBrowserSession" > { getSession: AgentNativeEmbeddedGetSession; + getBrowserSession?: AgentNativeEmbeddedGetSession; } export interface AgentNativeEmbeddedPluginOptions { @@ -136,16 +137,21 @@ export function createAgentNativeEmbeddedAuthOptions( ): AuthOptions | undefined { if (!auth) return undefined; - const authOptions = - typeof auth === "function" - ? ({ getSession: auth } satisfies AgentNativeEmbeddedAuthOptions) - : auth; + const authOptions: AgentNativeEmbeddedAuthOptions = + typeof auth === "function" ? { getSession: auth } : auth; + const { getSession, getBrowserSession, ...options } = authOptions; return { mountGoogleOAuthRoutes: false, - ...authOptions, + ...options, getSession: async (event) => - normalizeAgentNativeEmbeddedSession(await authOptions.getSession(event)), + normalizeAgentNativeEmbeddedSession(await getSession(event)), + ...(getBrowserSession + ? { + getBrowserSession: async (event: H3Event) => + normalizeAgentNativeEmbeddedSession(await getBrowserSession(event)), + } + : {}), }; } diff --git a/packages/core/src/server/ui-action-capability.spec.ts b/packages/core/src/server/ui-action-capability.spec.ts index 46724f1e288..4b7d19d6759 100644 --- a/packages/core/src/server/ui-action-capability.spec.ts +++ b/packages/core/src/server/ui-action-capability.spec.ts @@ -7,6 +7,7 @@ const { mockGetSession, mockGetBrowserCookieSession } = vi.hoisted(() => ({ })); vi.mock("./auth.js", () => ({ + getBrowserSessionConfigurationError: () => undefined, getSession: (...args: unknown[]) => mockGetSession(...args), getBrowserCookieSession: (...args: unknown[]) => mockGetBrowserCookieSession(...args), @@ -265,6 +266,7 @@ describe("UI action capability", () => { ownerEmail, targetPath: "/documents/doc-1", audienceHost: "example.test", + scope: "capability:visual-edit:design:design-1", }); const app = createApp(); mountUiActionCapabilityRoute(app); @@ -293,25 +295,55 @@ describe("UI action capability", () => { }, ); - it("mints for a browser session with an invalid leftover embed cookie", async () => { - const app = createApp(); - mountUiActionCapabilityRoute(app); + it.each([ + "invalid", + "expired", + "other-target", + "other-host", + "other-mount", + ] as const)( + "mints for a browser session with an %s ambient embed cookie", + async (kind) => { + const app = createApp(); + mountUiActionCapabilityRoute(app); + const issuedAt = + kind === "expired" + ? vi.spyOn(Date, "now").mockReturnValue(Date.now() - 120_000) + : undefined; + if (kind === "other-mount") vi.stubEnv("APP_BASE_PATH", "/other"); + const token = + kind === "invalid" + ? "invalid" + : signEmbedSessionToken({ + ownerEmail: "alice@example.com", + targetPath: "/documents/doc-1", + audienceHost: + kind === "other-host" ? "other.test" : "example.test", + ttlSeconds: 60, + scope: + kind === "other-target" + ? "minimal" + : "capability:visual-edit:design:design-1", + }); + issuedAt?.mockRestore(); + if (kind === "other-mount") vi.stubEnv("APP_BASE_PATH", ""); - const response = await app.fetch( - new Request("https://example.test/_agent-native/ui-capability", { - headers: { - "sec-fetch-site": "same-origin", - cookie: "an_embed_session=invalid; an_session=browser-login", - }, - }), - ); + const response = await app.fetch( + new Request("https://example.test/_agent-native/ui-capability", { + headers: { + "sec-fetch-site": "same-origin", + cookie: `an_embed_session=${token}; an_session=browser-login`, + }, + }), + ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - expect(response.headers.get("set-cookie")).toContain( - "agent-native-ui-capability=", - ); - }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(response.headers.get("set-cookie")).toContain( + "agent-native-ui-capability=", + ); + }, + ); it("does not mint a UI capability from an inline-enabled MCP embed session", async () => { const origin = "https://example.test"; @@ -391,6 +423,7 @@ describe("UI action capability", () => { headers: { origin, "sec-fetch-site": "same-origin", + "x-agent-native-embed-target": result.targetPath, cookie: `${EMBED_SESSION_COOKIE}=${token}; an_session=browser-login`, }, }), diff --git a/packages/core/src/server/ui-action-capability.ts b/packages/core/src/server/ui-action-capability.ts index fa5d9442603..9df0bc0cbe7 100644 --- a/packages/core/src/server/ui-action-capability.ts +++ b/packages/core/src/server/ui-action-capability.ts @@ -2,6 +2,7 @@ import type { H3Event } from "h3"; import { defineEventHandler, getCookie, + getHeader, getMethod, setCookie, setResponseHeader, @@ -12,7 +13,10 @@ import { getConfiguredAppBasePath, normalizeAppBasePath, } from "./app-base-path.js"; -import { getBrowserCookieSession } from "./auth.js"; +import { + getBrowserCookieSession, + getBrowserSessionConfigurationError, +} from "./auth.js"; import { hasPresentedEmbedSessionCredential } from "./embed-session.js"; import { getH3App } from "./framework-request-handler.js"; import { publicFrameworkPath } from "./framework-route-prefix.js"; @@ -67,11 +71,15 @@ async function issueUiActionCapability(event: H3Event, appBasePath?: string) { return { error: "Browser cookie session required" }; } - const session = await getBrowserCookieSession(event); + const session = getHeader(event, "authorization") + ? null + : await getBrowserCookieSession(event); const ownerEmail = normalizedEmail(session?.email); if (!ownerEmail) { setResponseStatus(event, 401); - return { error: "Authentication required" }; + return { + error: getBrowserSessionConfigurationError() ?? "Authentication required", + }; } if (!hasSameOriginBrowserEvidence(event)) { diff --git a/packages/docs/server/plugins/auth.ts b/packages/docs/server/plugins/auth.ts index 92f94bfdc5e..eabf02c164b 100644 --- a/packages/docs/server/plugins/auth.ts +++ b/packages/docs/server/plugins/auth.ts @@ -48,6 +48,13 @@ export function isDocsWebMcpPath( export const docsAuthOptions: AuthOptions = { workspaceAppAudience: "public", + getBrowserSession: async (event) => { + if (isDocsWebMcpPath(getDocsRequestPathname(event))) return null; + const sessionId = getCookie(event, "an_docs_session"); + return sessionId + ? { email: `anon-${sessionId}@agent-native.com`, userId: sessionId } + : null; + }, getSession: async (event) => { const cookieName = "an_docs_session"; if (isDocsWebMcpPath(getDocsRequestPathname(event))) return null; diff --git a/packages/docs/tests/auth.spec.ts b/packages/docs/tests/auth.spec.ts index b554941dad2..71507cd0fd3 100644 --- a/packages/docs/tests/auth.spec.ts +++ b/packages/docs/tests/auth.spec.ts @@ -12,6 +12,32 @@ describe("docs auth session scoping", () => { expect(docsAuthOptions.workspaceAppAudience).toBe("public"); }); + it("resolves browser identity only from an existing docs cookie", async () => { + const event = mockEvent( + "https://docs.example.com/_agent-native/ui-capability", + { + headers: { cookie: "an_docs_session=existing" }, + }, + ); + await expect(docsAuthOptions.getBrowserSession!(event)).resolves.toEqual({ + email: "anon-existing@agent-native.com", + userId: "existing", + }); + }); + + it("does not create a browser session from a bearer or an anonymous request", async () => { + for (const headers of [{}, { authorization: "Bearer docs-test-session" }]) { + const event = mockEvent( + "https://docs.example.com/_agent-native/ui-capability", + { headers }, + ); + await expect( + docsAuthOptions.getBrowserSession!(event), + ).resolves.toBeNull(); + expect(event.res.headers.get("set-cookie")).toBeNull(); + } + }); + it("creates anonymous sessions for framework and API routes under a mount path", () => { expect( shouldCreateDocsSessionForPath( @@ -42,6 +68,7 @@ describe("docs auth session scoping", () => { { headers: { cookie: "an_docs_session=existing" } }, ); await expect(docsAuthOptions.getSession!(event)).resolves.toBeNull(); + await expect(docsAuthOptions.getBrowserSession!(event)).resolves.toBeNull(); }); it("preserves WebMCP auth exclusion after mounted-route path stripping", async () => { @@ -54,5 +81,6 @@ describe("docs auth session scoping", () => { }; await expect(docsAuthOptions.getSession!(event)).resolves.toBeNull(); + await expect(docsAuthOptions.getBrowserSession!(event)).resolves.toBeNull(); }); }); diff --git a/templates/analytics/actions/configure-first-party-events-model.spec.ts b/templates/analytics/actions/configure-first-party-events-model.spec.ts index 4495082ec1a..43ec539c170 100644 --- a/templates/analytics/actions/configure-first-party-events-model.spec.ts +++ b/templates/analytics/actions/configure-first-party-events-model.spec.ts @@ -1,5 +1,13 @@ import { H3 } from "h3"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; const mocks = vi.hoisted(() => ({ dryRun: vi.fn(), @@ -54,6 +62,15 @@ beforeEach(() => { mocks.dryRun.mockResolvedValue({ error: null, totalBytesProcessed: 1024 }); }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +afterAll(async () => { + const { closeDbExec } = await import("@agent-native/core/db"); + await closeDbExec(); +}); + describe("configure-first-party-events-model", () => { it("checks with dry runs only and saves nothing", async () => { await expect( @@ -192,7 +209,10 @@ describe("configure-first-party-events-model over the HTTP action route", () => }); it("saves the model when the signed-in app UI sets it", async () => { - const { signShortLivedToken } = await import("@agent-native/core/server"); + vi.stubEnv("DATABASE_URL", "pglite:memory"); + const { addSession, COOKIE_NAME, signShortLivedToken } = + await import("@agent-native/core/server"); + await addSession("test-browser-session", "admin@example.com"); const capability = signShortLivedToken({ resourceId: "ui-action", viewerEmail: "admin@example.com", @@ -205,7 +225,7 @@ describe("configure-first-party-events-model over the HTTP action route", () => { "x-agent-native-frontend": "1", "sec-fetch-site": "same-origin", - cookie: `agent-native-ui-capability=${capability}`, + cookie: `${COOKIE_NAME}=test-browser-session; agent-native-ui-capability=${capability}`, }, ), ).resolves.toMatchObject({ diff --git a/templates/design/changelog/2026-10-22-builder-sign-in-keeps-design-export-actions-available.md b/templates/design/changelog/2026-10-22-builder-sign-in-keeps-design-export-actions-available.md new file mode 100644 index 00000000000..3fa4ab1a0a3 --- /dev/null +++ b/templates/design/changelog/2026-10-22-builder-sign-in-keeps-design-export-actions-available.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-22 +--- + +Builder sign-in keeps Design export actions available diff --git a/templates/design/server/plugins/auth.spec.ts b/templates/design/server/plugins/auth.spec.ts index 0d832afff16..d99bedd2fcc 100644 --- a/templates/design/server/plugins/auth.spec.ts +++ b/templates/design/server/plugins/auth.spec.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@agent-native/core/server", () => ({ createAuthPlugin: mocks.createAuthPlugin, registerAuthPublicPaths: mocks.registerAuthPublicPaths, + registerDelegatedBuilderCredentials: vi.fn(), })); import authPlugin from "./auth.js"; @@ -68,4 +69,23 @@ describe("design auth plugin", () => { ]), ); }); + + it("declares Builder identity as an explicit cookie-only browser provider", async () => { + vi.stubEnv("BUILDER_IDENTITY", "1"); + vi.resetModules(); + try { + const { getBuilderIdentitySession } = + await import("../lib/builder-identity.js"); + await import("./auth.js"); + expect(mocks.createAuthPlugin).toHaveBeenLastCalledWith( + expect.objectContaining({ + getSession: getBuilderIdentitySession, + getBrowserSession: getBuilderIdentitySession, + }), + ); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + } + }); }); diff --git a/templates/design/server/plugins/auth.ts b/templates/design/server/plugins/auth.ts index bcc19858e84..2130ea0edbd 100644 --- a/templates/design/server/plugins/auth.ts +++ b/templates/design/server/plugins/auth.ts @@ -15,6 +15,7 @@ export default createToolkitAuthPlugin({ ...(isBuilderIdentityEnabled() ? { getSession: getBuilderIdentitySession, + getBrowserSession: getBuilderIdentitySession, trustCustomEmailVerification: true, } : {}), From 5ba0eaf3a851486af12679c0f872b1c4eece4c6d Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:03:53 -0400 Subject: [PATCH 04/15] Limit proxy origin validation to embed sessions --- .changeset/tidy-embed-session-scope.md | 2 + .../src/mcp/build-server.call-parity.spec.ts | 6 +- packages/core/src/mcp/build-server.ts | 9 +- packages/core/src/mcp/builtin-tools.ts | 21 +- .../core/src/mcp/connector-catalog.spec.ts | 5 +- packages/core/src/mcp/server.spec.ts | 233 +++++++++++++++++- packages/core/src/mcp/server.ts | 21 +- .../core/src/server/embed-request-origin.ts | 55 +++++ packages/core/src/server/embed-route.spec.ts | 140 ++++++----- packages/core/src/server/embed-route.ts | 7 +- .../core/src/server/embed-session.spec.ts | 46 ++++ packages/core/src/server/embed-session.ts | 29 ++- packages/core/src/server/request-context.ts | 2 + .../src/server/ui-action-capability.spec.ts | 74 ++++++ .../core/src/server/ui-action-capability.ts | 10 +- 15 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 packages/core/src/server/embed-request-origin.ts diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md index b1dff904e3d..bbdd27d72d5 100644 --- a/.changeset/tidy-embed-session-scope.md +++ b/.changeset/tidy-embed-session-scope.md @@ -5,3 +5,5 @@ Scope MCP Apps embed sessions to their resource and declared actions, and require browser cookie sessions for UI capabilities and approvals. Apps with a custom `AuthOptions.getSession` resolver must also configure the cookie-only `getBrowserSession` hook to use UI capabilities and browser approvals. Without it, these operations return `401` and the server logs a migration warning. + +MCP Apps embeds behind a proxy require the public origin configured through `APP_URL` or the workspace gateway URL unless the proxy connects over loopback. diff --git a/packages/core/src/mcp/build-server.call-parity.spec.ts b/packages/core/src/mcp/build-server.call-parity.spec.ts index 55af2b6b5b5..fcb01408d94 100644 --- a/packages/core/src/mcp/build-server.call-parity.spec.ts +++ b/packages/core/src/mcp/build-server.call-parity.spec.ts @@ -134,13 +134,13 @@ vi.mock("./credential-membership.js", async (importOriginal) => { }; }); -vi.mock("h3", () => ({ +vi.mock("h3", async (importOriginal) => ({ + ...(await importOriginal()), defineEventHandler: (fn: any) => fn, getMethod: (event: any) => event.method ?? "GET", getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], getRequestHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], - getRequestIP: () => undefined, getQuery: () => ({}), setResponseStatus: (event: any, code: number) => { event._status = code; @@ -463,7 +463,7 @@ function makeEvent( const path = "/"; return { method, - url: { pathname: path }, + url: new URL(`${ORIGIN}${path}`), path, req: new Request(`${ORIGIN}${path}`, { method, headers: allHeaders }), _headers: allHeaders, diff --git a/packages/core/src/mcp/build-server.ts b/packages/core/src/mcp/build-server.ts index 0b067b424a4..ab620756fcf 100644 --- a/packages/core/src/mcp/build-server.ts +++ b/packages/core/src/mcp/build-server.ts @@ -54,6 +54,7 @@ import { toDesktopOpenUrl, toVsCodeOpenUrl, } from "../server/deep-link.js"; +import { getEmbedSessionOrigin } from "../server/embed-request-origin.js"; import { getRequestContext, getRequestOrgId, @@ -318,6 +319,7 @@ export function actionApprovalError(message: string): CallToolResult { export interface MCPRequestMeta { localAgentTaskClient?: import("../a2a/mcp-task-client.js").McpAgentTaskClient; origin?: string; + resolveEmbedRequestOrigin?: () => string; basePath?: string; target?: "browser" | "desktop" | "terminal"; clientName?: string; @@ -1315,7 +1317,7 @@ export async function withServerMintedMcpAppEmbedStart( } const ctx = getRequestContext(); const ownerEmail = ctx?.userEmail?.trim(); - const requestOrigin = ctx?.requestOrigin; + const requestOrigin = getEmbedSessionOrigin(); if (!ownerEmail || !requestOrigin) { return resultWithoutExistingEmbedTicket; } @@ -1717,7 +1719,7 @@ async function renewMcpDirectoryWidgetEmbedSession( if (!hasVerifiedMcpUserIdentity(identity)) { throw new Error("Widget session renewal requires a verified user caller."); } - const requestOrigin = getRequestContext()?.requestOrigin; + const requestOrigin = getEmbedSessionOrigin(); if (!requestOrigin) { throw new Error( "Widget session renewal requires a trusted request origin. Open the full app instead.", @@ -3083,6 +3085,9 @@ export async function createMCPServerForRequest( ? { orgScope: "personal" as const } : {}), ...(requestMeta?.origin ? { requestOrigin: requestMeta.origin } : {}), + ...(requestMeta?.resolveEmbedRequestOrigin + ? { resolveEmbedRequestOrigin: requestMeta.resolveEmbedRequestOrigin } + : {}), ...(mcpRequestId ? { mcpRequestId } : {}), mcpCredentialIssuedAtMs: effectiveIdentity?.mcpCredentialIssuedAtMs ?? null, diff --git a/packages/core/src/mcp/builtin-tools.ts b/packages/core/src/mcp/builtin-tools.ts index cd517835623..fbe87029338 100644 --- a/packages/core/src/mcp/builtin-tools.ts +++ b/packages/core/src/mcp/builtin-tools.ts @@ -887,14 +887,17 @@ function openAppTool(config: MCPConfig): ActionEntry { await import("../server/request-context.js"); const ctx = getRequestContext(); const ownerEmail = ctx?.userEmail?.trim(); - if (ownerEmail && ctx?.requestOrigin) { + const { getEmbedSessionOrigin } = + await import("../server/embed-request-origin.js"); + const embedOrigin = getEmbedSessionOrigin(); + if (ownerEmail && embedOrigin) { const { normalizeEmbedTargetPath, createEmbedSessionTicket } = await import("../server/embed-session.js"); const { buildEmbedStartPath } = await import("../server/embed-route.js"); const targetPath = normalizeEmbedTargetPath( withMcpChatBridgeParam(url), - ctx.requestOrigin, + embedOrigin, ); if (targetPath) { const ticket = await createEmbedSessionTicket({ @@ -904,7 +907,7 @@ function openAppTool(config: MCPConfig): ActionEntry { scope: chrome ?? null, }); const startPath = buildEmbedStartPath(ticket.ticket); - embedStartUrl = new URL(startPath, ctx.requestOrigin).toString(); + embedStartUrl = new URL(startPath, embedOrigin).toString(); embedTargetPath = targetPath; embedExpiresAt = ticket.expiresAt; } @@ -996,22 +999,22 @@ function createEmbedSessionTool(): ActionEntry { const { normalizeEmbedTargetPath, createEmbedSessionTicket } = await import("../server/embed-session.js"); const { buildEmbedStartPath } = await import("../server/embed-route.js"); + const { getEmbedSessionOrigin } = + await import("../server/embed-request-origin.js"); + const embedOrigin = getEmbedSessionOrigin(); const rawTarget = typeof args.url === "string" && args.url.trim() ? args.url : typeof args.path === "string" ? args.path : ""; - const targetPath = normalizeEmbedTargetPath( - rawTarget, - ctx?.requestOrigin, - ); + const targetPath = normalizeEmbedTargetPath(rawTarget, embedOrigin); if (!targetPath) { throw new Error( "create_embed_session can only embed same-origin app-relative URLs.", ); } - if (!ctx?.requestOrigin) { + if (!embedOrigin) { return { url: targetPath, targetPath, embed: false }; } @@ -1022,7 +1025,7 @@ function createEmbedSessionTool(): ActionEntry { scope: typeof args.chrome === "string" ? args.chrome : null, }); const startPath = buildEmbedStartPath(ticket.ticket); - const startUrl = new URL(startPath, ctx.requestOrigin).toString(); + const startUrl = new URL(startPath, embedOrigin).toString(); return { startUrl, targetPath, diff --git a/packages/core/src/mcp/connector-catalog.spec.ts b/packages/core/src/mcp/connector-catalog.spec.ts index 760d63ac8ec..8d0e58c658c 100644 --- a/packages/core/src/mcp/connector-catalog.spec.ts +++ b/packages/core/src/mcp/connector-catalog.spec.ts @@ -153,7 +153,7 @@ function makeEvent(opts: { const webReq = new Request(reqUrl, { method: "POST", headers }); return { method: "POST", - url: { pathname: "/" }, + url: new URL(reqUrl), path: "/", req: webReq, _headers: headers, @@ -300,7 +300,8 @@ const connectorConfig = { connectorCatalog: CONNECTOR_CATALOG, }; -vi.mock("h3", () => ({ +vi.mock("h3", async (importOriginal) => ({ + ...(await importOriginal()), defineEventHandler: (fn: any) => fn, getMethod: (event: any) => event.method ?? "GET", getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()], diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 79fff2dffde..1360148132d 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -25,6 +25,10 @@ import { listResourceSuggestions } from "../review/suggestions/actions.js"; import { loadActionsFromStaticRegistry } from "../server/action-discovery.js"; import { registerErrorCaptureProvider } from "../server/capture-error.js"; import { filesystemActionEntry } from "../server/filesystem-action-entry.js"; +import { + EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS, + WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS, +} from "../server/origin-allowlist.js"; import { isMcpDirectoryWidgetReadCapabilityScope, isMcpDirectoryWidgetWriteCapabilityScope, @@ -2391,7 +2395,12 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", describe("trusted widget request origin", () => { beforeEach(() => { - vi.stubEnv("APP_URL", "https://mail.agent-native.com"); + for (const key of [ + ...EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS, + ...WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS, + "BETTER_AUTH_TRUSTED_ORIGINS", + ]) + vi.stubEnv(key, ""); }); afterEach(() => { vi.unstubAllEnvs(); @@ -2415,7 +2424,10 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", node, ip: "203.0.113.20", headers: { - ...(await mcpAppsAuthHeaders()), + ...(await mcpAppsAuthHeaders({ + resource: "http://attacker.example/_agent-native/mcp", + issuer: "http://attacker.example", + })), "x-forwarded-host": "attacker.example", "x-forwarded-proto": "http", "x-forwarded-for": "127.0.0.1", @@ -2455,18 +2467,206 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", audienceHost: "attacker.example", }), ).resolves.toBeNull(); - expect(JSON.stringify(called)).not.toContain("attacker.example"); + expect(startUrl.hostname).not.toBe("attacker.example"); + }, + ); + + it("retains the forwarded MCP origin for an ordinary non-embed tool", async () => { + const { getRequestContext } = + await import("../server/request-context.js"); + const response = await callWeb( + { + jsonrpc: "2.0", + id: 140, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "origin" } }, + }, + { + headers: { + ...(await mcpAppsAuthHeaders({ + resource: "https://workspace.example.test/_agent-native/mcp", + issuer: "https://workspace.example.test", + })), + host: "internal.gateway:8100", + "x-forwarded-host": "workspace.example.test", + "x-forwarded-proto": "https", + }, + config: { + ...config, + catalogMode: "app", + actions: { + "echo-thing": { + ...config.actions["echo-thing"], + mcpApp: undefined, + link: undefined, + run: async () => ({ + origin: getRequestContext()?.requestOrigin, + }), + }, + }, + }, + }, + ); + expect(response.result).toMatchObject({ + structuredContent: { origin: "https://workspace.example.test" }, + }); + expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled(); + }); + + it.each([undefined, "203.0.113.20"])( + "uses the public Netlify request origin without an allowlist (peer: %s)", + async (ip) => { + const actual = await vi.importActual< + typeof import("../server/embed-session.js") + >("../server/embed-session.js"); + embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce( + async (input) => ({ + ...(await actual.createEmbedSessionTicket( + input as Parameters[0], + )), + targetPath: input.targetPath, + }), + ); + const event = makeWebEvent({ + headers: { + ...(await mcpAppsAuthHeaders()), + host: "mail.agent-native.com", + "x-forwarded-host": "mail.agent-native.com", + "x-forwarded-proto": "https", + ...(ip ? { "x-nf-client-connection-ip": ip } : {}), + }, + body: { + jsonrpc: "2.0", + id: 141, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + // Nitro's Netlify adapter preserves Request.url and copies this header to req.ip. + event.req.runtime = { name: "netlify" }; + event.req.ip = + event.req.headers.get("x-nf-client-connection-ip") || undefined; + const { getRequestIP } = await import("h3"); + expect(getRequestIP(event)).toBe(ip); + const { getConfiguredOriginAllowlist } = + await import("../server/origin-allowlist.js"); + expect(getConfiguredOriginAllowlist().size).toBe(0); + const response = await handleMcpRequest( + event, + scopedWidgetConfig(config), + ); + const called = await readWebResponse(response as Response); + expect(called.result.isError).not.toBe(true); + const startUrl = new URL( + called.result._meta["agent-native/embedStart"].startUrl, + ); + expect(startUrl.origin).toBe("https://mail.agent-native.com"); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket( + startUrl.searchParams.get("ticket")!, + { audienceHost: "mail.agent-native.com" }, + ), + ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" }); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket( + startUrl.searchParams.get("ticket")!, + { audienceHost: "attacker.example" }, + ), + ).resolves.toBeNull(); + }, + ); + + it.each(["APP_URL", "WORKSPACE_GATEWAY_URL"])( + "uses an allowlisted non-loopback gateway for minting and renewal via %s", + async (key) => { + vi.stubEnv(key, "https://workspace.example.test"); + const actual = await vi.importActual< + typeof import("../server/embed-session.js") + >("../server/embed-session.js"); + embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce( + async (input) => ({ + ...(await actual.createEmbedSessionTicket( + input as Parameters[0], + )), + targetPath: input.targetPath, + }), + ); + const gatewayConfig = scopedWidgetConfig(config); + const headers = { + ...(await mcpAppsAuthHeaders({ + resource: "https://workspace.example.test/_agent-native/mcp", + issuer: "https://workspace.example.test", + })), + host: "internal.gateway:8100", + "x-forwarded-host": "workspace.example.test", + "x-forwarded-proto": "https", + }; + const event = makeWebEvent({ + ip: "203.0.113.20", + headers, + body: { + jsonrpc: "2.0", + id: 138, + method: "tools/call", + params: { name: "echo-thing", arguments: { value: "hello" } }, + }, + }); + event.url = new URL("http://internal.gateway:8100/"); + const response = await handleMcpRequest(event, gatewayConfig); + const called = await readWebResponse(response as Response); + expect(called.result.isError).not.toBe(true); + const startUrl = new URL( + called.result._meta["agent-native/embedStart"].startUrl, + ); + expect(startUrl.origin).toBe("https://workspace.example.test"); + const sourceTicket = startUrl.searchParams.get("ticket")!; + await expect( + actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, { + audienceHost: "workspace.example.test", + }), + ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" }); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, { + audienceHost: "internal.gateway", + }), + ).resolves.toBeNull(); + await expect( + actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, { + audienceHost: "attacker.example", + }), + ).resolves.toBeNull(); + embedSessionMocks.readMcpDirectoryWidgetRenewalTicket.mockImplementationOnce( + actual.readMcpDirectoryWidgetRenewalTicket, + ); + const renewal = await callWeb( + { + jsonrpc: "2.0", + id: 139, + method: "tools/call", + params: { + name: "create_embed_session", + arguments: { sourceTicket }, + }, + }, + { headers, config: gatewayConfig }, + ); + expect(renewal.result.isError).not.toBe(true); + expect( + new URL(renewal.result._meta["agent-native/embedStart"].startUrl) + .origin, + ).toBe("https://workspace.example.test"); }, ); it("uses validated forwarded authority from a loopback proxy for widget tickets", async () => { + vi.stubEnv("APP_URL", "https://mail.agent-native.com"); const event = makeWebEvent({ ip: "::ffff:127.0.0.1", headers: { ...(await mcpAppsAuthHeaders()), host: "127.0.0.1:8100", - "x-forwarded-host": "mail.agent-native.com, internal.gateway:3000", - "x-forwarded-proto": "https, http", + "x-forwarded-host": "mail.agent-native.com", + "x-forwarded-proto": "https", }, body: { jsonrpc: "2.0", @@ -2504,9 +2704,17 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", params: { name: "echo-thing", arguments: { value: "hello" } }, }, }); - await expect( - handleMcpRequest(event, scopedWidgetConfig(config)), - ).rejects.toMatchObject({ statusCode: 400 }); + const response = await handleMcpRequest( + event, + scopedWidgetConfig(config), + ); + if (response instanceof Response) { + const called = await readWebResponse(response); + expect(called.result.isError).toBe(true); + } else { + expect(event._status).toBe(401); + expect(response).toMatchObject({ error: "Unauthorized" }); + } expect( embedSessionMocks.createEmbedSessionTicket, ).not.toHaveBeenCalled(); @@ -2530,9 +2738,12 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }); delete event.url; event.req = { headers: event.req.headers, url: "/" }; - await expect( - handleMcpRequest(event, scopedWidgetConfig(config)), - ).rejects.toThrow(); + const response = await handleMcpRequest( + event, + scopedWidgetConfig(config), + ); + expect(event._status).toBe(401); + expect(response).toMatchObject({ error: "Unauthorized" }); expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 96d1498bf88..5653e9f8948 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -15,10 +15,10 @@ import { type BearerCredentialRefusal, } from "../server/bearer-credential-refusal.js"; import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../server/credential-membership-unavailable.js"; +import { resolveEmbedRequestOrigin } from "../server/embed-request-origin.js"; import { getH3App } from "../server/framework-request-handler.js"; import { getOrigin } from "../server/google-oauth.js"; import { readBody } from "../server/h3-helpers.js"; -import { getForwardedRequestOrigin } from "../server/request-origin.js"; import { verifyAuth, McpIdentityVerificationUnavailableError, @@ -64,7 +64,14 @@ export { export type { MCPConfig, MCPCallerIdentity, MCPRequestMeta }; function deriveRequestMeta(event: H3Event): MCPRequestMeta { - const origin = getForwardedRequestOrigin(event); + const forwardedProto = getRequestHeader(event, "x-forwarded-proto"); + const host = + getRequestHeader(event, "x-forwarded-host") || + getRequestHeader(event, "host"); + const proto = + forwardedProto?.split(",")[0]?.trim() || + (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); + const origin = host ? `${proto}://${host}` : undefined; const targetHeader = getRequestHeader( event, "x-agent-native-open-target", @@ -149,9 +156,14 @@ function buildWebRequest( } } - const origin = getForwardedRequestOrigin(event); + const host = + headers.get("x-forwarded-host") || headers.get("host") || "localhost"; + const forwardedProto = headers.get("x-forwarded-proto"); + const proto = + forwardedProto?.split(",")[0]?.trim() || + (/^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https"); const basePath = getConfiguredAppBasePath(); - const url = `${origin}${basePath}${routePath}`; + const url = `${proto}://${host}${basePath}${routePath}`; return new Request(url, { method, headers }); } @@ -437,6 +449,7 @@ async function handleMcpRequestInternal( const serverRequestMeta: MCPRequestMeta = { ...requestMeta, + resolveEmbedRequestOrigin: () => resolveEmbedRequestOrigin(event), ...(requestConfig.createLocalAgentTaskClient ? { localAgentTaskClient: requestConfig.createLocalAgentTaskClient(event), diff --git a/packages/core/src/server/embed-request-origin.ts b/packages/core/src/server/embed-request-origin.ts new file mode 100644 index 00000000000..95e586eecfc --- /dev/null +++ b/packages/core/src/server/embed-request-origin.ts @@ -0,0 +1,55 @@ +import { createError, getRequestHeader, type H3Event } from "h3"; + +import { getConfiguredOriginAllowlist } from "./origin-allowlist.js"; +import { getRequestContext } from "./request-context.js"; +import { getForwardedRequestOrigin } from "./request-origin.js"; + +export function resolveEmbedRequestOrigin(event: H3Event): string { + const fallback = getForwardedRequestOrigin(event); + const requestOrigin = new URL(fallback); + const rawHost = getRequestHeader(event, "x-forwarded-host"); + const rawProto = getRequestHeader(event, "x-forwarded-proto"); + const host = rawHost?.split(",")[0]?.trim() ?? requestOrigin.host; + const proto = + rawProto?.split(",")[0]?.trim().toLowerCase() ?? + requestOrigin.protocol.slice(0, -1); + if ( + !host || + /[\s/\\?#@]/.test(host) || + (proto !== "http" && proto !== "https") + ) { + throw createError({ + statusCode: 400, + statusMessage: "Invalid embed request origin", + }); + } + let forwarded: URL; + try { + forwarded = new URL(`${proto}://${host}`); + } catch { + throw createError({ + statusCode: 400, + statusMessage: "Invalid embed request origin", + }); + } + if (getConfiguredOriginAllowlist().has(forwarded.origin)) { + return forwarded.origin; + } + if ( + requestOrigin.protocol !== "http:" && + requestOrigin.protocol !== "https:" + ) { + throw createError({ + statusCode: 400, + statusMessage: "Invalid embed request origin", + }); + } + return fallback; +} + +export function getEmbedSessionOrigin(): string | undefined { + const context = getRequestContext(); + return context?.resolveEmbedRequestOrigin + ? context.resolveEmbedRequestOrigin() + : context?.requestOrigin; +} diff --git a/packages/core/src/server/embed-route.spec.ts b/packages/core/src/server/embed-route.spec.ts index d738679b51d..a136b4a3a8c 100644 --- a/packages/core/src/server/embed-route.spec.ts +++ b/packages/core/src/server/embed-route.spec.ts @@ -8,6 +8,8 @@ import { const setResponseHeader = vi.hoisted(() => vi.fn()); vi.mock("h3", () => ({ + createError: (input: { statusCode: number; statusMessage: string }) => + Object.assign(new Error(input.statusMessage), input), defineEventHandler: (handler: any) => handler, getHeader: (event: any, name: string) => event.headers?.[name] ?? event.headers?.[name.toLowerCase()], @@ -109,72 +111,96 @@ describe("createEmbedStartRouteHandler", () => { expect(setEmbedSessionCookie).not.toHaveBeenCalled(); }); - it("still consumes valid tickets on GET and redirects to the embedded target", async () => { - consumeEmbedSessionTicket.mockResolvedValue({ - ownerEmail: "steve@example.com", - orgId: "builder", - targetPath: "/inbox", - scope: "full", - expiresAt: Date.now() + 60_000, - ticketCreatedAtMs: Date.now() - 1, - }); + it.each(["127.0.0.1", "203.0.113.20"])( + "consumes valid tickets behind a proxy and redirects to the embedded target (%s)", + async (ip) => { + if (ip !== "127.0.0.1") + vi.stubEnv( + "WORKSPACE_GATEWAY_URL", + "https://beta.calendar.agent-native.com", + ); + consumeEmbedSessionTicket.mockResolvedValue({ + ownerEmail: "steve@example.com", + orgId: "builder", + targetPath: "/inbox", + scope: "full", + expiresAt: Date.now() + 60_000, + ticketCreatedAtMs: Date.now() - 1, + }); - const handler = createEmbedStartRouteHandler(); + const handler = createEmbedStartRouteHandler(); - const res: Response = await handler( - fakeEvent( + const event = fakeEvent( "GET", { ticket: "ticket-123" }, { host: "internal.gateway:3000", "x-forwarded-host": "beta.calendar.agent-native.com", }, - ), - ); - - expect(consumeEmbedSessionTicket).toHaveBeenCalledWith( - "ticket-123", - expect.objectContaining({ + ); + event.ip = ip; + const res: Response = await handler(event); + + expect(consumeEmbedSessionTicket).toHaveBeenCalledWith( + "ticket-123", + expect.objectContaining({ + audienceHost: "beta.calendar.agent-native.com", + expectedOwnerEmail: null, + }), + ); + expect(setEmbedSessionCookie).toHaveBeenCalledTimes(1); + expect(signEmbedSessionToken).toHaveBeenCalledWith({ + ownerEmail: "steve@example.com", + orgId: "builder", + targetPath: "/inbox", audienceHost: "beta.calendar.agent-native.com", - expectedOwnerEmail: null, - }), - ); - expect(setEmbedSessionCookie).toHaveBeenCalledTimes(1); - expect(signEmbedSessionToken).toHaveBeenCalledWith({ - ownerEmail: "steve@example.com", - orgId: "builder", - targetPath: "/inbox", - audienceHost: "beta.calendar.agent-native.com", - scope: "full", - ticketCreatedAtMs: expect.any(Number), - }); - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toBe( - "/inbox?embedded=1&__an_embed_token=signed-token&agentSidebar=closed", - ); - expect(res.headers.get("Cross-Origin-Embedder-Policy")).toBe( - "require-corp", - ); - expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin"); - expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe( - "cross-origin", - ); - expect(setResponseHeader).toHaveBeenCalledWith( - expect.anything(), - "Cross-Origin-Embedder-Policy", - "require-corp", - ); - expect(setResponseHeader).toHaveBeenCalledWith( - expect.anything(), - "Cross-Origin-Opener-Policy", - "same-origin", - ); - expect(setResponseHeader).toHaveBeenCalledWith( - expect.anything(), - "Cross-Origin-Resource-Policy", - "cross-origin", - ); - }); + scope: "full", + ticketCreatedAtMs: expect.any(Number), + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe( + "/inbox?embedded=1&__an_embed_token=signed-token&agentSidebar=closed", + ); + expect(res.headers.get("Cross-Origin-Embedder-Policy")).toBe( + "require-corp", + ); + expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin"); + expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe( + "cross-origin", + ); + expect(setResponseHeader).toHaveBeenCalledWith( + expect.anything(), + "Cross-Origin-Embedder-Policy", + "require-corp", + ); + expect(setResponseHeader).toHaveBeenCalledWith( + expect.anything(), + "Cross-Origin-Opener-Policy", + "same-origin", + ); + expect(setResponseHeader).toHaveBeenCalledWith( + expect.anything(), + "Cross-Origin-Resource-Policy", + "cross-origin", + ); + }, + ); + + it.each([ + { "x-forwarded-host": "mail.test/path" }, + { "x-forwarded-host": "", "x-forwarded-proto": "https" }, + { "x-forwarded-proto": "javascript" }, + ])( + "refuses malformed forwarded authority before consuming a ticket: %j", + async (headers) => { + const handler = createEmbedStartRouteHandler(); + await expect( + handler(fakeEvent("GET", { ticket: "ticket-123" }, headers)), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(consumeEmbedSessionTicket).not.toHaveBeenCalled(); + expect(signEmbedSessionToken).not.toHaveBeenCalled(); + }, + ); it("returns a refreshable expired-session page for stale embed tickets", async () => { consumeEmbedSessionTicket.mockResolvedValue(null); diff --git a/packages/core/src/server/embed-route.ts b/packages/core/src/server/embed-route.ts index 218387e0f38..9be81e07b44 100644 --- a/packages/core/src/server/embed-route.ts +++ b/packages/core/src/server/embed-route.ts @@ -23,6 +23,7 @@ import { import { getConfiguredAppBasePath } from "./app-base-path.js"; import type { AuthSession } from "./auth.js"; import { readCorsAllowedOrigins } from "./cors-origins.js"; +import { resolveEmbedRequestOrigin } from "./embed-request-origin.js"; import { consumeEmbedSessionTicket, type EmbedSessionTicketConsumeDiagnostic, @@ -31,7 +32,6 @@ import { setEmbedSessionCookie, signEmbedSessionToken, } from "./embed-session.js"; -import { getForwardedRequestHostname } from "./request-origin.js"; function withConfiguredBasePath(path: string): string { const base = getConfiguredAppBasePath(); @@ -336,8 +336,9 @@ export function createEmbedStartRouteHandler( .getExistingSession?.(event) .catch(() => null); let consumeDiagnostic: EmbedSessionTicketConsumeDiagnostic | null = null; + const audienceHost = new URL(resolveEmbedRequestOrigin(event)).hostname; const consumed = await consumeEmbedSessionTicket(ticket, { - audienceHost: getForwardedRequestHostname(event), + audienceHost, expectedOwnerEmail: existingSession?.email ?? null, allowCapabilityIdentityMismatch: true, onResult: (diagnostic) => { @@ -363,7 +364,7 @@ export function createEmbedStartRouteHandler( ownerEmail: consumed.ownerEmail, orgId: consumed.orgId, targetPath: target, - audienceHost: getForwardedRequestHostname(event), + audienceHost, scope: consumed.scope, ...(consumed.ticketCreatedAtMs != null && (!isEmbedCapabilityScope(consumed.scope) || diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index 2333ac842dc..be43c1e2e53 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -1775,6 +1775,52 @@ describe("requestMatchesEmbedTarget", () => { }, ); + it("redeems and resolves an embed through an allowlisted non-loopback gateway", async () => { + process.env.WORKSPACE_GATEWAY_URL = "https://workspace.example.test"; + process.env.APP_BASE_PATH = "/mail"; + installTicketStore(); + const ticket = await runWithRequestContext( + { requestOrigin: "https://workspace.example.test" }, + () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + }), + ); + const { createEmbedStartRouteHandler } = await import("./embed-route.js"); + const headers = { + host: "internal.gateway:8100", + "x-forwarded-host": "workspace.example.test", + "x-forwarded-proto": "https", + }; + const start = fakeEvent( + `/mail/_agent-native/embed/start?ticket=${ticket.ticket}`, + headers, + { clientAddress: "203.0.113.20" }, + ); + start.req.method = "GET"; + const response = (await createEmbedStartRouteHandler()(start)) as Response; + expect(response.status).toBe(302); + const location = new URL( + response.headers.get("location")!, + "https://workspace.example.test", + ); + const document = fakeEvent( + `${location.pathname}${location.search}`, + headers, + { clientAddress: "203.0.113.20" }, + ); + await expect( + resolveEmbedSessionFromRequest(document), + ).resolves.toMatchObject({ email: "owner@example.com" }); + const otherHost = fakeEvent( + `${location.pathname}${location.search}`, + { ...headers, "x-forwarded-host": "attacker.example" }, + { clientAddress: "203.0.113.20" }, + ); + await expect(resolveEmbedSessionFromRequest(otherHost)).resolves.toBeNull(); + }); + it.each(["audience_host", "audience_base_path"])( "requires a fresh ticket when legacy metadata %s is unknown", async (column) => { diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index 0548ff145e9..97da8d33837 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -36,9 +36,12 @@ import { } from "./app-base-path.js"; import { resolveAuthCookieNamespace } from "./cookie-namespace.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; +import { + getEmbedSessionOrigin, + resolveEmbedRequestOrigin, +} from "./embed-request-origin.js"; import { isHttpsRequest } from "./https-request.js"; import { getRequestContext } from "./request-context.js"; -import { getForwardedRequestHostname } from "./request-origin.js"; const TOKEN_KIND = "agent-native-embed-session"; const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60; @@ -660,12 +663,8 @@ function requestUrl(event: H3Event): URL | null { return event.url ?? null; } -function requestHostname(event: H3Event): string | null { - try { - return getForwardedRequestHostname(event); - } catch { - return null; - } +function requestHostname(event: H3Event): string { + return new URL(resolveEmbedRequestOrigin(event)).hostname; } function normalizedHostname( @@ -683,7 +682,7 @@ interface EmbedTicketAudience { function ticketAudience(audienceHost?: string): EmbedTicketAudience | null { let hostname = audienceHost; if (hostname === undefined) { - const origin = getRequestContext()?.requestOrigin; + const origin = getEmbedSessionOrigin(); if (!origin || !URL.canParse(origin)) return null; const url = new URL(origin); if (url.protocol !== "https:" && url.protocol !== "http:") return null; @@ -1948,11 +1947,17 @@ export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { if (!cookie.ok || !embedTokenMatchesRequestAudience(event, cookie.claims)) { return false; } - // A live capability can restrict browser proof minting without authorizing it. + return embedSessionAllowsRequest(event, cookie.claims); +} + +export function hasLiveEmbedCapabilityCookie(event: H3Event): boolean { + const cookie = verifyEmbedSessionToken( + getCookie(event, EMBED_SESSION_COOKIE), + ); return ( - embedSessionAllowsRequest(event, cookie.claims) || - (isEmbedCapabilityScope(cookie.claims.scope) && - requestPathname(event) === "/_agent-native/ui-capability") + cookie.ok && + isEmbedCapabilityScope(cookie.claims.scope) && + embedTokenMatchesRequestAudience(event, cookie.claims) ); } diff --git a/packages/core/src/server/request-context.ts b/packages/core/src/server/request-context.ts index e4b6705a636..d2aa6b8aa74 100644 --- a/packages/core/src/server/request-context.ts +++ b/packages/core/src/server/request-context.ts @@ -163,6 +163,8 @@ export interface RequestContext { authContextAccessed?: boolean; requestOrigin?: string; federationMembershipValidated?: boolean; + /** Resolves embed authority independently of the origin used for MCP links. */ + resolveEmbedRequestOrigin?: () => string; /** * True when the request's real socket peer is loopback, captured by the * action-route handler while the h3 event is still in scope (nothing below diff --git a/packages/core/src/server/ui-action-capability.spec.ts b/packages/core/src/server/ui-action-capability.spec.ts index 4b7d19d6759..19bb559c777 100644 --- a/packages/core/src/server/ui-action-capability.spec.ts +++ b/packages/core/src/server/ui-action-capability.spec.ts @@ -295,6 +295,80 @@ describe("UI action capability", () => { }, ); + it.each([ + { base: "", path: "/_agent-native/ui-capability" }, + { base: "", path: "/_agent-native/ui-capability/" }, + { base: "/mail", path: "/mail/_agent-native/ui-capability" }, + { base: "/mail", path: "/mail/_agent-native/ui-capability/" }, + ])( + "checks live embed cookies inside the capability handler at $path", + async ({ base, path }) => { + vi.stubEnv("APP_BASE_PATH", base); + const token = signEmbedSessionToken({ + ownerEmail: "alice@example.com", + targetPath: "/documents/doc-1", + audienceHost: "example.test", + scope: "capability:visual-edit:design:design-1", + }); + const app = createApp(); + mountUiActionCapabilityRoute(app, `${base}/_agent-native`, base); + const blocked = await app.fetch( + new Request(`https://example.test${path}`, { + headers: { + "sec-fetch-site": "same-origin", + cookie: `${EMBED_SESSION_COOKIE}=${token}; an_session=browser-login`, + }, + }), + ); + expect(blocked.status).toBe(403); + expect(await blocked.json()).toEqual({ + error: "Browser cookie session required", + }); + expect(blocked.headers.get("set-cookie")).toBeNull(); + const browser = await app.fetch( + new Request(`https://example.test${path}`, { + headers: { + "sec-fetch-site": "same-origin", + cookie: "an_session=browser-login", + }, + }), + ); + expect(browser.status).toBe(200); + expect(browser.headers.get("set-cookie")).toContain( + "agent-native-ui-capability=", + ); + }, + ); + + it.each([ + { base: "", path: "/_agent-native/ui-capability" }, + { base: "/mail", path: "/mail/_agent-native/ui-capability/" }, + ])( + "refuses malformed authority with a live embed cookie at $path", + async ({ base, path }) => { + vi.stubEnv("APP_BASE_PATH", base); + const token = signEmbedSessionToken({ + ownerEmail: "alice@example.com", + targetPath: "/documents/doc-1", + audienceHost: "example.test", + scope: "capability:visual-edit:design:design-1", + }); + const app = createApp(); + mountUiActionCapabilityRoute(app, `${base}/_agent-native`, base); + const response = await app.fetch( + new Request(`https://example.test${path}`, { + headers: { + "sec-fetch-site": "same-origin", + "x-forwarded-host": "example.test/path", + cookie: `${EMBED_SESSION_COOKIE}=${token}; an_session=browser-login`, + }, + }), + ); + expect(response.status).toBe(400); + expect(response.headers.get("set-cookie")).toBeNull(); + }, + ); + it.each([ "invalid", "expired", diff --git a/packages/core/src/server/ui-action-capability.ts b/packages/core/src/server/ui-action-capability.ts index 9df0bc0cbe7..307df26ddfc 100644 --- a/packages/core/src/server/ui-action-capability.ts +++ b/packages/core/src/server/ui-action-capability.ts @@ -17,7 +17,10 @@ import { getBrowserCookieSession, getBrowserSessionConfigurationError, } from "./auth.js"; -import { hasPresentedEmbedSessionCredential } from "./embed-session.js"; +import { + hasLiveEmbedCapabilityCookie, + hasPresentedEmbedSessionCredential, +} from "./embed-session.js"; import { getH3App } from "./framework-request-handler.js"; import { publicFrameworkPath } from "./framework-route-prefix.js"; import { isHttpsRequest } from "./https-request.js"; @@ -66,7 +69,10 @@ export function hasUiActionCapability( } async function issueUiActionCapability(event: H3Event, appBasePath?: string) { - if (hasPresentedEmbedSessionCredential(event)) { + if ( + hasLiveEmbedCapabilityCookie(event) || + hasPresentedEmbedSessionCredential(event) + ) { setResponseStatus(event, 403); return { error: "Browser cookie session required" }; } From 8a2bf9c69d894c8ba1c435b3b6032d5fc4ef1b64 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:29:31 -0400 Subject: [PATCH 05/15] Align widget metadata and embed session origin checks --- .changeset/tidy-embed-session-scope.md | 2 + packages/core/src/mcp/build-server.ts | 120 ++++++++---------- packages/core/src/mcp/server.spec.ts | 113 +++++++++++++++++ .../core/src/server/action-routes.spec.ts | 20 ++- packages/core/src/server/auth.spec.ts | 49 +++++++ .../core/src/server/embed-request-origin.ts | 7 +- packages/core/src/server/embed-route.spec.ts | 10 +- .../core/src/server/embed-session.spec.ts | 80 ++++++++++++ packages/core/src/server/embed-session.ts | 41 ++++-- .../src/server/ui-action-capability.spec.ts | 2 +- .../server/lib/calendar-mcp-writes.spec.ts | 1 - 11 files changed, 360 insertions(+), 85 deletions(-) diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md index bbdd27d72d5..cc764ec3484 100644 --- a/.changeset/tidy-embed-session-scope.md +++ b/.changeset/tidy-embed-session-scope.md @@ -7,3 +7,5 @@ Scope MCP Apps embed sessions to their resource and declared actions, and requir Apps with a custom `AuthOptions.getSession` resolver must also configure the cookie-only `getBrowserSession` hook to use UI capabilities and browser approvals. Without it, these operations return `401` and the server logs a migration warning. MCP Apps embeds behind a proxy require the public origin configured through `APP_URL` or the workspace gateway URL unless the proxy connects over loopback. + +Use the embed origin consistently for widget CSP and keep ordinary sign-in session lookup independent of embed origin validation. diff --git a/packages/core/src/mcp/build-server.ts b/packages/core/src/mcp/build-server.ts index ab620756fcf..e93ea4d60a1 100644 --- a/packages/core/src/mcp/build-server.ts +++ b/packages/core/src/mcp/build-server.ts @@ -1042,6 +1042,7 @@ interface ResolvedMcpAppResource { title?: string; description?: string; html: ActionMcpAppResourceConfig["html"]; + requestOrigin?: string; mimeType: typeof MCP_APP_MIME_TYPE; _meta?: Record; } @@ -2074,34 +2075,33 @@ function getMcpAppResourceUri( function expandRequestOriginSources( sources: string[] | undefined, - requestMeta?: MCPRequestMeta, + requestOrigin?: string, ): string[] | undefined { if (!sources) return undefined; - const origin = requestMeta?.origin; return sources.flatMap((source) => - source === MCP_APP_REQUEST_ORIGIN_CSP_SOURCE && origin - ? [origin] + source === MCP_APP_REQUEST_ORIGIN_CSP_SOURCE && requestOrigin + ? [requestOrigin] : [source], ); } function openAiWidgetCsp( cspConfig: ActionMcpAppCsp | undefined, - requestMeta?: MCPRequestMeta, + requestOrigin?: string, ): Record | undefined { if (!cspConfig) return undefined; const csp: Record = {}; const connectDomains = expandRequestOriginSources( cspConfig.connectDomains, - requestMeta, + requestOrigin, ); const resourceDomains = expandRequestOriginSources( cspConfig.resourceDomains, - requestMeta, + requestOrigin, ); const frameDomains = expandRequestOriginSources( cspConfig.frameDomains, - requestMeta, + requestOrigin, ); if (connectDomains?.length) csp.connect_domains = connectDomains; if (resourceDomains?.length) csp.resource_domains = resourceDomains; @@ -2112,7 +2112,7 @@ function openAiWidgetCsp( function mcpAppUiMeta( resource: ActionMcpAppResourceConfig, resolvedCsp: ActionMcpAppCsp | undefined, - requestMeta?: MCPRequestMeta, + requestOrigin?: string, description?: string, widgetDomain?: string, directoryMode = false, @@ -2144,21 +2144,21 @@ function mcpAppUiMeta( ...csp, connectDomains: expandRequestOriginSources( resolvedCsp.connectDomains, - requestMeta, + requestOrigin, ), resourceDomains: expandRequestOriginSources( resolvedCsp.resourceDomains, - requestMeta, + requestOrigin, ), frameDomains: expandRequestOriginSources( resolvedCsp.frameDomains, - requestMeta, + requestOrigin, ), ...(!directoryMode ? { baseUriDomains: expandRequestOriginSources( resolvedCsp.baseUriDomains, - requestMeta, + requestOrigin, ), } : {}), @@ -2175,7 +2175,7 @@ function mcpAppUiMeta( originString(resource.domain) ?? originString(ui.domain) ?? originString(existingUi.domain) ?? - originString(requestMeta?.origin); + originString(requestOrigin); if (typeof resource.prefersBorder === "boolean") { ui.prefersBorder = resource.prefersBorder; } @@ -2189,7 +2189,7 @@ function mcpAppUiMeta( ) { base["openai/widgetPrefersBorder"] = resource.prefersBorder; } - const openAiCsp = openAiWidgetCsp(resolvedCsp, requestMeta); + const openAiCsp = openAiWidgetCsp(resolvedCsp, requestOrigin); if (directoryMode) { const directoryCsp = { ...(openAiCsp ?? {}) }; const redirectDomain = originString(widgetDomain); @@ -2226,7 +2226,7 @@ async function resolveMcpAppCsp( : resource.csp; } -async function resolveMcpAppResource( +export async function resolveMcpAppResourceSafely( config: MCPConfig, actionName: string, entry: ActionEntry, @@ -2244,41 +2244,36 @@ async function resolveMcpAppResource( } const resolvedUri = getMcpAppResourceUri(config, actionName, entry); if (!resolvedUri) return null; - const description = resource.description ?? entry.tool.description; - const resolvedCsp = await resolveMcpAppCsp(resource, { - actionName, - appId: config.appId, - requestOrigin: requestMeta?.origin, - catalogMode: config.catalogMode, - }); - const resourceMeta = mcpAppUiMeta( - resource, - resolvedCsp, - requestMeta, - description, - config.catalogMode === "directory" ? config.widgetDomain : undefined, - config.catalogMode === "directory", - ); - return { - uri: resolvedUri.uri, - ...(resolvedUri.legacyUris ? { legacyUris: resolvedUri.legacyUris } : {}), - name: resource.name?.trim() || actionName, - ...(resource.title ? { title: resource.title } : {}), - ...(description ? { description } : {}), - html: resource.html, - mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE, - ...(resourceMeta ? { _meta: resourceMeta } : {}), - }; -} - -export async function resolveMcpAppResourceSafely( - config: MCPConfig, - actionName: string, - entry: ActionEntry, - requestMeta?: MCPRequestMeta, -): Promise { + const requestOrigin = requestMeta?.resolveEmbedRequestOrigin + ? requestMeta.resolveEmbedRequestOrigin() + : requestMeta?.origin; try { - return await resolveMcpAppResource(config, actionName, entry, requestMeta); + const description = resource.description ?? entry.tool.description; + const resolvedCsp = await resolveMcpAppCsp(resource, { + actionName, + appId: config.appId, + requestOrigin, + catalogMode: config.catalogMode, + }); + const resourceMeta = mcpAppUiMeta( + resource, + resolvedCsp, + requestOrigin, + description, + config.catalogMode === "directory" ? config.widgetDomain : undefined, + config.catalogMode === "directory", + ); + return { + uri: resolvedUri.uri, + ...(resolvedUri.legacyUris ? { legacyUris: resolvedUri.legacyUris } : {}), + name: resource.name?.trim() || actionName, + ...(resource.title ? { title: resource.title } : {}), + ...(description ? { description } : {}), + html: resource.html, + requestOrigin, + mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE, + ...(resourceMeta ? { _meta: resourceMeta } : {}), + }; } catch (error) { console.warn( `[mcp] Skipping MCP App resource for action "${actionName}" because its metadata could not be resolved: ${describeThrownForLog(error)}`, @@ -2383,13 +2378,12 @@ function renderMcpAppHtml( resource: ResolvedMcpAppResource, actionName: string, config: MCPConfig, - requestMeta?: MCPRequestMeta, ): string { if (typeof resource.html === "function") { return resource.html({ actionName, appId: config.appId, - requestOrigin: requestMeta?.origin, + requestOrigin: resource.requestOrigin, catalogMode: config.catalogMode, startToolName: config.catalogMode === "directory" && @@ -3207,17 +3201,16 @@ export async function createMCPServerForRequest( .sort(([a], [b]) => compareMcpCatalogValues(a, b)) .map(async ([name, entry]) => { const hasLink = typeof entry.link === "function"; - const mcpAppResource = mcpAppWidgetsEnabledForIdentity( - config, - effectiveIdentity, - ) - ? await resolveMcpAppResourceSafely( - config, - name, - entry, - requestMeta, - ) - : null; + const mcpAppResource = + requestMeta?.inlineMcpApps === true && + mcpAppWidgetsEnabledForIdentity(config, effectiveIdentity) + ? await resolveMcpAppResourceSafely( + config, + name, + entry, + requestMeta, + ) + : null; const rawToolMeta = (entry.tool as any)._meta && typeof (entry.tool as any)._meta === "object" && @@ -3727,7 +3720,6 @@ export async function createMCPServerForRequest( found.resource, found.actionName, config, - requestMeta, ), ...(found.resource._meta ? { _meta: found.resource._meta } diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 1360148132d..93ca72ae0c4 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -24,6 +24,7 @@ import { MCP_ACTION_RESULT_MARKER } from "../mcp-client/app-result.js"; import { listResourceSuggestions } from "../review/suggestions/actions.js"; import { loadActionsFromStaticRegistry } from "../server/action-discovery.js"; import { registerErrorCaptureProvider } from "../server/capture-error.js"; +import { resolveEmbedRequestOrigin } from "../server/embed-request-origin.js"; import { filesystemActionEntry } from "../server/filesystem-action-entry.js"; import { EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS, @@ -2406,6 +2407,107 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", vi.unstubAllEnvs(); }); + async function expectWidgetResourceOrigin( + sourceEvent: any, + expectedOrigin: string, + ) { + const cspOrigins: Array = []; + const widgetConfig = scopedWidgetConfig({ + ...config, + actions: { + "echo-thing": { + ...config.actions["echo-thing"], + mcpApp: { + resource: { + ...config.actions["echo-thing"].mcpApp.resource, + csp: ({ requestOrigin }) => { + cspOrigins.push(requestOrigin); + return { + connectDomains: [ + "$requestOrigin", + "https://api.example.com", + ], + resourceDomains: ["$requestOrigin"], + frameDomains: ["$requestOrigin"], + baseUriDomains: ["$requestOrigin"], + }; + }, + }, + }, + }, + }, + }); + const expectedCsp = { + connect_domains: [expectedOrigin, "https://api.example.com"], + resource_domains: [expectedOrigin], + frame_domains: [expectedOrigin], + }; + for (const method of [ + "tools/list", + "resources/list", + "resources/templates/list", + "resources/read", + "tools/call", + ]) { + const event = makeWebEvent({ + node: Boolean(sourceEvent.node), + ip: sourceEvent._ip, + headers: { + ...sourceEvent._headers, + "x-agent-native-mcp-full-catalog": "1", + }, + body: { + jsonrpc: "2.0", + id: 142, + method, + params: + method === "resources/read" + ? { uri: "ui://mail/echo-thing/shell-v65" } + : method === "tools/call" + ? { name: "echo-thing", arguments: { value: "hello" } } + : {}, + }, + }); + event.url = new URL(sourceEvent.url); + event.req.runtime = sourceEvent.req.runtime; + event.req.ip = sourceEvent.req.ip; + const response = await handleMcpRequest(event, widgetConfig); + expect(response).toBeInstanceOf(Response); + const out = await readWebResponse(response as Response); + expect(out.error).toBeUndefined(); + expect(out.result.isError).not.toBe(true); + const item = + method === "tools/list" + ? out.result.tools.find((tool: any) => tool.name === "echo-thing") + : method === "resources/list" + ? out.result.resources[0] + : method === "resources/templates/list" + ? out.result.resourceTemplates[0] + : method === "resources/read" + ? out.result.contents[0] + : out.result; + expect(item._meta["openai/widgetCSP"]).toEqual(expectedCsp); + expect(JSON.stringify(item._meta["openai/widgetCSP"])).not.toContain( + "attacker.example", + ); + if (method.startsWith("resources/")) { + expect(item._meta.ui.csp).toEqual({ + connectDomains: [expectedOrigin, "https://api.example.com"], + resourceDomains: [expectedOrigin], + frameDomains: [expectedOrigin], + baseUriDomains: [expectedOrigin], + }); + expect(item._meta.ui.domain).toBeUndefined(); + expect(item._meta["openai/widgetDomain"]).toBe(expectedOrigin); + } + if (method === "resources/read") { + expect(item.text).toContain(`data-origin="${expectedOrigin}"`); + expect(item.text).not.toContain("attacker.example"); + } + } + expect(cspOrigins).toEqual(Array(5).fill(expectedOrigin)); + } + it.each([false, true])( "uses the raw request origin for widget tickets despite untrusted forwarded headers (Node: %s)", async (node) => { @@ -2468,6 +2570,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }), ).resolves.toBeNull(); expect(startUrl.hostname).not.toBe("attacker.example"); + await expectWidgetResourceOrigin(event, startUrl.origin); }, ); @@ -2573,6 +2676,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", { audienceHost: "attacker.example" }, ), ).resolves.toBeNull(); + await expectWidgetResourceOrigin(event, startUrl.origin); }, ); @@ -2655,6 +2759,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", new URL(renewal.result._meta["agent-native/embedStart"].startUrl) .origin, ).toBe("https://workspace.example.test"); + await expectWidgetResourceOrigin(event, startUrl.origin); }, ); @@ -10482,6 +10587,12 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", "honors an explicit false inline signal in the %s catalog", async (catalog) => { const run = vi.fn(async () => ({ ready: true })); + const event = makeWebEvent({ + headers: { "x-forwarded-host": "mail.agent-native.com/path" }, + }); + const resolveWidgetOrigin = vi.fn(() => + resolveEmbedRequestOrigin(event), + ); const server = await createMCPServerForRequest( { ...config, @@ -10501,6 +10612,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", { inlineMcpApps: false, fullCatalog: catalog === "full", + resolveEmbedRequestOrigin: resolveWidgetOrigin, }, ); const [clientTransport, serverTransport] = @@ -10524,6 +10636,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", }), ).toMatchObject({ isError: true }); expect(run).not.toHaveBeenCalled(); + expect(resolveWidgetOrigin).not.toHaveBeenCalled(); } finally { await Promise.all([client.close(), server.close()]); } diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 4c91ae516b8..097465737f1 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -61,7 +61,8 @@ function fakeUnsignedJwt(payload: Record): string { return `${encode({ alg: "none" })}.${encode(payload)}.not-a-real-signature`; } -vi.mock("h3", () => ({ +vi.mock("h3", async (importOriginal) => ({ + ...(await importOriginal()), createError: (opts: any) => Object.assign(new Error(opts?.statusMessage), opts), defineEventHandler: (handler: any) => handler, @@ -796,9 +797,11 @@ describe("mountActionRoutes", () => { "invalid", "marked", "live-capability", + "live-capability-malformed", + "live-capability-empty", ] as const ).flatMap((credential) => - (credential === "live-capability" ? [true] : [true, false]).map( + (credential.startsWith("live-capability") ? [true] : [true, false]).map( (needsApproval) => ({ credential, needsApproval }), ), ), @@ -840,7 +843,7 @@ describe("mountActionRoutes", () => { audienceHost: "app.example.com", targetPath: "/page/doc-1", ttlSeconds: 60, - ...(credential === "live-capability" + ...(credential.startsWith("live-capability") ? { scope: "capability:visual-edit:design:doc-1" } : {}), }); @@ -868,6 +871,11 @@ describe("mountActionRoutes", () => { origin: "https://app.example.com", "sec-fetch-site": "same-origin", "x-agent-native-frontend": "1", + ...(credential === "live-capability-malformed" + ? { "x-forwarded-host": "app.example.com/path" } + : credential === "live-capability-empty" + ? { "x-forwarded-host": "" } + : {}), ...(credential === "marked" ? { "x-agent-native-embed-target": "/page/doc-1" } : {}), @@ -879,13 +887,17 @@ describe("mountActionRoutes", () => { json: async () => ({ id: "doc-1", designId: "doc-1" }), }, }; + event.req.headers = new Headers(event._headers); if (credential === "live-capability") { await expect(mounted[0]!.handler(event)).resolves.toMatchObject({ errorCode: "approval_required", }); expect(event._status).toBe(409); expect(run).not.toHaveBeenCalled(); - } else if (credential !== "marked") { + } else if ( + !credential.startsWith("live-capability") && + credential !== "marked" + ) { await expect(mounted[0]!.handler(event)).resolves.toEqual({ ok: true }); expect(run).toHaveBeenCalledOnce(); expect(getOwnerFromEvent).toHaveBeenCalledOnce(); diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index 8eb837bc625..ec9bc5633eb 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -9365,6 +9365,55 @@ describe("server/auth", () => { }); describe("getSession", () => { + it.each([ + { "x-forwarded-host": "" }, + { "x-forwarded-host": "localhost/path" }, + { "x-forwarded-proto": "" }, + { "x-forwarded-proto": "javascript" }, + ])( + "preserves browser cookie sessions with unrelated malformed forwarding: %j", + async (forwardedHeaders) => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.ACCESS_TOKEN; + delete process.env.ACCESS_TOKENS; + delete process.env.AUTH_DISABLED; + const auth = { + api: { + getSession: vi.fn(async ({ headers }: { headers: Headers }) => + headers.get("cookie") === + "better-auth.session_token=browser-login" + ? { + user: { id: "browser-user", email: "owner@example.com" }, + session: { token: "browser-login" }, + } + : null, + ), + }, + }; + vi.doMock("./better-auth-instance.js", async (importOriginal) => ({ + ...(await importOriginal()), + getBetterAuth: vi.fn(async () => auth), + getBetterAuthSync: vi.fn(() => auth), + resumeIdentityRekeysForEmail: vi.fn(async () => {}), + })); + vi.doMock("../org/context.js", () => ({ + resolveOrgIdForEmailViaEvent: vi.fn(async () => null), + })); + const { getSession } = await import("./auth.js"); + const event = createMockEvent({ + headers: { + cookie: "better-auth.session_token=browser-login", + ...forwardedHeaders, + }, + }); + + await expect(getSession(event)).resolves.toMatchObject({ + email: "owner@example.com", + token: "browser-login", + }); + }, + ); + it("rechecks the database after a legacy session is revoked", async () => { vi.stubEnv("NODE_ENV", "production"); let sessionPresent = true; diff --git a/packages/core/src/server/embed-request-origin.ts b/packages/core/src/server/embed-request-origin.ts index 95e586eecfc..dd6bf262198 100644 --- a/packages/core/src/server/embed-request-origin.ts +++ b/packages/core/src/server/embed-request-origin.ts @@ -1,4 +1,4 @@ -import { createError, getRequestHeader, type H3Event } from "h3"; +import { createError, getRequestHeaders, type H3Event } from "h3"; import { getConfiguredOriginAllowlist } from "./origin-allowlist.js"; import { getRequestContext } from "./request-context.js"; @@ -7,8 +7,9 @@ import { getForwardedRequestOrigin } from "./request-origin.js"; export function resolveEmbedRequestOrigin(event: H3Event): string { const fallback = getForwardedRequestOrigin(event); const requestOrigin = new URL(fallback); - const rawHost = getRequestHeader(event, "x-forwarded-host"); - const rawProto = getRequestHeader(event, "x-forwarded-proto"); + const headers = getRequestHeaders(event); + const rawHost = headers["x-forwarded-host"]; + const rawProto = headers["x-forwarded-proto"]; const host = rawHost?.split(",")[0]?.trim() ?? requestOrigin.host; const proto = rawProto?.split(",")[0]?.trim().toLowerCase() ?? diff --git a/packages/core/src/server/embed-route.spec.ts b/packages/core/src/server/embed-route.spec.ts index a136b4a3a8c..31fce3e648c 100644 --- a/packages/core/src/server/embed-route.spec.ts +++ b/packages/core/src/server/embed-route.spec.ts @@ -7,7 +7,8 @@ import { const setResponseHeader = vi.hoisted(() => vi.fn()); -vi.mock("h3", () => ({ +vi.mock("h3", async (importOriginal) => ({ + ...(await importOriginal()), createError: (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input), defineEventHandler: (handler: any) => handler, @@ -46,6 +47,13 @@ function fakeEvent( headers: Record = {}, ) { return { + req: { + headers: new Headers({ + host: "app.test", + "x-forwarded-proto": "https", + ...headers, + }), + }, method, ip: "127.0.0.1", query, diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index be43c1e2e53..bee48aad140 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -32,6 +32,8 @@ import { isMcpDirectoryWidgetWriteCapabilityScope, renewMcpDirectoryWidgetCapabilityScope, } from "../shared/embed-auth.js"; +import { resolveEmbedRequestOrigin } from "./embed-request-origin.js"; +import { createEmbedStartRouteHandler } from "./embed-route.js"; import { requestMatchesEmbedTarget, normalizeEmbedTargetPath, @@ -51,6 +53,7 @@ import { setEmbedSessionCookie, signEmbedSessionToken, verifyEmbedSessionToken, + embedSessionsStore, } from "./embed-session.js"; import { getRequestContext, runWithRequestContext } from "./request-context.js"; @@ -1154,6 +1157,83 @@ describe("requestMatchesEmbedTarget", () => { }); } + it.each([ + { "x-forwarded-host": "" }, + { "x-forwarded-host": "mail.test/path" }, + { "x-forwarded-proto": "" }, + { "x-forwarded-proto": "javascript" }, + ])( + "does not resolve an embed session with malformed authority: %j", + async (headers) => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + }); + const event = credentialEvent("/inbox", token, "cookie", headers); + await expect(resolveEmbedSessionFromRequest(event)).resolves.toBeNull(); + await expect( + isExpiredMcpDirectoryWidgetSessionRequest(event), + ).resolves.toBe(false); + }, + ); + + it.each([ + { "x-forwarded-host": "" }, + { "x-forwarded-host": "mail.test/path" }, + { "x-forwarded-proto": "" }, + { "x-forwarded-proto": "javascript" }, + ])( + "keeps ticket mint, start and renewal strict for malformed authority: %j", + async (headers) => { + await embedSessionsStore.ready(); + const rows = installTicketStore(); + const scope = contentWidgetWriteScope(); + const ticket = await createEmbedSessionTicket({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + scope, + audienceHost: "mail.test", + revocationAnchorCreatedAtMs: Date.now(), + }); + const event = fakeEvent( + `/_agent-native/embed/start?ticket=${ticket.ticket}`, + { + host: "mail.test", + ...headers, + }, + ); + const context = { + userEmail: "owner@example.com", + requestOrigin: "http://mail.test", + resolveEmbedRequestOrigin: () => resolveEmbedRequestOrigin(event), + }; + await expect( + runWithRequestContext(context, () => + createEmbedSessionTicketForAudience({ + ownerEmail: "owner@example.com", + targetPath: "/inbox", + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + await expect(createEmbedStartRouteHandler()(event)).rejects.toMatchObject( + { statusCode: 400 }, + ); + await expect( + runWithRequestContext(context, () => + renewMcpDirectoryWidgetSessionForAudience({ + sourceTicket: ticket.ticket, + ownerEmail: "owner@example.com", + expectedScope: scope, + renewedScope: scope, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(rows.get(ticket.ticketHash)?.consumed_at).toBeNull(); + expect(rows.get(ticket.ticketHash)?.scope).toBe(scope); + }, + ); + it.each([ ["/_agent-native/auth/session", "GET"], ["/_agent-native/application-state", "GET"], diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index 97da8d33837..0122955af72 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -663,8 +663,13 @@ function requestUrl(event: H3Event): URL | null { return event.url ?? null; } -function requestHostname(event: H3Event): string { - return new URL(resolveEmbedRequestOrigin(event)).hostname; +function requestHostname(event: H3Event): string | null { + try { + return new URL(resolveEmbedRequestOrigin(event)).hostname; + } catch { + // coercion-ok: invalid authority cannot authenticate an embed session. + return null; + } } function normalizedHostname( @@ -1361,7 +1366,7 @@ export async function resolveEmbedSessionTokenForHost( export async function isExpiredMcpDirectoryWidgetSessionRequest( event: H3Event, ): Promise { - const hostname = requestHostname(event) ?? ""; + let hostname: string | undefined; const tokens = [ queryToken(event), bearerToken(event), @@ -1372,7 +1377,13 @@ export async function isExpiredMcpDirectoryWidgetSessionRequest( if ( !verified.ok || !verified.claims.sessionId || - !isMcpDirectoryWidgetCapabilityScope(verified.claims.scope) || + !isMcpDirectoryWidgetCapabilityScope(verified.claims.scope) + ) { + continue; + } + hostname ??= requestHostname(event) ?? undefined; + if (!hostname) return false; + if ( !embedTokenMatchesHostname(hostname, verified.claims) || !embedSessionAllowsRequest(event, verified.claims) ) { @@ -1870,12 +1881,16 @@ function queryToken(event: H3Event): string | undefined { export async function resolveEmbedSessionFromRequest( event: H3Event, ): Promise { - const hostname = requestHostname(event) ?? ""; const candidates = [ { token: queryToken(event), source: "query" }, { token: bearerToken(event), source: "bearer" }, { token: getCookie(event, EMBED_SESSION_COOKIE), source: "cookie" }, - ]; + ].filter( + ({ token }) => verifyEmbedSessionToken(token, { allowExpired: true }).ok, + ); + if (!candidates.length) return null; + const hostname = requestHostname(event); + if (!hostname) return null; for (const candidate of candidates) { const claims = await resolveEmbedSessionTokenForHost( candidate.token, @@ -1944,20 +1959,24 @@ export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { const cookie = verifyEmbedSessionToken( getCookie(event, EMBED_SESSION_COOKIE), ); - if (!cookie.ok || !embedTokenMatchesRequestAudience(event, cookie.claims)) { + if (!cookie.ok || !embedSessionAllowsRequest(event, cookie.claims)) { return false; } - return embedSessionAllowsRequest(event, cookie.claims); + const hostname = requestHostname(event); + return ( + hostname === null || embedTokenMatchesHostname(hostname, cookie.claims) + ); } export function hasLiveEmbedCapabilityCookie(event: H3Event): boolean { const cookie = verifyEmbedSessionToken( getCookie(event, EMBED_SESSION_COOKIE), ); + if (!cookie.ok || !isEmbedCapabilityScope(cookie.claims.scope)) return false; + const hostname = requestHostname(event); + // Invalid authority cannot establish that a live capability cookie is unrelated. return ( - cookie.ok && - isEmbedCapabilityScope(cookie.claims.scope) && - embedTokenMatchesRequestAudience(event, cookie.claims) + hostname === null || embedTokenMatchesHostname(hostname, cookie.claims) ); } diff --git a/packages/core/src/server/ui-action-capability.spec.ts b/packages/core/src/server/ui-action-capability.spec.ts index 19bb559c777..8f5df7a2b9c 100644 --- a/packages/core/src/server/ui-action-capability.spec.ts +++ b/packages/core/src/server/ui-action-capability.spec.ts @@ -364,7 +364,7 @@ describe("UI action capability", () => { }, }), ); - expect(response.status).toBe(400); + expect(response.status).toBe(403); expect(response.headers.get("set-cookie")).toBeNull(); }, ); diff --git a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts index 72d3339f3da..bf4e07583e0 100644 --- a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts +++ b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts @@ -199,7 +199,6 @@ describe("Calendar direct MCP writes", () => { "ask_app", "ask_app_status", "create-event", - "create_embed_session", "list-events", "list_apps", "open_app", From 27bd7af5c268dde950d89c09df174c3d17221567 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:41:28 -0400 Subject: [PATCH 06/15] Open Slides 2 cards in the full app --- .../mcp/directory-template-profiles.spec.ts | 169 +++++++++++++++--- templates/slides-2/actions/add-slide.ts | 14 +- templates/slides-2/actions/create-deck.ts | 9 +- ...22-open-decks-in-the-full-app-from-chat.md | 5 + .../server/lib/chatgpt-directory-tools.ts | 44 +---- 5 files changed, 157 insertions(+), 84 deletions(-) create mode 100644 templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md diff --git a/packages/core/src/mcp/directory-template-profiles.spec.ts b/packages/core/src/mcp/directory-template-profiles.spec.ts index 23e592ba97d..9a0c5724d71 100644 --- a/packages/core/src/mcp/directory-template-profiles.spec.ts +++ b/packages/core/src/mcp/directory-template-profiles.spec.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CHATGPT_DIRECTORY_PROFILE as contentProfile } from "../../../../templates/content/server/lib/chatgpt-directory-tools.js"; import { CHATGPT_DIRECTORY_PROFILE as designProfile } from "../../../../templates/design/server/lib/chatgpt-directory-tools.js"; +import { CHATGPT_DIRECTORY_PROFILE as slides2Profile } from "../../../../templates/slides-2/server/lib/chatgpt-directory-tools.js"; import { CHATGPT_DIRECTORY_PROFILE as slidesProfile } from "../../../../templates/slides/server/lib/chatgpt-directory-tools.js"; import { isActionHiddenFromEveryAgentSurface } from "../action.js"; import { @@ -21,6 +22,7 @@ import { filterMcpOnlyActions, } from "../server/agent-chat/action-filters-a2a.js"; import { resolveAgentChatMcpOptions } from "../server/agent-chat/mcp-options.js"; +import * as embedSession from "../server/embed-session.js"; import { canRenewMcpDirectoryWidgetCapabilityScope, createMcpDirectoryWidgetReadCapability, @@ -109,12 +111,14 @@ async function loadTemplateActions(appId: string) { path.join(projectRoot, ".generated/actions-registry.ts"), "utf8", ); - const profile = templateProfiles.find( - (profile) => profile.appId === appId, - )?.profile; + const profile = + appId === "slides-2" + ? slides2Profile + : templateProfiles.find((profile) => profile.appId === appId)?.profile; if (!profile) throw new Error(`Unknown ChatGPT directory template ${appId}.`); const toolNames = profile.connectorCatalog; - const sharedActions = sharedActionsByApp[appId] ?? {}; + const sharedActions = + sharedActionsByApp[appId === "slides-2" ? "slides" : appId] ?? {}; const loadNames = [ ...new Set([ ...toolNames, @@ -183,6 +187,127 @@ function mentionsTool(text: string, name: string): boolean { } describe("ChatGPT directory template profiles", () => { + it.each( + [{ appId: "slides-2", profile: slides2Profile }].flatMap((template) => + [ + { mode: "generic", catalogMode: undefined }, + { mode: "directory", catalogMode: "directory" as const }, + ].map((mode) => ({ + ...template, + ...mode, + })), + ), + )( + "$appId create/add return full-app links without UI resources or tickets in $mode mode", + async ({ appId, profile, catalogMode }) => { + const mintTicket = vi + .spyOn(embedSession, "createEmbedSessionTicket") + .mockRejectedValue( + new Error("Plain links must not mint embed tickets."), + ); + const { actions, productionActions } = await loadTemplateActions(appId); + const names = ["create-deck", "add-slide"]; + const deckId = "deck-full-app-link"; + const payload = { id: deckId, deckId, title: "Test deck", slideCount: 1 }; + const entries = { + ...productionActions, + ...Object.fromEntries( + names.map((name) => [ + name, + { + ...productionActions[name]!, + schema: undefined, + run: async () => payload, + }, + ]), + ), + }; + const server = await createMCPServerForRequest( + { + name: `agent-native-${appId}`, + appId, + description: "Slides full-app links", + catalogMode, + builtinCrossAppTools: false, + connectorCatalog: profile.connectorCatalog, + widgetDomain: profile.widgetDomain, + directoryProfile: profile, + actions: entries, + productionActions: entries, + widgetReadActions: selectMcpDirectoryWidgetReadActions( + profile, + actions, + ), + widgetWriteActions: selectMcpDirectoryWidgetWriteActions( + profile, + actions, + ), + }, + { + userEmail: "reviewer@example.test", + identityAssurance: "user", + mcpCredentialIssuedAtMs: Date.now() - 1_000, + orgId: null, + orgDomain: undefined, + }, + { + origin: profile.widgetDomain, + transport: "http", + inlineMcpApps: true, + }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "slides-links-spec", version: "1" }); + try { + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + const { tools } = await client.listTools(); + for (const name of names) { + const tool = tools.find((tool) => tool.name === name); + expect(tool, name).toBeDefined(); + expect(tool?._meta?.ui, name).toBeUndefined(); + expect(tool?._meta?.["openai/outputTemplate"], name).toBeUndefined(); + const result = await client.callTool({ name, arguments: { deckId } }); + expect(result.isError, name).not.toBe(true); + const openLink = result._meta?.["agent-native/openLink"] as { + webUrl: string; + label: string; + }; + expect(openLink, name).toMatchObject({ + label: + appId === "slides" + ? "Open deck in Slides" + : "Open deck in Slides 2", + }); + const url = new URL(openLink.webUrl); + expect(url.origin).toBe(profile.widgetDomain); + expect(url.pathname).toBe("/_agent-native/open"); + expect(url.searchParams.get("app")).toBe(appId); + expect(url.searchParams.get("view")).toBe("editor"); + expect(url.searchParams.get("deckId")).toBe(deckId); + expect(JSON.stringify(result.content)).toContain(openLink.webUrl); + expect(JSON.stringify(result)).not.toMatch( + /ui:\/\/|embed\/start|embedTicket|openai\/outputTemplate/, + ); + expect(result._meta?.ui).toBeUndefined(); + } + if (catalogMode === "directory") { + expect(client.getServerCapabilities()?.resources).toBeUndefined(); + } else { + expect((await client.listResources()).resources).toEqual([]); + } + expect(mintTicket).not.toHaveBeenCalled(); + } finally { + await Promise.all([client.close(), server.close()]); + mintTicket.mockRestore(); + } + }, + ACTION_REGISTRY_TEST_TIMEOUT_MS, + ); + it.each(templateProfiles)( "$appId keeps the full widget enabled at its canonical domain", ({ appId, profile }) => { @@ -1095,22 +1220,26 @@ describe("ChatGPT directory template profiles", () => { const sessionTool = tools.find( (tool) => tool.name === "create_embed_session", ); - expect(sessionTool?.annotations).toMatchObject({ - readOnlyHint: false, - destructiveHint: false, - openWorldHint: false, - }); - expect(sessionTool?._meta?.ui?.visibility).toEqual(["app"]); - expect(sessionTool?.inputSchema.required).toEqual(["sourceTicket"]); - expect(sessionTool?.inputSchema.properties).not.toHaveProperty( - "sourceTool", - ); - expect(sessionTool?.inputSchema.properties).not.toHaveProperty( - "toolInput", - ); - expect(sessionTool?.inputSchema.properties).not.toHaveProperty( - "toolOutput", - ); + if (widgetTargetNames.length === 0) { + expect(sessionTool).toBeUndefined(); + } else { + expect(sessionTool?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }); + expect(sessionTool?._meta?.ui?.visibility).toEqual(["app"]); + expect(sessionTool?.inputSchema.required).toEqual(["sourceTicket"]); + expect(sessionTool?.inputSchema.properties).not.toHaveProperty( + "sourceTool", + ); + expect(sessionTool?.inputSchema.properties).not.toHaveProperty( + "toolInput", + ); + expect(sessionTool?.inputSchema.properties).not.toHaveProperty( + "toolOutput", + ); + } } finally { await Promise.all([client.close(), server.close()]); } diff --git a/templates/slides-2/actions/add-slide.ts b/templates/slides-2/actions/add-slide.ts index 5255b7fd325..0e1754c66d6 100644 --- a/templates/slides-2/actions/add-slide.ts +++ b/templates/slides-2/actions/add-slide.ts @@ -1,9 +1,4 @@ -import { - ActionContractError, - defineAction, - embedApp, - fail, -} from "@agent-native/core"; +import { ActionContractError, defineAction, fail } from "@agent-native/core"; import type { WriteReceipt } from "@agent-native/core/action"; import { buildDeepLink, getRequestContext } from "@agent-native/core/server"; import { assertAccess } from "@agent-native/core/sharing"; @@ -247,13 +242,6 @@ export default defineAction({ }, mcpApp: { compactCatalog: true, - resource: embedApp({ - title: "Deck editor", - description: "Open the updated deck in the Slides 2 editor.", - iframeTitle: "Agent-Native Slides 2", - openLabel: "Open deck", - height: 680, - }), }, // Not retrySafe: a replay with the same slideId is rejected and must be // reconciled by reading the deck before another add is attempted. diff --git a/templates/slides-2/actions/create-deck.ts b/templates/slides-2/actions/create-deck.ts index ace9d09a250..0bda752d652 100644 --- a/templates/slides-2/actions/create-deck.ts +++ b/templates/slides-2/actions/create-deck.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp, fail } from "@agent-native/core"; +import { defineAction, fail } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { buildDeepLink } from "@agent-native/core/server"; import { @@ -474,13 +474,6 @@ export default defineAction({ }, mcpApp: { compactCatalog: true, - resource: embedApp({ - title: "Deck preview", - description: "Open the generated deck in the Slides 2 editor.", - iframeTitle: "Agent-Native Slides 2", - openLabel: "Open deck", - height: 680, - }), }, retrySafe: (input) => input.promptDraft !== undefined, http: { method: "POST" }, diff --git a/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md new file mode 100644 index 00000000000..794448856eb --- /dev/null +++ b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md @@ -0,0 +1,5 @@ +--- +type: fixed +date: 2026-10-22 +--- +New decks and added slides open in the full Slides 2 app from their chat links. diff --git a/templates/slides-2/server/lib/chatgpt-directory-tools.ts b/templates/slides-2/server/lib/chatgpt-directory-tools.ts index 5986ca8990b..58d23930cb0 100644 --- a/templates/slides-2/server/lib/chatgpt-directory-tools.ts +++ b/templates/slides-2/server/lib/chatgpt-directory-tools.ts @@ -22,39 +22,6 @@ export const CHATGPT_DIRECTORY_TOOL_NAMES = [ "duplicate-deck", ]; -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function id(...values: unknown[]): string | null { - return ( - values.find( - (value): value is string => - typeof value === "string" && Boolean(value.trim()), - ) ?? null - ); -} - -// share-resource, unshare-resource, and set-resource-visibility assert admin -// access to the deck themselves, so an editor holds this grant without being -// able to change sharing. -const DECK_WIDGET_WRITE_ACTIONS = [ - "patch-deck", - "share-resource", - "unshare-resource", - "set-resource-visibility", -] as const; - -function deckWidgetTarget(deckId: string) { - return { - targetPath: `/deck/${encodeURIComponent(deckId)}`, - resourceIds: { deckId, resourceType: "deck" }, - writeActions: DECK_WIDGET_WRITE_ACTIONS, - }; -} - type WidgetWriteAuthorizationInput = { toolName: string; args: Record; @@ -91,16 +58,7 @@ export const CHATGPT_DIRECTORY_PROFILE = { throw error; } }, - widgetTargets: { - "create-deck": (args: Record, result: unknown) => { - const deckId = id(record(result).id, args.deckId); - return deckId ? deckWidgetTarget(deckId) : null; - }, - "add-slide": (args: Record, result: unknown) => { - const deckId = id(args.deckId, record(result).deckId); - return deckId ? deckWidgetTarget(deckId) : null; - }, - }, + widgetTargets: {}, widgetReadActionArguments: { // The ticketed get-deck path normalizes duplicate IDs in memory only. "get-deck": { id: "deckId", deckId: "deckId" }, From 1aa2d6f98ac1ade9d1ed27bcc1ddb10dbc2d0590 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:44:45 -0400 Subject: [PATCH 07/15] Open Slides cards in the full app --- .../mcp/directory-template-profiles.spec.ts | 20 ++++++--- templates/slides/actions/add-slide.ts | 14 +----- templates/slides/actions/create-deck.ts | 11 +---- ...22-open-decks-in-the-full-app-from-chat.md | 5 +++ .../server/lib/chatgpt-directory-tools.ts | 44 +------------------ 5 files changed, 23 insertions(+), 71 deletions(-) create mode 100644 templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md diff --git a/packages/core/src/mcp/directory-template-profiles.spec.ts b/packages/core/src/mcp/directory-template-profiles.spec.ts index 9a0c5724d71..32196757aa5 100644 --- a/packages/core/src/mcp/directory-template-profiles.spec.ts +++ b/packages/core/src/mcp/directory-template-profiles.spec.ts @@ -188,7 +188,10 @@ function mentionsTool(text: string, name: string): boolean { describe("ChatGPT directory template profiles", () => { it.each( - [{ appId: "slides-2", profile: slides2Profile }].flatMap((template) => + [ + { appId: "slides-2", profile: slides2Profile }, + { appId: "slides", profile: slidesProfile }, + ].flatMap((template) => [ { mode: "generic", catalogMode: undefined }, { mode: "directory", catalogMode: "directory" as const }, @@ -1846,11 +1849,16 @@ describe("Slides widget share grant", () => { expiresAtMs = Date.now() + fifteenMinutes, }: { userEmail?: string; orgId?: string; expiresAtMs?: number } = {}, ) { - const target = slidesProfile.widgetTargets["create-deck"]( - {}, - { id: deckId }, - ); - if (!target) throw new Error("Slides create-deck target is missing."); + const target = { + targetPath: `/deck/${encodeURIComponent(deckId)}`, + resourceIds: { deckId, resourceType: "deck" } as Record, + writeActions: [ + "patch-deck", + "share-resource", + "unshare-resource", + "set-resource-visibility", + ], + }; const materialize = (rules: ArgumentRules): ArgumentRules => Object.fromEntries( Object.entries(rules).map(([name, rule]) => [ diff --git a/templates/slides/actions/add-slide.ts b/templates/slides/actions/add-slide.ts index ca70104b38c..72fb6b199f9 100644 --- a/templates/slides/actions/add-slide.ts +++ b/templates/slides/actions/add-slide.ts @@ -1,9 +1,4 @@ -import { - ActionContractError, - defineAction, - embedApp, - fail, -} from "@agent-native/core"; +import { ActionContractError, defineAction, fail } from "@agent-native/core"; import { buildDeepLink, getRequestContext } from "@agent-native/core/server"; import { assertAccess } from "@agent-native/core/sharing"; import { track } from "@agent-native/core/tracking"; @@ -199,13 +194,6 @@ export default defineAction({ }), mcpApp: { compactCatalog: true, - resource: embedApp({ - title: "Deck editor", - description: "Open the updated deck in the real Slides editor.", - iframeTitle: "Agent-Native Slides", - openLabel: "Open deck", - height: 680, - }), }, // Not retrySafe, even with a caller slideId: run inserts without checking for // an existing id, so a replay appends a second copy of the slide. diff --git a/templates/slides/actions/create-deck.ts b/templates/slides/actions/create-deck.ts index b80e6a3a760..2a33f9a5f48 100644 --- a/templates/slides/actions/create-deck.ts +++ b/templates/slides/actions/create-deck.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { buildDeepLink } from "@agent-native/core/server"; import { @@ -177,7 +177,7 @@ export default defineAction({ title: "Create Slides deck", description: "Create the real editable Agent-Native Slides deck, optionally already populated with slides, or atomically replace all slides in an existing deck. This is the primary Slides MCP write action: use it instead of creating or publishing a standalone HTML artifact with the host's file tools. Put slide markup in `slides[].content`; this action persists it and returns an Open in Slides link. " + - "For short AI-generated decks in MCP app hosts, pass all generated slides in this call so the real deck editor opens inline already populated. " + + "For short AI-generated decks in MCP app hosts, pass all generated slides in this call so the saved deck is ready to open in the full Slides app. " + "For longer decks or live in-app generation, create the deck with slides: [], then add every generated slide with add-slide sequentially so each write preserves per-slide Creative Context provenance; pass generationComplete=false on intermediate writes and true on the final write; use patch-deck for edits to existing slides or deck structure, and never issue parallel writes to the same deck. The new deck is also opened in the connected Slides UI. " + "Pass presenter-only speaker notes in each slide's `notes` field; keep them out of slide HTML. " + "Pass deckId to replace an existing deck. " + @@ -242,13 +242,6 @@ export default defineAction({ }, mcpApp: { compactCatalog: true, - resource: embedApp({ - title: "Deck preview", - description: "Open the generated deck in the real Slides editor.", - iframeTitle: "Agent-Native Slides", - openLabel: "Open deck", - height: 680, - }), }, http: { method: "POST" }, mcpAnnotations: { diff --git a/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md new file mode 100644 index 00000000000..e35d2ee78bc --- /dev/null +++ b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md @@ -0,0 +1,5 @@ +--- +type: fixed +date: 2026-10-22 +--- +New decks and added slides open in the full Slides app from their chat links. diff --git a/templates/slides/server/lib/chatgpt-directory-tools.ts b/templates/slides/server/lib/chatgpt-directory-tools.ts index 3e4ea48fea3..fd565080e89 100644 --- a/templates/slides/server/lib/chatgpt-directory-tools.ts +++ b/templates/slides/server/lib/chatgpt-directory-tools.ts @@ -17,39 +17,6 @@ export const CHATGPT_DIRECTORY_TOOL_NAMES = [ "duplicate-deck", ]; -function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function id(...values: unknown[]): string | null { - return ( - values.find( - (value): value is string => - typeof value === "string" && Boolean(value.trim()), - ) ?? null - ); -} - -// share-resource, unshare-resource, and set-resource-visibility assert admin -// access to the deck themselves, so an editor holds this grant without being -// able to change sharing. -const DECK_WIDGET_WRITE_ACTIONS = [ - "patch-deck", - "share-resource", - "unshare-resource", - "set-resource-visibility", -] as const; - -function deckWidgetTarget(deckId: string) { - return { - targetPath: `/deck/${encodeURIComponent(deckId)}`, - resourceIds: { deckId, resourceType: "deck" }, - writeActions: DECK_WIDGET_WRITE_ACTIONS, - }; -} - type WidgetWriteAuthorizationInput = { toolName: string; args: Record; @@ -86,16 +53,7 @@ export const CHATGPT_DIRECTORY_PROFILE = { throw error; } }, - widgetTargets: { - "create-deck": (args: Record, result: unknown) => { - const deckId = id(record(result).id, args.deckId); - return deckId ? deckWidgetTarget(deckId) : null; - }, - "add-slide": (args: Record, result: unknown) => { - const deckId = id(args.deckId, record(result).deckId); - return deckId ? deckWidgetTarget(deckId) : null; - }, - }, + widgetTargets: {}, widgetReadActionArguments: { // The ticketed get-deck path normalizes duplicate IDs in memory only. "get-deck": { id: "deckId", deckId: "deckId" }, From b41ef526878fe9bfaa5439a80e2eec98896a3a5f Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:54:48 -0400 Subject: [PATCH 08/15] Update Slides 2 profile tests for full-app links --- .../lib/chatgpt-directory-tools.spec.ts | 52 ++++--------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts b/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts index 01d5bdf379f..f8d39c8aba5 100644 --- a/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts +++ b/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts @@ -200,25 +200,6 @@ describe("Slides ChatGPT directory widget share grant", () => { mocks.getRequestUserEmail.mockReturnValue("reviewer@example.com"); }); - it("binds the literal deck type and id on every widget target", () => { - const { widgetTargets } = CHATGPT_DIRECTORY_PROFILE; - - expect(widgetTargets["create-deck"]({}, { id: "deck-a" })).toEqual( - target("deck-a"), - ); - expect(widgetTargets["create-deck"]({ deckId: "deck-a" }, {})).toEqual( - target("deck-a"), - ); - expect(widgetTargets["add-slide"]({ deckId: "deck-a" }, {})).toEqual( - target("deck-a"), - ); - expect(widgetTargets["add-slide"]({}, { deckId: "deck-a" })).toEqual( - target("deck-a"), - ); - expect(widgetTargets["create-deck"]({}, {})).toBeNull(); - expect(widgetTargets["add-slide"]({}, {})).toBeNull(); - }); - it("limits the Share popover grant to its four actions and their arguments", () => { const { widgetReadActionArguments, @@ -270,43 +251,28 @@ describe("Slides ChatGPT directory widget share grant", () => { }); }); - it("resolves every resource rule from a target key and every target action from the allowlist", () => { - const { widgetTargets, widgetWriteActionArguments } = - CHATGPT_DIRECTORY_PROFILE; - const targets = [ - widgetTargets["create-deck"]({}, { id: "deck-a" }), - widgetTargets["add-slide"]({ deckId: "deck-a" }, {}), - ]; + it("resolves stored widget rules against a bounded deck fixture", () => { + const { widgetWriteActionArguments } = CHATGPT_DIRECTORY_PROFILE; + const widgetTarget = target("deck-a"); const rules = [ ...Object.values(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments), ...Object.values(widgetWriteActionArguments), ].flatMap((argumentMap) => Object.values(argumentMap)); - for (const widgetTarget of targets) { - expect(widgetTarget).not.toBeNull(); - for (const rule of rules) { - if (typeof rule === "string") { - expect(widgetTarget!.resourceIds).toHaveProperty(rule); - } - } - for (const action of widgetTarget!.writeActions) { - expect(widgetWriteActionArguments).toHaveProperty(action); + for (const rule of rules) { + if (typeof rule === "string") { + expect(widgetTarget.resourceIds).toHaveProperty(rule); } } + for (const action of widgetTarget.writeActions) { + expect(widgetWriteActionArguments).toHaveProperty(action); + } }); it("does not grant access requests, groups, agent links, or deck deletion", () => { const granted = new Set([ ...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments), ...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetWriteActionArguments), - ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["create-deck"]( - {}, - { id: "deck-a" }, - )!.writeActions, - ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["add-slide"]( - { deckId: "deck-a" }, - {}, - )!.writeActions, ]); for (const action of [ From a7951f17ec55f38890f6805c21c7553ea3682165 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:54:51 -0400 Subject: [PATCH 09/15] Update Slides profile tests for full-app links --- .../lib/chatgpt-directory-tools.spec.ts | 52 ++++--------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/templates/slides/server/lib/chatgpt-directory-tools.spec.ts b/templates/slides/server/lib/chatgpt-directory-tools.spec.ts index a3559cfbe97..09d619f3db5 100644 --- a/templates/slides/server/lib/chatgpt-directory-tools.spec.ts +++ b/templates/slides/server/lib/chatgpt-directory-tools.spec.ts @@ -128,25 +128,6 @@ describe("Slides ChatGPT directory widget share grant", () => { mocks.getRequestUserEmail.mockReturnValue("reviewer@example.com"); }); - it("binds the literal deck type and id on every widget target", () => { - const { widgetTargets } = CHATGPT_DIRECTORY_PROFILE; - - expect(widgetTargets["create-deck"]({}, { id: "deck-a" })).toEqual( - target("deck-a"), - ); - expect(widgetTargets["create-deck"]({ deckId: "deck-a" }, {})).toEqual( - target("deck-a"), - ); - expect(widgetTargets["add-slide"]({ deckId: "deck-a" }, {})).toEqual( - target("deck-a"), - ); - expect(widgetTargets["add-slide"]({}, { deckId: "deck-a" })).toEqual( - target("deck-a"), - ); - expect(widgetTargets["create-deck"]({}, {})).toBeNull(); - expect(widgetTargets["add-slide"]({}, {})).toBeNull(); - }); - it("limits the Share popover grant to its four actions and their arguments", () => { const { widgetReadActionArguments, @@ -198,43 +179,28 @@ describe("Slides ChatGPT directory widget share grant", () => { }); }); - it("resolves every resource rule from a target key and every target action from the allowlist", () => { - const { widgetTargets, widgetWriteActionArguments } = - CHATGPT_DIRECTORY_PROFILE; - const targets = [ - widgetTargets["create-deck"]({}, { id: "deck-a" }), - widgetTargets["add-slide"]({ deckId: "deck-a" }, {}), - ]; + it("resolves stored widget rules against a bounded deck fixture", () => { + const { widgetWriteActionArguments } = CHATGPT_DIRECTORY_PROFILE; + const widgetTarget = target("deck-a"); const rules = [ ...Object.values(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments), ...Object.values(widgetWriteActionArguments), ].flatMap((argumentMap) => Object.values(argumentMap)); - for (const widgetTarget of targets) { - expect(widgetTarget).not.toBeNull(); - for (const rule of rules) { - if (typeof rule === "string") { - expect(widgetTarget!.resourceIds).toHaveProperty(rule); - } - } - for (const action of widgetTarget!.writeActions) { - expect(widgetWriteActionArguments).toHaveProperty(action); + for (const rule of rules) { + if (typeof rule === "string") { + expect(widgetTarget.resourceIds).toHaveProperty(rule); } } + for (const action of widgetTarget.writeActions) { + expect(widgetWriteActionArguments).toHaveProperty(action); + } }); it("does not grant access requests, groups, agent links, or deck deletion", () => { const granted = new Set([ ...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments), ...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetWriteActionArguments), - ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["create-deck"]( - {}, - { id: "deck-a" }, - )!.writeActions, - ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["add-slide"]( - { deckId: "deck-a" }, - {}, - )!.writeActions, ]); for (const action of [ From 8399ce6147a42bbf059c82771fbd9e022216125b Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:17:07 -0400 Subject: [PATCH 10/15] Preserve mounted embed selectors and scoped test fixtures --- packages/core/src/mcp/server.spec.ts | 22 ++++++------ .../core/src/server/action-routes.spec.ts | 5 ++- .../core/src/server/embed-session.spec.ts | 34 ++++++++++++++----- packages/core/src/server/embed-session.ts | 9 +++-- .../src/sharing/widget-share-grant.spec.ts | 8 +++-- ...22-open-decks-in-the-full-app-from-chat.md | 1 + ...22-open-decks-in-the-full-app-from-chat.md | 1 + 7 files changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index 93ca72ae0c4..acaec2e1d6d 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -864,12 +864,6 @@ async function mcpAppsAuthHeaders( } const directoryWidgetTemplates = [ - { - appId: "slides", - profile: slidesDirectoryProfile, - toolName: "create-deck", - result: { id: "deck-1" }, - }, { appId: "design", profile: designDirectoryProfile, @@ -1219,7 +1213,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", { appId: "content", profile: contentDirectoryProfile }, ] as const; const expectedWidgetToolNames = { - slides: ["add-slide", "create-deck"], + slides: [], design: [ "create-design", "create-design-from-template", @@ -1481,7 +1475,9 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", expect(init.body?.result?.protocolVersion).toBe(protocolVersion); expect(init.body?.result?.instructions).toBe(profile.instructions); expect(init.body?.result?.capabilities?.prompts).toBeUndefined(); - expect(resourcesSupported).toBe(profile.widgets !== false); + expect(resourcesSupported).toBe( + expectedWidgetToolNames[appId].length > 0, + ); const modelVisibleTools = listedTools.filter((tool: any) => { const visibility = tool._meta?.ui?.visibility; return !( @@ -1544,7 +1540,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", directiveCopyPattern.test(entry.text), ); expect(directiveCopy).toEqual([]); - if (profile.widgets === false) { + if (expectedWidgetToolNames[appId].length === 0) { expect(linkedUris).toEqual([]); expect(JSON.stringify(listedTools)).not.toMatch( /ui:\/\/|openai\/(?:ui|outputTemplate|widget)/, @@ -3357,7 +3353,13 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", widgetDomain: "https://slides.agent-native.com", authorizeWidgetWrite, widgetTargets: { - "create-deck": slidesDirectoryProfile.widgetTargets["create-deck"], + "create-deck": () => ({ + targetPath: "/deck/deck-a", + resourceIds: { deckId: "deck-a", resourceType: "deck" }, + writeActions: Object.keys( + slidesDirectoryProfile.widgetWriteActionArguments, + ), + }), }, widgetReadActionArguments: slidesDirectoryProfile.widgetReadActionArguments, diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 097465737f1..45d343eeda7 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -888,6 +888,7 @@ describe("mountActionRoutes", () => { }, }; event.req.headers = new Headers(event._headers); + event.url = new URL(event.req.url); if (credential === "live-capability") { await expect(mounted[0]!.handler(event)).resolves.toMatchObject({ errorCode: "approval_required", @@ -4033,7 +4034,9 @@ describe("mountActionRoutes", () => { "unshare-resource", "set-resource-visibility", ]; - const target = profile.widgetTargets["create-deck"]({}, { id: "deck-a" })!; + const target = { + resourceIds: { deckId: "deck-a", resourceType: "deck" }, + }; const materialize = (rules: Record) => Object.fromEntries( Object.entries(rules).map(([argumentName, rule]) => [ diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index bee48aad140..5666bd55224 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -2178,9 +2178,7 @@ describe("requestMatchesEmbedTarget", () => { it("uses the browser URL, not the mounted handler path, for framework routes", () => { const event = fakeEvent("/"); event.context = { _mountedPathname: "/_agent-native/open" }; - event.url = { - search: "?app=mail&view=inbox&embedded=1&__an_embed_token=tok", - }; + event.url.search = "?app=mail&view=inbox&embedded=1&__an_embed_token=tok"; expect( requestMatchesEmbedTarget( @@ -2190,6 +2188,25 @@ describe("requestMatchesEmbedTarget", () => { ).toBe(true); }); + it.each([false, true])( + "keeps signed open selectors at a base path (handler-relative URL: %s)", + (handlerRelative) => { + process.env.APP_BASE_PATH = "/mail"; + const event = fakeEvent("/mail/_agent-native/open?view=editor"); + if (handlerRelative) { + event.context = { _mountedPathname: "/mail/_agent-native/open" }; + event.url = new URL("http://mail.test/?view=editor"); + } + const target = + "/_agent-native/open?app=content&view=editor&documentId=doc-1"; + expect(requestMatchesEmbedTarget(event, target)).toBe(false); + event.url.search = "?app=content&view=editor&documentId=doc-1"; + expect(requestMatchesEmbedTarget(event, target)).toBe(true); + event.url.search = "?app=content&view=editor&documentId=doc-2"; + expect(requestMatchesEmbedTarget(event, target)).toBe(false); + }, + ); + it("rejects unrelated page routes for the same token", () => { expect( requestMatchesEmbedTarget( @@ -3323,11 +3340,10 @@ describe("directory widget session token size", () => { email: string; orgId?: string; }) { - const target = slidesProfile.widgetTargets["create-deck"]( - {}, - { id: deckId }, - ); - if (!target) throw new Error("Slides create-deck target is missing."); + const target = { + resourceIds: { deckId, resourceType: "deck" }, + writeActions: Object.keys(slidesProfile.widgetWriteActionArguments), + }; const materialize = ( argumentMaps: Record>, only?: readonly string[], @@ -3376,7 +3392,7 @@ describe("directory widget session token size", () => { }; } - it("fits the full Slides grant in one cookie for a realistic deck, user and org", () => { + it("fits a representative deck grant in one cookie for a realistic user and org", () => { const { scope, token } = slidesWidgetToken({ deckId: "deck-V1StGXR8_Z5jdHi6B-myT-cd1", email: "taylor.reviewer@example-company.com", diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index 0122955af72..3a95efab2ad 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -757,10 +757,14 @@ function isEmbedDocumentPath(pathname: string): boolean { ); } -function matchesEmbedTargetQuery(current: URL, target: URL): boolean { +function matchesEmbedTargetQuery( + current: URL, + target: URL, + currentPathname: string, +): boolean { const expected = new URLSearchParams(target.search); const redirectedOpen = - target.pathname === OPEN_ROUTE_PATH && current.pathname !== OPEN_ROUTE_PATH; + target.pathname === OPEN_ROUTE_PATH && currentPathname !== OPEN_ROUTE_PATH; if (redirectedOpen) { const to = normalizeEmbedTargetPath(expected.get("to")); if (to) { @@ -805,6 +809,7 @@ export function requestMatchesEmbedTarget( return matchesEmbedTargetQuery( current, new URL(target, "http://agent-native.invalid"), + pathname, ); } diff --git a/packages/core/src/sharing/widget-share-grant.spec.ts b/packages/core/src/sharing/widget-share-grant.spec.ts index eac48d1b970..78a5b24177f 100644 --- a/packages/core/src/sharing/widget-share-grant.spec.ts +++ b/packages/core/src/sharing/widget-share-grant.spec.ts @@ -94,10 +94,12 @@ afterEach(async () => { await pglite.close(); }); -// Mints the write grant the Slides profile gives its deck editors (the server -// does this for anyone authorizeWidgetWrite accepts: editor access or above). +// A bounded deck grant exercises sharing independently of card availability. function mintGrant(userEmail: string) { - const target = slidesProfile.widgetTargets["create-deck"]({}, { id: deckA })!; + const target = { + resourceIds: { deckId: deckA, resourceType: "deck" }, + writeActions: Object.keys(slidesProfile.widgetWriteActionArguments), + }; const materialize = (rules: Record) => Object.fromEntries( Object.entries(rules).map(([name, rule]) => [ diff --git a/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md index 794448856eb..3f4006013c5 100644 --- a/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md +++ b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md @@ -2,4 +2,5 @@ type: fixed date: 2026-10-22 --- + New decks and added slides open in the full Slides 2 app from their chat links. diff --git a/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md index e35d2ee78bc..84e27a8fe80 100644 --- a/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md +++ b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md @@ -2,4 +2,5 @@ type: fixed date: 2026-10-22 --- + New decks and added slides open in the full Slides app from their chat links. From a2086caea3a7c969ed072e7403a3021545ba405e Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:36:02 -0400 Subject: [PATCH 11/15] Preserve scoped Visual Edit access and redirect selectors --- .changeset/tidy-embed-session-scope.md | 2 + .../core/src/server/action-routes.spec.ts | 523 ++++++++++------ packages/core/src/server/action-routes.ts | 111 ++-- .../core/src/server/embed-session.spec.ts | 49 +- packages/core/src/server/embed-session.ts | 31 +- templates/design/actions/get-design.test.ts | 53 +- templates/design/actions/get-design.ts | 24 +- .../commands/save-file-content.ts | 34 ++ .../domains/use-editor-canvas-and-screens.ts | 2 + ...ion-and-access.source-persistence.test.tsx | 578 ++++++++++++++++++ .../use-editor-generation-and-access.ts | 40 +- .../use-editor-live-edits-and-presence.ts | 32 +- .../app/pages/design-editor/editor-state.ts | 6 + .../effects/publish-active-source-content.ts | 55 ++ .../design/app/pages/design-editor/types.ts | 1 + ...p-their-editing-access-after-sign-in-wi.md | 6 + 16 files changed, 1253 insertions(+), 294 deletions(-) create mode 100644 templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx create mode 100644 templates/design/app/pages/design-editor/effects/publish-active-source-content.ts create mode 100644 templates/design/changelog/2026-10-24-visual-edit-links-keep-their-editing-access-after-sign-in-wi.md diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md index cc764ec3484..7ee6c9313a2 100644 --- a/.changeset/tidy-embed-session-scope.md +++ b/.changeset/tidy-embed-session-scope.md @@ -9,3 +9,5 @@ Apps with a custom `AuthOptions.getSession` resolver must also configure the coo MCP Apps embeds behind a proxy require the public origin configured through `APP_URL` or the workspace gateway URL unless the proxy connects over loopback. Use the embed origin consistently for widget CSP and keep ordinary sign-in session lookup independent of embed origin validation. + +Keep Visual Edit capabilities on their declared action set and automatic source synchronization on the signed-in user's own write access. diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 45d343eeda7..c7e75cb27a4 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -134,6 +134,8 @@ vi.mock("./auth.js", () => ({ isLoopbackRequest: () => false, })); vi.mock("./embed-session.js", () => ({ + hasLiveEmbedCapabilityCookie: (event: any) => + event._hasLiveEmbedCapabilityCookie ?? false, hasPresentedEmbedSessionCredential: (event: any) => mockHasPresentedEmbedSessionCredential(event), isExpiredMcpDirectoryWidgetSessionRequest: (...args: unknown[]) => @@ -2037,7 +2039,127 @@ describe("mountActionRoutes", () => { }, ); - it("does not let a capability satisfy account-backed write action auth", async () => { + it.each( + [ + { + credential: "capability-only", + browserUser: undefined, + scope: "visual-edit", + }, + { + credential: "signed-in plus capability", + browserUser: "browser@example.com", + scope: "visual-edit", + }, + { + credential: "MCP card", + browserUser: "browser@example.com", + scope: "card", + }, + { + credential: "plain signed-in", + browserUser: "browser@example.com", + scope: undefined, + }, + ].flatMap((credential) => + [ + { inside: true, requiresAuth: true }, + { inside: false, requiresAuth: true }, + { inside: false, requiresAuth: false }, + ].map((action) => ({ ...credential, ...action })), + ), + )( + "keeps $credential authority independent (Visual Edit: $inside, account required: $requiresAuth)", + async ({ browserUser, scope, inside, requiresAuth }) => { + const { createMcpDirectoryWidgetReadCapability } = + await import("../shared/embed-auth.js"); + const { mountActionRoutes } = await import("./action-routes.js"); + const { getRequestAuthCapability } = await import("./request-context.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const visualScope = "capability:visual-edit:design:design_1"; + const resolvedScope = + scope === "card" + ? createMcpDirectoryWidgetReadCapability({ + appId: "design", + resourceUri: "ui://design/card", + resourceIds: { designId: "design_1" }, + actionArguments: { "get-design": { id: "design_1" } }, + }) + : scope + ? visualScope + : undefined; + mockResolveEmbedSessionFromRequest.mockResolvedValue( + resolvedScope + ? { + email: "link-owner@example.com", + token: "signed-capability", + targetPath: "/visual-edit/design_1", + scope: resolvedScope, + } + : null, + ); + const unauthenticated = Object.assign(new Error("Unauthenticated"), { + statusCode: 401, + }); + const run = vi.fn(async (_args, ctx) => ({ + userEmail: ctx?.userEmail, + authCapability: getRequestAuthCapability(), + })); + const name = inside ? "update-screen-source" : "future-visual-action"; + mountActionRoutes( + { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }, + { + [name]: { + http: { method: "POST" }, + requiresAuth, + capabilityScopes: ["visual-edit"], + run, + } as any, + }, + { + getOwnerFromEvent: async () => { + if (browserUser) return browserUser; + throw unauthenticated; + }, + }, + ); + const event = { + _method: "POST", + _headers: { + "x-agent-native-frontend": "1", + ...(browserUser + ? { cookie: "better-auth.session_token=browser-session" } + : {}), + }, + req: { + url: `http://app.test/_agent-native/actions/${name}`, + json: async () => (inside ? { designId: "design_1" } : {}), + }, + }; + if (scope === "card") { + expect(await mounted[0].handler(event)).toEqual({ + error: "This widget capability only permits its scoped data routes.", + }); + expect(event).toMatchObject({ _status: 403 }); + } else if (!inside && !browserUser && requiresAuth) { + await expect(mounted[0].handler(event)).rejects.toBe(unauthenticated); + } else { + expect(await mounted[0].handler(event)).toEqual({ + userEmail: browserUser, + authCapability: inside && scope ? visualScope : undefined, + }); + expect(run).toHaveBeenCalledOnce(); + return; + } + expect(run).not.toHaveBeenCalled(); + }, + ); + + it("refuses update-file from a capability-only client that ignores its persistence hint", async () => { const { mountActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; const run = vi.fn(async () => ({ ok: true })); @@ -2058,7 +2180,7 @@ describe("mountActionRoutes", () => { ), }, { - "update-design": { + "update-file": { http: { method: "POST" }, requiresAuth: true, run, @@ -2074,9 +2196,14 @@ describe("mountActionRoutes", () => { await expect( mounted[0]!.handler({ _method: "POST", + _headers: { "x-agent-native-frontend": "1" }, req: { - url: "http://app.test/_agent-native/actions/update-design", - json: async () => ({ id: "design_1" }), + url: "http://app.test/_agent-native/actions/update-file", + json: async () => ({ + id: "file_1", + filename: "renamed.html", + canPersistSource: true, + }), }, }), ).rejects.toBe(unauthenticated); @@ -7711,206 +7838,218 @@ describe("mountWebMcpActionRoutes", () => { expect(privateRun).not.toHaveBeenCalled(); }); - it("filters signed-out WebMCP actions to the matching capability scope", async () => { - const { mountWebMcpActionRoutes } = await import("./action-routes.js"); - const mounted: Array<{ path: string; handler: any }> = []; - const nitroApp = { - use: vi.fn((path: string, handler: any) => - mounted.push({ path, handler }), - ), - }; - mockResolveEmbedSessionFromRequest.mockResolvedValue({ - email: "ticket-owner@example.com", - token: "signed-capability", - targetPath: "/visual-edit/design_1", - scope: "capability:visual-edit:design:design_1", - }); - const unauthenticated = Object.assign(new Error("Unauthorized"), { - statusCode: 401, - }); + it.each([false, true])( + "filters Visual Edit metadata with browser identity: %s", + async (signedIn) => { + const { mountWebMcpActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + mockResolveEmbedSessionFromRequest.mockResolvedValue({ + email: "ticket-owner@example.com", + token: "signed-capability", + targetPath: "/visual-edit/design_1", + scope: "capability:visual-edit:design:design_1", + }); + const unauthenticated = Object.assign(new Error("Unauthorized"), { + statusCode: 401, + }); - mountWebMcpActionRoutes( - nitroApp, - { - "visual-edit": { - tool: { description: "Visual edit", parameters: {} }, - run: vi.fn(), - capabilityScopes: ["visual-edit"], - } as any, - "other-capability": { - tool: { description: "Other capability", parameters: {} }, - run: vi.fn(), - capabilityScopes: ["other"], - } as any, - }, - { - getOwnerFromEvent: async () => { - throw unauthenticated; + mountWebMcpActionRoutes( + nitroApp, + { + "update-screen-source": { + tool: { description: "Visual edit", parameters: {} }, + run: vi.fn(), + capabilityScopes: ["visual-edit"], + } as any, + "other-capability": { + tool: { description: "Other capability", parameters: {} }, + run: vi.fn(), + capabilityScopes: ["other"], + } as any, + "future-visual-action": { + tool: { description: "Unlisted action", parameters: {} }, + run: vi.fn(), + capabilityScopes: ["visual-edit"], + } as any, }, - }, - ); + { + getOwnerFromEvent: async () => { + if (signedIn) return "browser@example.com"; + throw unauthenticated; + }, + }, + ); - const manifestRoute = mounted.find( - ({ path }) => path === "/_agent-native/webmcp/manifest", - ); - await expect( - manifestRoute?.handler({ _method: "GET", _headers: {} }), - ).resolves.toEqual([ - { - name: "visual-edit", - title: "Visual edit", - description: "Visual edit", - inputSchema: {}, - readOnly: false, - }, - ]); + const manifestRoute = mounted.find( + ({ path }) => path === "/_agent-native/webmcp/manifest", + ); + await expect( + manifestRoute?.handler({ _method: "GET", _headers: {} }), + ).resolves.toEqual([ + { + name: "update-screen-source", + title: "Update screen source", + description: "Visual edit", + inputSchema: {}, + readOnly: false, + }, + ]); - const otherRoute = mounted.find( - ({ path }) => path === "/_agent-native/webmcp/actions/other-capability", - ); - await expect( - otherRoute?.handler({ - _method: "POST", - _headers: {}, - req: { json: async () => ({}) }, - }), - ).rejects.toMatchObject({ statusCode: 401 }); - }); + const otherRoute = mounted.find( + ({ path }) => path === "/_agent-native/webmcp/actions/other-capability", + ); + if (!signedIn) + await expect( + otherRoute?.handler({ + _method: "POST", + _headers: {}, + req: { json: async () => ({}) }, + }), + ).rejects.toMatchObject({ statusCode: 401 }); + }, + ); - it("keeps directory-widget read manifests and WebMCP calls scoped when a session cookie is present", async () => { - const { createMcpDirectoryWidgetReadCapability } = - await import("../shared/embed-auth.js"); - const { mountWebMcpActionRoutes } = await import("./action-routes.js"); - const mounted: Array<{ path: string; handler: any }> = []; - const run = vi.fn(async (_args, context) => ({ - caller: context.caller, - userEmail: context.userEmail, - mcpDirectoryWidgetReadOnly: context.mcpDirectoryWidgetReadOnly, - })); - const runUnscopedPublicRead = vi.fn(); - const capability = createMcpDirectoryWidgetReadCapability({ - appId: "design", - resourceUri: "ui://design/shell-v69", - resourceIds: { designId: "d1" }, - actionArguments: { "get-design": { id: "d1" } }, - })!; - mockResolveEmbedSessionFromRequest.mockResolvedValue({ - email: "ticket-owner@example.com", - token: "signed-directory-capability", - targetPath: "/design/d1", - scope: capability, - }); - const nitroApp = { - use: vi.fn((path: string, handler: any) => - mounted.push({ path, handler }), - ), - }; + it.each([false, true])( + "refuses card metadata but keeps scoped reads (ineligible cookie: %s)", + async (ineligibleCookie) => { + const { createMcpDirectoryWidgetReadCapability } = + await import("../shared/embed-auth.js"); + const { mountWebMcpActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(async (_args, context) => ({ + caller: context.caller, + userEmail: context.userEmail, + mcpDirectoryWidgetReadOnly: context.mcpDirectoryWidgetReadOnly, + })); + const runUnscopedPublicRead = vi.fn(); + const capability = createMcpDirectoryWidgetReadCapability({ + appId: "design", + resourceUri: "ui://design/shell-v69", + resourceIds: { designId: "d1" }, + actionArguments: { "get-design": { id: "d1" } }, + })!; + mockResolveEmbedSessionFromRequest.mockResolvedValue({ + email: "ticket-owner@example.com", + token: "signed-directory-capability", + targetPath: "/design/d1", + scope: capability, + }); + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; - mountWebMcpActionRoutes( - nitroApp, - { - "get-design": { - tool: { description: "Read this design", parameters: {} }, - run, - http: { method: "GET" }, - readOnly: true, - } as any, - "update-design": { - tool: { description: "Update a design", parameters: {} }, - run: vi.fn(), - http: { method: "POST" }, - readOnly: false, - } as any, - "get-other-design": { - tool: { description: "Read another design", parameters: {} }, - run: vi.fn(), - http: { method: "GET" }, - readOnly: true, - } as any, - "search-public-docs": { - tool: { description: "Search public docs", parameters: {} }, - run: runUnscopedPublicRead, - http: { method: "GET" }, - requiresAuth: false, - readOnly: true, - publicAgent: { - expose: true, + mountWebMcpActionRoutes( + nitroApp, + { + "get-design": { + tool: { description: "Read this design", parameters: {} }, + run, + http: { method: "GET" }, + readOnly: true, + } as any, + "update-design": { + tool: { description: "Update a design", parameters: {} }, + run: vi.fn(), + http: { method: "POST" }, + readOnly: false, + } as any, + "get-other-design": { + tool: { description: "Read another design", parameters: {} }, + run: vi.fn(), + http: { method: "GET" }, readOnly: true, + } as any, + "search-public-docs": { + tool: { description: "Search public docs", parameters: {} }, + run: runUnscopedPublicRead, + http: { method: "GET" }, requiresAuth: false, - }, - } as any, - }, - { - getOwnerContextFromEvent: async () => ({ - owner: "cookie-owner@example.com", - anonymous: false, - }), - mcpDirectoryWidgetReadActionArguments: { "get-design": ["id"] }, - mcpDirectoryWidgetReadOnlyActions: ["get-design"], - mcpDirectoryWidgetAppId: "design", - mcpDirectoryWidgetResourceUri: "ui://design/shell-v69", - }, - ); + readOnly: true, + publicAgent: { + expose: true, + readOnly: true, + requiresAuth: false, + }, + } as any, + }, + { + getOwnerContextFromEvent: async () => ({ + owner: "cookie-owner@example.com", + anonymous: false, + }), + mcpDirectoryWidgetReadActionArguments: { "get-design": ["id"] }, + mcpDirectoryWidgetReadOnlyActions: ["get-design"], + mcpDirectoryWidgetAppId: "design", + mcpDirectoryWidgetResourceUri: "ui://design/shell-v69", + }, + ); - const manifestRoute = mounted.find( - ({ path }) => path === "/_agent-native/webmcp/manifest", - ); - const webMcpRoute = mounted.find( - ({ path }) => path === "/_agent-native/webmcp/actions/get-design", - ); - const unscopedPublicRoute = mounted.find( - ({ path }) => path === "/_agent-native/webmcp/actions/search-public-docs", - ); - const mcpToolRoute = mounted.find( - ({ path }) => path === "/mcp/tool/get-design", - ); - await expect( - manifestRoute?.handler({ _method: "GET", _headers: {} }), - ).resolves.toEqual([ - { - name: "get-design", - title: "Get design", - description: "Read this design", - inputSchema: {}, - readOnly: true, - }, - ]); + const manifestRoute = mounted.find( + ({ path }) => path === "/_agent-native/webmcp/manifest", + ); + const webMcpRoute = mounted.find( + ({ path }) => path === "/_agent-native/webmcp/actions/get-design", + ); + const unscopedPublicRoute = mounted.find( + ({ path }) => + path === "/_agent-native/webmcp/actions/search-public-docs", + ); + const mcpToolRoute = mounted.find( + ({ path }) => path === "/mcp/tool/get-design", + ); + if (ineligibleCookie) + mockResolveEmbedSessionFromRequest.mockResolvedValueOnce(null); + await expect( + manifestRoute?.handler({ + _method: "GET", + _headers: {}, + _hasLiveEmbedCapabilityCookie: true, + }), + ).rejects.toMatchObject({ statusCode: 401 }); - await expect( - webMcpRoute?.handler({ - _method: "POST", - _headers: {}, - req: { json: async () => ({ id: "d1" }) }, - }), - ).resolves.toEqual({ - caller: "mcp-widget", - userEmail: "ticket-owner@example.com", - mcpDirectoryWidgetReadOnly: true, - }); - expect(run).toHaveBeenCalledOnce(); + await expect( + webMcpRoute?.handler({ + _method: "POST", + _headers: {}, + req: { json: async () => ({ id: "d1" }) }, + }), + ).resolves.toEqual({ + caller: "mcp-widget", + userEmail: "ticket-owner@example.com", + mcpDirectoryWidgetReadOnly: true, + }); + expect(run).toHaveBeenCalledOnce(); - await expect( - mcpToolRoute?.handler({ - _method: "POST", - _headers: {}, - req: { json: async () => ({ id: "d1" }) }, - }), - ).resolves.toEqual({ - error: "This widget capability only permits its scoped data routes.", - }); - expect(run).toHaveBeenCalledOnce(); + await expect( + mcpToolRoute?.handler({ + _method: "POST", + _headers: {}, + req: { json: async () => ({ id: "d1" }) }, + }), + ).resolves.toEqual({ + error: "This widget capability only permits its scoped data routes.", + }); + expect(run).toHaveBeenCalledOnce(); - await expect( - unscopedPublicRoute?.handler({ - _method: "POST", - _headers: {}, - req: { json: async () => ({}) }, - }), - ).resolves.toEqual({ - error: "This widget capability only permits its scoped data routes.", - }); - expect(runUnscopedPublicRead).not.toHaveBeenCalled(); - }); + await expect( + unscopedPublicRoute?.handler({ + _method: "POST", + _headers: {}, + req: { json: async () => ({}) }, + }), + ).resolves.toEqual({ + error: "This widget capability only permits its scoped data routes.", + }); + expect(runUnscopedPublicRead).not.toHaveBeenCalled(); + }, + ); it("does not let a bootstrap capability match ordinary visual-edit actions", async () => { const { mountWebMcpActionRoutes } = await import("./action-routes.js"); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index 6e678d268d9..fdaa9de8239 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -78,7 +78,6 @@ import { getMcpDirectoryWidgetWriteCapabilityGrant, isExpiredMcpDirectoryWidgetWriteCapability, isMcpDirectoryWidgetCapabilityScope, - isMcpDirectoryWidgetReadCapabilityScope, isMcpDirectoryWidgetWriteCapabilityScope, normalizeMcpDirectoryWidgetReadActionArguments, normalizeMcpDirectoryWidgetWriteActionArguments, @@ -112,6 +111,7 @@ import { readCorsAllowedOrigins, } from "./cors-origins.js"; import { + hasLiveEmbedCapabilityCookie, hasPresentedEmbedSessionCredential, isExpiredMcpDirectoryWidgetSessionRequest, resolveEmbedSessionFromRequest, @@ -577,10 +577,41 @@ function isPublicWebMcpAction(entry: ActionEntry): boolean { ); } +const VISUAL_EDIT_CAPABILITY_ACTIONS = new Set([ + "acknowledge-visual-edit-pending", + "add-breakpoint", + "add-localhost-screens", + "clear-localhost-write-consent-request", + "get-design", + "get-design-snapshot", + "get-localhost-write-consent-request", + "get-visual-edit-pending", + "get-visual-edit-snapshot", + "grant-localhost-write-consent", + "list-design-source-capabilities", + "list-design-states", + "list-files", + "list-local-files", + "list-localhost-connections", + "migrate-board-objects-to-file", + "publish-visual-edit-pending", + "read-local-file", + "refresh-localhost-preview-token", + "remove-breakpoint", + "request-localhost-write-consent", + "update-breakpoint", + "update-screen-source", +]); + function allowsWebMcpCapability( + name: string, entry: ActionEntry, authCapability: string | undefined, ): boolean { + if (authCapability?.startsWith("capability:visual-edit:")) { + if (!VISUAL_EDIT_CAPABILITY_ACTIONS.has(name)) return false; + if (name === "get-design") return true; + } if (!authCapability || !Array.isArray(entry.capabilityScopes)) return false; return entry.capabilityScopes.some( (scope) => @@ -590,32 +621,6 @@ function allowsWebMcpCapability( ); } -function allowsMcpDirectoryWidgetReadManifestAction( - name: string, - entry: ActionEntry, - authCapability: string | undefined, - options: MountWebMcpActionRoutesOptions | undefined, -): boolean { - const allowedArgumentNames = - options?.mcpDirectoryWidgetReadActionArguments?.[name]; - return ( - entry.http !== false && - entry.http?.method === "GET" && - (entry.readOnly === true || - options?.mcpDirectoryWidgetReadOnlyActions?.includes(name) === true) && - (entry.requiresAuth !== false || - options?.mcpDirectoryWidgetReadPublicActions?.includes(name) === true) && - allowedArgumentNames !== undefined && - allowsMcpDirectoryWidgetReadAction(authCapability, { - actionName: name, - appId: options?.mcpDirectoryWidgetAppId ?? options?.appId, - resourceUri: options?.mcpDirectoryWidgetResourceUri, - allowedArgumentNames, - requireArgumentMatch: false, - }) - ); -} - function allowsWebMcpCapabilityResource( actionName: string, authCapability: string | undefined, @@ -634,10 +639,18 @@ function allowsWebMcpCapabilityResource( } } -async function resolveRequestAuthCapability( +async function resolveWebMcpManifestCapability( event: any, ): Promise { - return resolvedEmbedCapabilityScope(await resolveRequestEmbedSession(event)); + const session = await resolveRequestEmbedSession(event); + const capability = resolvedEmbedCapabilityScope(session); + if ( + isMcpDirectoryWidgetCapabilityScope(capability) || + (!session && hasLiveEmbedCapabilityCookie(event)) + ) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } + return capability; } async function resolveRequestEmbedSession( @@ -846,7 +859,12 @@ function mountActionRoutesInternal( let userName: string | undefined; let authUserId: string | undefined; const embedSession = await resolveRequestEmbedSession(event); - const authCapability = resolvedEmbedCapabilityScope(embedSession); + const presentedCapability = resolvedEmbedCapabilityScope(embedSession); + const authCapability = + presentedCapability?.startsWith("capability:visual-edit:") && + !allowsWebMcpCapability(name, entry, presentedCapability) + ? undefined + : presentedCapability; const embedded = Boolean(embedSession) || hasPresentedEmbedSessionCredential(event); const directoryWidgetWriteCapability = @@ -950,7 +968,7 @@ function mountActionRoutesInternal( let directoryWidgetReadAuthenticatedFallback = false; const capabilityAllowed = (options?.caller === "webmcp" || isFrontendActionRequest(event)) && - (allowsWebMcpCapability(entry, authCapability) || + (allowsWebMcpCapability(name, entry, authCapability) || directoryWidgetReadAllowed || directoryWidgetWriteAllowed); if ( @@ -1899,12 +1917,6 @@ export function mountWebMcpActionRoutes( const publicEligible = Object.fromEntries( Object.entries(eligible).filter(([, entry]) => isPublicWebMcpAction(entry)), ); - const capabilityEligible = Object.fromEntries( - Object.entries(eligible).filter(([, entry]) => - Array.isArray(entry.capabilityScopes), - ), - ); - const app = getH3App(nitroApp); const actionRoutePrefixes = [WEBMCP_ACTION_ROUTE_PREFIX, "/mcp/tool"]; const actionRoutePaths = actionRoutePrefixes.flatMap((routePrefix) => @@ -1958,39 +1970,24 @@ export function mountWebMcpActionRoutes( if (!isAuthResolutionFailure(error)) throw error; } } - const authCapability = await resolveRequestAuthCapability(event); - const directoryWidgetReadCapability = - isMcpDirectoryWidgetReadCapabilityScope(authCapability); + const authCapability = await resolveWebMcpManifestCapability(event); const visibleCapabilityActions = authCapability - ? Object.fromEntries( - Object.entries(capabilityEligible).filter(([, entry]) => - allowsWebMcpCapability(entry, authCapability), - ), - ) - : {}; - const visibleDirectoryWidgetReadActions = directoryWidgetReadCapability ? Object.fromEntries( Object.entries(eligible).filter(([name, entry]) => - allowsMcpDirectoryWidgetReadManifestAction( - name, - entry, - authCapability, - options, - ), + allowsWebMcpCapability(name, entry, authCapability), ), ) : {}; if ( !authenticated && Object.keys(publicEligible).length === 0 && - Object.keys(visibleCapabilityActions).length === 0 && - Object.keys(visibleDirectoryWidgetReadActions).length === 0 + Object.keys(visibleCapabilityActions).length === 0 ) { throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); } setResponseHeader(event, "Cache-Control", "no-store"); - const visible = directoryWidgetReadCapability - ? visibleDirectoryWidgetReadActions + const visible = authCapability?.startsWith("capability:visual-edit:") + ? visibleCapabilityActions : authenticated ? eligible : { ...publicEligible, ...visibleCapabilityActions }; diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index 5666bd55224..47c1a629a5a 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -1128,6 +1128,37 @@ describe("requestMatchesEmbedTarget", () => { } as any; } + it("resolves Visual Edit metadata without granting other framework routes", async () => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/visual-edit/design_1", + scope: "capability:visual-edit:design:design_1", + }); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/_agent-native/webmcp/manifest", token, "query"), + ), + ).resolves.toMatchObject({ + scope: "capability:visual-edit:design:design_1", + }); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/_agent-native/org/me", token, "query"), + ), + ).resolves.toBeNull(); + const ordinary = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/inbox", + }); + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/_agent-native/webmcp/manifest", ordinary, "query"), + ), + ).resolves.toBeNull(); + }); + it.each([ "/page/github%3Aowner%2Frepo", "/page/local-file%3Adocs%2Freadme%2emd", @@ -1428,6 +1459,7 @@ describe("requestMatchesEmbedTarget", () => { "/inbox?documentId=doc-2", "/_agent-native/open?view=inbox&documentId=doc-1", ], + ["/inbox", "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1"], [ "/inbox?documentId=doc-2", "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1", @@ -1452,21 +1484,20 @@ describe("requestMatchesEmbedTarget", () => { }, ); - it("preserves signed resource queries through open redirects", async () => { - const targetPath = "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1"; + it.each([ + [ + "/inbox?documentId=doc-1&embedded=1&agentSidebar=closed", + "/_agent-native/open?to=%2Finbox%3FdocumentId%3Ddoc-1", + ], + ["/inbox", "/_agent-native/open?app=mail&view=inbox&to=%2Finbox"], + ])("preserves signed open destination %s", async (path, targetPath) => { const token = signEmbedSessionToken({ ownerEmail: "owner@example.com", audienceHost: "mail.test", targetPath, }); await expect( - resolveEmbedSessionFromRequest( - credentialEvent( - "/inbox?documentId=doc-1&embedded=1&agentSidebar=closed", - token, - "query", - ), - ), + resolveEmbedSessionFromRequest(credentialEvent(path, token, "query")), ).resolves.toMatchObject({ targetPath }); }); diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index 3a95efab2ad..ba5fefe99a4 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -765,23 +765,22 @@ function matchesEmbedTargetQuery( const expected = new URLSearchParams(target.search); const redirectedOpen = target.pathname === OPEN_ROUTE_PATH && currentPathname !== OPEN_ROUTE_PATH; + const requiredKeys = new Set(redirectedOpen ? [] : expected.keys()); if (redirectedOpen) { const to = normalizeEmbedTargetPath(expected.get("to")); if (to) { const destination = new URL(to, "http://agent-native.invalid"); for (const key of new Set(destination.searchParams.keys())) { + requiredKeys.add(key); expected.delete(key); for (const value of destination.searchParams.getAll(key)) expected.append(key, value); } } } - const keys = new Set([ - ...current.searchParams.keys(), - ...(redirectedOpen ? [] : expected.keys()), - ]); - // Open redirects move navigation selectors into application state, but a URL - // that carries selectors must still agree with the signed target. + const keys = new Set([...current.searchParams.keys(), ...requiredKeys]); + // Open redirects move top-level navigation selectors into application state; + // selectors in the destination URL remain part of the signed target. for (const key of keys) { if (EMBED_PRESENTATION_QUERY_PARAMS.has(key)) continue; const actual = current.searchParams.getAll(key); @@ -813,13 +812,25 @@ export function requestMatchesEmbedTarget( ); } -function isEmbedActionRuntimeRequest(event: H3Event): boolean { +function isEmbedCapabilityRuntimeRequest( + event: H3Event, + scope: string, +): boolean { const pathname = requestPathname(event); - return ( - !!pathname && + if (!pathname) return false; + if ( ["/_agent-native/actions", "/_agent-native/webmcp/actions"].some( (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), ) + ) + return true; + const method = + (event as any).req?.method ?? (event as any).node?.req?.method ?? "GET"; + return ( + scope.startsWith("capability:visual-edit:design:") && + method === "GET" && + (pathname === "/_agent-native/webmcp/manifest" || + pathname === "/_agent-native/webmcp/manifest/") ); } @@ -846,7 +857,7 @@ function embedSessionAllowsRequest( ): boolean { if ( isEmbedCapabilityScope(claims.scope) && - isEmbedActionRuntimeRequest(event) + isEmbedCapabilityRuntimeRequest(event, claims.scope!) ) return true; const method = diff --git a/templates/design/actions/get-design.test.ts b/templates/design/actions/get-design.test.ts index 1a369e7e32a..e6c9d7c2459 100644 --- a/templates/design/actions/get-design.test.ts +++ b/templates/design/actions/get-design.test.ts @@ -55,6 +55,7 @@ const mocks = vi.hoisted(() => { sql: vi.fn((strings, ...values) => ({ strings, values })), getDb: vi.fn(() => ({ select })), resolveAccess: vi.fn(), + currentAccess: vi.fn(() => ({})), currentRequestUserIsOrgAdmin: vi.fn(), getAppConfig: vi.fn(() => ({ observability: { superOrgId: state.superOrgId }, @@ -74,7 +75,11 @@ const mocks = vi.hoisted(() => { vi.mock("@agent-native/core/tracking", () => ({ track: mocks.track })); -vi.mock("@agent-native/core/sharing", () => ({ +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + roleSatisfies: ( + await importOriginal() + ).roleSatisfies, + currentAccess: mocks.currentAccess, registerShareableResource: vi.fn(), resolveAccess: mocks.resolveAccess, })); @@ -133,6 +138,7 @@ import action from "./get-design.js"; describe("get-design", () => { beforeEach(() => { mocks.resolveAccess.mockReset(); + mocks.currentAccess.mockReset().mockReturnValue({}); mocks.currentRequestUserIsOrgAdmin.mockReset(); mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(false); mocks.getAppConfig.mockClear(); @@ -230,6 +236,51 @@ describe("get-design", () => { expect(result.data).not.toContain("example-private-bridge-token"); }); + it.each([ + { userEmail: undefined, sourceRole: "viewer", canPersistSource: false }, + { + userEmail: "viewer@example.com", + sourceRole: "viewer", + canPersistSource: false, + }, + { + userEmail: "editor@example.com", + sourceRole: "editor", + canPersistSource: true, + }, + { + userEmail: "owner@example.com", + sourceRole: "owner", + canPersistSource: true, + }, + ])( + "separates Visual Edit role from source persistence for $sourceRole", + async ({ userEmail, sourceRole, canPersistSource }) => { + const resource = mocks.state.reviewResource; + mocks.resolveAccess.mockResolvedValueOnce({ role: "editor", resource }); + mocks.resolveAccess.mockResolvedValueOnce({ role: sourceRole, resource }); + mocks.currentAccess.mockReturnValue({ + userEmail, + orgId: "org-a", + authCapability: "capability:visual-edit:design:design_123", + } as any); + const result = await action.run({ id: "design_123" }, { + userEmail, + orgId: "org-a", + caller: "frontend", + } as any); + expect(result).toMatchObject({ accessRole: "editor", canPersistSource }); + if (userEmail) + expect(mocks.resolveAccess).toHaveBeenLastCalledWith( + "design", + "design_123", + { userEmail, orgId: "org-a", authCapability: undefined }, + { skipResourceBody: true }, + ); + else expect(mocks.resolveAccess).toHaveBeenCalledTimes(1); + }, + ); + it("allows an org admin to read a same-org design for Human Review", async () => { mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(true); mocks.resolveAccess.mockResolvedValueOnce(null); diff --git a/templates/design/actions/get-design.ts b/templates/design/actions/get-design.ts index 74b99c19e67..885078e29b3 100644 --- a/templates/design/actions/get-design.ts +++ b/templates/design/actions/get-design.ts @@ -9,7 +9,11 @@ import { } from "@agent-native/core/server"; import { getRequestOrgId } from "@agent-native/core/server/request-context"; import { loadAgentDesignSystemContext } from "@agent-native/core/shared"; -import { resolveAccess } from "@agent-native/core/sharing"; +import { + currentAccess, + resolveAccess, + roleSatisfies, +} from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; @@ -160,6 +164,23 @@ export default defineAction({ } const row = access.resource; + const accessContext = ctx?.userEmail ? currentAccess() : undefined; + const sourceAccess = accessContext?.authCapability?.startsWith( + "capability:visual-edit:", + ) + ? await resolveAccess( + "design", + id, + { ...accessContext, authCapability: undefined }, + { skipResourceBody: true }, + ) + : access; + const canPersistSource = Boolean( + !reviewPreview && + ctx?.userEmail && + sourceAccess && + roleSatisfies(sourceAccess.role, "editor"), + ); const baseFileFields = { id: schema.designFiles.id, filename: schema.designFiles.filename, @@ -234,6 +255,7 @@ export default defineAction({ createdAt: row.createdAt, updatedAt: row.updatedAt, accessRole: access.role, + canPersistSource, files: files.map((f) => { const metadata = { id: f.id, diff --git a/templates/design/app/pages/design-editor/commands/save-file-content.ts b/templates/design/app/pages/design-editor/commands/save-file-content.ts index 94e776f9528..9ac22b099b3 100644 --- a/templates/design/app/pages/design-editor/commands/save-file-content.ts +++ b/templates/design/app/pages/design-editor/commands/save-file-content.ts @@ -86,6 +86,7 @@ export interface SaveFileContentArgs { baseUpdatedAt?: string | null, identityMigrationSourceContent?: string, deferCollabReconcile?: boolean, + identityMigrationRefused?: boolean, ) => void; /** The server content the file's first unsaved local edit was made from. */ getPendingBaseContent?: (fileId: string) => string | undefined; @@ -322,6 +323,14 @@ export function runFileContentSaveKeepalive( .catch(() => {}); } +function isAccessRefusal(error: unknown): boolean { + const status = + error && typeof error === "object" + ? (error as { status?: unknown }).status + : undefined; + return status === 401 || status === 403; +} + const SAVE_FAILURE_REASONS: Record = { conflict: "conflict", @@ -648,6 +657,31 @@ export function runSaveFileContent( op.skip("superseded"); return "failed"; } + // An identity migration carries no user edit; a refused one is dropped + // quietly, and its marked pending entry stops the same source re-queuing. + if ( + pending.identityMigrationSourceContent !== undefined && + isAccessRefusal(error) + ) { + markPendingLocalFileContent( + pending.id, + pending.content, + undefined, + pending.identityMigrationSourceContent, + pending.deferCollabReconcile, + true, + ); + if (queuedOutboxEntry) + void acknowledgeOutboxEntry(queuedOutboxEntry).catch(() => {}); + if (latestFileSaveForUnloadRef.current[pending.id] === pending) { + delete latestFileSaveForUnloadRef.current[pending.id]; + } + void queryClient.invalidateQueries({ + queryKey: ["action", "get-design"], + }); + op.skip("no_edit_rights"); + return "failed"; + } const failureKind = classifyDesignSaveFailure(error, navigator.onLine); if ( failureKind === "conflict" && diff --git a/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts index 2fd19f92d94..3d6c50b03d4 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts @@ -178,6 +178,7 @@ export function useEditorCanvasAndScreens({ designEditorCommandVersion, designAccessRole, canEditDesign, + canPersistDesignSource, rawServerFilesByIdRef, historyFilesRef, pendingLocalFileContentsRef, @@ -702,6 +703,7 @@ export function useEditorCanvasAndScreens({ serverFiles, publishCanonicalContent, canEditDesign, + canPersistDesignSource, isSignedIn, activeFileId, viewMode, diff --git a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx new file mode 100644 index 00000000000..8afeb5e23ba --- /dev/null +++ b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx @@ -0,0 +1,578 @@ +// @vitest-environment happy-dom + +import { sourceContentHash } from "@shared/source-workspace"; +import { QueryClient } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +import type { DesignSaveOutboxEntry } from "@/lib/design-save-outbox"; +import type { FileContentSaveRequest } from "@/pages/design-editor/editor-state"; + +import { runPublishCanonicalContent } from "../commands/publish-canonical-content"; +import { + runQueueFileContentSave, + runSaveFileContent, + type QueueFileContentSaveArgs, + type SaveFileContentArgs, +} from "../commands/save-file-content"; +import { usePublishActiveSourceContent } from "../effects/publish-active-source-content"; +import { prepareCanonicalSourceContent } from "../source-publication"; +import type { DesignData, DesignFile } from "../types"; +import type { EditorCore } from "./use-editor-core"; +import { useEditorGenerationAndAccess } from "./use-editor-generation-and-access"; +import type { EditorHistory } from "./use-editor-history"; + +const mocks = vi.hoisted(() => ({ + design: { current: undefined as unknown }, + toast: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + info: vi.fn(), + message: vi.fn(), + }, +})); + +vi.mock("sonner", () => ({ toast: mocks.toast })); + +vi.mock("@agent-native/core/client/hooks", () => { + const refetch = async () => ({ isSuccess: true }); + const mutations = new Map(); + return { + useActionQuery: (name: string) => ({ + data: name === "get-design" ? mocks.design.current : undefined, + error: null, + isError: false, + isLoading: false, + isFetching: false, + dataUpdatedAt: 1, + status: "success", + refetch, + }), + useActionMutation: (name: string) => { + if (!mutations.has(name)) { + mutations.set(name, { + mutate: () => {}, + mutateAsync: async () => ({}), + isPending: false, + }); + } + return mutations.get(name); + }, + actionErrorMessage: () => undefined, + callAction: async () => ({}), + getBrowserTabId: () => "tab-1", + readClientAppState: async () => null, + setClientAppState: async () => {}, + useChangeVersion: () => 0, + useChangeVersions: () => 0, + useAvatarUrl: () => undefined, + }; +}); + +vi.mock("@agent-native/core/client/mcp-app-host", () => ({ + useIsMcpDirectoryWidgetWriteEmbed: () => false, +})); + +vi.mock("@agent-native/core/client/review", () => { + const sendReviewThreadToAgent = () => {}; + return { + useReviewComments: () => ({ data: undefined }), + useSendReviewThreadToAgent: () => sendReviewThreadToAgent, + }; +}); + +vi.mock("@agent-native/creative-context/client", () => { + const lab = {}; + return { useCreativeContextLabState: () => lab }; +}); + +vi.mock("@/hooks/use-agent-generating", () => { + const generation = { + generating: false, + submit: () => {}, + reset: () => {}, + track: () => {}, + }; + return { useAgentGenerating: () => generation }; +}); + +vi.mock("@/hooks/use-annotate-lab", () => { + const annotateLab = {}; + return { useAnnotateLab: () => annotateLab }; +}); + +vi.mock("@/hooks/use-question-flow", () => { + const questionFlow = { + questions: null, + handleSubmit: () => {}, + handleSkip: () => {}, + retryProviderStatus: () => {}, + }; + return { useQuestionFlow: () => questionFlow }; +}); + +vi.mock("@/lib/design-funnel-tracking", () => ({ + useDesignReadyViewTracking: () => {}, +})); + +type Access = ReturnType; + +const DESIGN_ID = "design-1"; +const FILE_ID = "screen-1"; +const RAW_CONTENT = + ''; +const FILES: DesignFile[] = [ + { + id: FILE_ID, + filename: "index.html", + fileType: "html", + content: RAW_CONTENT, + createdAt: null, + updatedAt: "2026-10-24T00:00:00.000Z", + }, +]; + +function designData(overrides: Partial): DesignData { + return { + id: DESIGN_ID, + title: "Design", + updatedAt: "2026-10-24T00:00:00.000Z", + projectType: "prototype", + accessRole: "editor", + files: FILES, + ...overrides, + }; +} + +type Surface = { isSignedIn: boolean; isVisualEditSurface: boolean }; + +const unmounts: Array<() => Promise> = []; + +async function mountAccess( + surface: Surface, + design: DesignData, + useWithAccess?: (access: () => Access) => void, +) { + const queryClient = new QueryClient(); + const editorHistory = { + shellInput: null, + setSelectedLayerIdsState: () => {}, + codeLayerOwnerByNodeIdRef: { current: new Map() }, + overviewSelectedScreenIds: [], + setOverviewSelectedScreenIds: () => {}, + pendingOverviewScreenSelectionRef: { current: null }, + pendingOverviewLayerSelectionRef: { current: null }, + clearPendingOverviewLayerSelectionTimer: () => {}, + schedulePendingOverviewLayerSelectionClear: () => {}, + latestClipboardMutationContentRef: { current: null }, + explicitOverviewScreenSelectionRef: { current: [] }, + captureCurrentSelection: () => null, + } as unknown as EditorHistory; + const viewModeRef = { current: "single" }; + const selectedElementRef = { current: null }; + const editorCore = (next: Surface) => + ({ + t: (key: string) => key, + id: DESIGN_ID, + session: next.isSignedIn ? { email: "person@example.com" } : null, + isSignedIn: next.isSignedIn, + sessionResolved: true, + reviewPreview: false, + queryClient, + shellMode: false, + isVisualEditSurface: next.isVisualEditSurface, + readOnlyWidget: false, + viewMode: "single", + viewModeRef, + setSelectedElement: () => {}, + selectedElementRef, + setPendingVisualEditPublicationFailed: () => {}, + activeFileId: FILE_ID, + setActiveFileId: () => {}, + }) as unknown as EditorCore; + const latest: { current: Access | null } = { current: null }; + function Harness({ surface }: { surface: Surface }) { + latest.current = useEditorGenerationAndAccess({ + editorCore: editorCore(surface), + editorHistory, + }); + useWithAccess?.(() => latest.current!); + return null; + } + mocks.design.current = design; + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => root.render()); + unmounts.push(() => + act(async () => root.unmount()).then(() => host.remove()), + ); + return { + access: () => latest.current!, + queryClient, + rerender: (next: Surface, nextDesign: DesignData) => { + mocks.design.current = nextDesign; + return act(async () => root.render()); + }, + }; +} + +function accessRefusal() { + return Object.assign(new Error("Forbidden"), { status: 403 }); +} + +function stored(input: Record) { + return { + updated: true, + versionHash: sourceContentHash(String(input.content)), + content: input.content, + }; +} + +// Wires the hook's own refs and pending-content bookkeeping into the real +// publish, queue and save commands, the way useEditorFilesAndSaving does. +function sourceSync( + access: () => Access, + queryClient: QueryClient, + updateFile: (input: Record) => Promise, +) { + const latestFileSaveForUnloadRef: SaveFileContentArgs["latestFileSaveForUnloadRef"] = + { current: {} }; + const acknowledgeOutboxEntry = vi.fn(async () => {}); + const journalOutboxEntry = vi.fn(async () => true); + const createFileSaveOutboxEntry = ( + pending: FileContentSaveRequest, + ): DesignSaveOutboxEntry => ({ + key: `${DESIGN_ID}:${pending.id}:update-file`, + designId: DESIGN_ID, + actorScope: "user-1", + actionName: "update-file", + resourceId: pending.id, + operationSource: pending.operationSource, + operationRevision: pending.operationRevision, + payload: { id: pending.id, content: pending.content }, + updatedAt: 1, + }); + const updateFileMutation = { mutateAsync: vi.fn(updateFile) }; + const markPendingLocalFileContent: SaveFileContentArgs["markPendingLocalFileContent"] = + (...args) => access().markPendingLocalFileContent(...args); + const saveArgs: SaveFileContentArgs = { + acknowledgeOutboxEntry, + canEditDesignRef: access().canEditDesignRef, + createFileSaveOutboxEntry, + designId: DESIGN_ID, + fileSaveChainsRef: { current: {} }, + journalOutboxEntry, + latestFileSaveForUnloadRef, + rollbackPendingLocalFileContent: (...args) => + access().rollbackPendingLocalFileContent(...args), + markPendingLocalFileContent, + queryClient, + setPatchProof: () => {}, + t: (key) => key, + updateFileMutation: + updateFileMutation as unknown as SaveFileContentArgs["updateFileMutation"], + warnChangesWillRetry: () => {}, + }; + const completions: Array | void> = []; + const queueArgs: QueueFileContentSaveArgs = { + canEditDesignRef: access().canEditDesignRef, + createFileSaveOutboxEntry, + fileSaveOperationRevisionRef: { current: {} }, + fileSaveOutboxJournalPromisesRef: { current: new WeakMap() }, + fileSaveTimersRef: { current: {} }, + journalOutboxEntry, + latestFileSaveForUnloadRef, + markPendingLocalFileContent, + operationSource: "tab-1", + pendingFileSavesRef: { current: {} }, + saveFileContent: (pending, journalPromise, onPersistedContent) => + runSaveFileContent(saveArgs, pending, journalPromise, onPersistedContent), + setTimer: (callback, delayMs) => window.setTimeout(callback, delayMs), + clearTimer: (timerId) => window.clearTimeout(timerId), + }; + const settle = () => + act(async () => { + await Promise.all(completions); + }); + const publishCanonicalContent = ( + fileId: string, + sourceContent: string, + fileType?: string, + ) => + runPublishCanonicalContent( + { + canEditDesignRef: access().canEditDesignRef, + canPersistDesignSourceRef: access().canPersistDesignSourceRef, + pendingLocalFileContentsRef: access().pendingLocalFileContentsRef, + cancelIdentityMigration: () => {}, + queueFileContentSave: (fileId, content, options) => { + completions.push( + runQueueFileContentSave(queueArgs, fileId, content, options), + ); + }, + }, + fileId, + sourceContent, + fileType, + ); + return { + acknowledgeOutboxEntry, + journalOutboxEntry, + latestFileSaveForUnloadRef, + updateFileMutation, + settle, + publishCanonicalContent, + publish: async () => { + let canonical = ""; + await act(async () => { + canonical = publishCanonicalContent(FILE_ID, RAW_CONTENT, "html"); + }); + await settle(); + return canonical; + }, + saveUserEdit: async (content: string) => { + await act(async () => { + completions.push( + runQueueFileContentSave(queueArgs, FILE_ID, content, { + expectedVersionHash: "user-edit-base", + immediate: true, + }), + ); + }); + await settle(); + }, + }; +} + +const VISUAL_EDIT_SIGNED_IN: Surface = { + isSignedIn: true, + isVisualEditSurface: true, +}; + +describe("automatic canonical source persistence", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + for (const fn of Object.values(mocks.toast)) fn.mockClear(); + }); + + afterEach(async () => { + while (unmounts.length) await unmounts.pop()!(); + }); + + it("syncs on a Visual Edit link when the account itself can edit", async () => { + const { access, queryClient } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + const sync = sourceSync(access, queryClient, async (input) => ({ + updated: true, + versionHash: "persisted", + content: input.content, + })); + + await sync.publish(); + + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + id: FILE_ID, + identityOnly: true, + content: expect.stringContaining("data-agent-native-node-id"), + }), + ); + }); + + it("keeps canonical content in memory without syncing when only the link can edit", async () => { + const { access, queryClient } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: false }), + ); + const sync = sourceSync(access, queryClient, async () => ({})); + + const canonical = await sync.publish(); + + expect(access().canEditDesign).toBe(true); + expect(access().canPersistDesignSourceRef.current).toBe(false); + expect(canonical).toContain("data-agent-native-node-id"); + expect(sync.updateFileMutation.mutateAsync).not.toHaveBeenCalled(); + expect(sync.journalOutboxEntry).not.toHaveBeenCalled(); + expect(access().pendingLocalFileContentsRef.current.size).toBe(0); + expect(mocks.toast.error).not.toHaveBeenCalled(); + }); + + it("starts syncing when a refetch grants account access with the same files", async () => { + const { access, queryClient, rerender } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: false }), + ); + expect(access().canPersistDesignSourceRef.current).toBe(false); + + await rerender( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + + expect(access().design?.files).toBe(FILES); + expect(access().canPersistDesignSourceRef.current).toBe(true); + const sync = sourceSync(access, queryClient, async (input) => ({ + updated: true, + versionHash: "persisted", + content: input.content, + })); + await sync.publish(); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + }); + + it.each([ + { surface: VISUAL_EDIT_SIGNED_IN, persists: false }, + { + surface: { isSignedIn: true, isVisualEditSurface: false }, + persists: true, + }, + ])( + "treats a missing flag as $persists on visualEdit=$surface.isVisualEditSurface", + async ({ surface, persists }) => { + const { access } = await mountAccess(surface, designData({})); + + expect(access().canEditDesign).toBe(true); + expect(access().canPersistDesignSourceRef.current).toBe(persists); + }, + ); + + it("drops a refused automatic sync quietly and does not queue it again", async () => { + const { access, queryClient } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + const sync = sourceSync(access, queryClient, async () => { + throw accessRefusal(); + }); + + const canonical = await sync.publish(); + + expect(canonical).toContain("data-agent-native-node-id"); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(sync.acknowledgeOutboxEntry).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: FILE_ID }), + ); + expect(sync.latestFileSaveForUnloadRef.current[FILE_ID]).toBeUndefined(); + expect(mocks.toast.error).not.toHaveBeenCalled(); + + await sync.publish(); + + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(mocks.toast.error).not.toHaveBeenCalled(); + }); + + it("syncs a refused automatic migration once account access returns", async () => { + const { access, queryClient, rerender } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + let refuse = true; + const sync = sourceSync(access, queryClient, async (input) => { + if (refuse) throw accessRefusal(); + return stored(input); + }); + const userEdit = "Other screen edit"; + await act(async () => { + access().markPendingLocalFileContent("screen-2", userEdit); + }); + + await sync.publish(); + await rerender( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: false }), + ); + await sync.publish(); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + + refuse = false; + await rerender( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + await sync.publish(); + await sync.publish(); + + expect(access().design?.files).toBe(FILES); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(2); + expect(sync.updateFileMutation.mutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ id: FILE_ID, identityOnly: true }), + ); + expect( + access().pendingLocalFileContentsRef.current.get("screen-2")?.content, + ).toBe(userEdit); + expect(mocks.toast.error).not.toHaveBeenCalled(); + }); + + it("publishes the active synced screen's raw text when account access arrives", async () => { + const doc = new Y.Doc(); + doc.getText("content").insert(0, RAW_CONTENT); + const canonical = prepareCanonicalSourceContent(RAW_CONTENT, { + fileId: FILE_ID, + fileType: "html", + }).content; + let sync: ReturnType | undefined; + function useActiveScreen(access: () => Access) { + sync ??= sourceSync(access, new QueryClient(), async (input) => + stored(input), + ); + usePublishActiveSourceContent({ + activeFile: FILES[0], + activeContent: canonical, + rawActiveContent: canonical, + canEditDesign: access().canEditDesign, + canPersistDesignSource: access().canPersistDesignSource, + isSynced: true, + publishCanonicalContent: sync.publishCanonicalContent, + ydoc: doc, + }); + } + const { rerender } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: false }), + useActiveScreen, + ); + await sync!.settle(); + expect(sync!.updateFileMutation.mutateAsync).not.toHaveBeenCalled(); + + await rerender( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: true }), + ); + await sync!.settle(); + + expect(sync!.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(sync!.updateFileMutation.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + id: FILE_ID, + identityOnly: true, + content: canonical, + expectedVersionHash: sourceContentHash(RAW_CONTENT), + }), + ); + }); + + it("still reports a refused user edit", async () => { + const { access, queryClient } = await mountAccess( + VISUAL_EDIT_SIGNED_IN, + designData({ canPersistSource: false }), + ); + const sync = sourceSync(access, queryClient, async () => { + throw accessRefusal(); + }); + + await sync.saveUserEdit("Edited"); + + expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(sync.acknowledgeOutboxEntry).not.toHaveBeenCalled(); + expect(mocks.toast.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts index 8228216b3c4..79e1e236eda 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts @@ -1062,8 +1062,14 @@ export function useEditorGenerationAndAccess({ setReviewSendingThreadId(null); } }, [reviewAgentQueueThreadIds, reviewSendingThreadId]); + // accessRole includes a Visual Edit link's role, which update-file does not + // honor; automatic source syncs need the account's own canPersistSource. + const canPersistDesignSource = + canEditDesign && + isSignedIn && + (design?.canPersistSource ?? !isVisualEditSurface); const canEditDesignRef = useRef(canEditDesign); - const canPersistDesignSourceRef = useRef(canEditDesign && isSignedIn); + const canPersistDesignSourceRef = useRef(canPersistDesignSource); const rawServerFilesByIdRef = useRef(new Map()); const historyFilesRef = useRef([]); const restoreSelectionSnapshot = useCallback( @@ -1191,13 +1197,22 @@ export function useEditorGenerationAndAccess({ baseUpdatedAt?: string | null, identityMigrationSourceContent?: string, deferCollabReconcile?: boolean, + identityMigrationRefused?: boolean, ) => { const current = pendingLocalFileContentsRef.current.get(fileId); + const sameMigration = + current?.content === content && + current.identityMigrationSourceContent === + identityMigrationSourceContent; + // A refusal annotates only the migration it answered, never a newer edit. + if (identityMigrationRefused && !sameMigration) return; if ( current?.content === content && current.identityMigrationSourceContent === identityMigrationSourceContent && current.deferCollabReconcile === deferCollabReconcile && + current.identityMigrationRefused === + (identityMigrationRefused || undefined) && (baseUpdatedAt === undefined || current.baseUpdatedAt !== undefined) ) return; @@ -1210,6 +1225,7 @@ export function useEditorGenerationAndAccess({ baseUpdatedAt, identityMigrationSourceContent, deferCollabReconcile, + identityMigrationRefused, }), ); setPendingLocalFileContentsRevision((revision) => revision + 1); @@ -1252,8 +1268,25 @@ export function useEditorGenerationAndAccess({ useLayoutEffect(() => { canEditDesignRef.current = canEditDesign; - canPersistDesignSourceRef.current = canEditDesign && isSignedIn; - }, [canEditDesign, isSignedIn]); + canPersistDesignSourceRef.current = canPersistDesignSource; + }, [canEditDesign, canPersistDesignSource]); + const sourcePersistenceDeniedRef = useRef(!canPersistDesignSource); + useLayoutEffect(() => { + const regained = + canPersistDesignSource && sourcePersistenceDeniedRef.current; + sourcePersistenceDeniedRef.current = !canPersistDesignSource; + if (!regained) return; + // Refused migrations stay pending so they are not re-sent; regained source + // access is the only signal that one may now succeed. + let cleared = false; + for (const [fileId, pending] of pendingLocalFileContentsRef.current) { + if (!pending.identityMigrationRefused) continue; + pendingLocalFileContentsRef.current.delete(fileId); + cleared = true; + } + if (cleared) + setPendingLocalFileContentsRevision((revision) => revision + 1); + }, [canPersistDesignSource]); useEffect(() => { if (!id || !hasPendingGeneration || !designDataReady) return; @@ -1666,6 +1699,7 @@ export function useEditorGenerationAndAccess({ reviewSendingThreadId, setReviewSendingThreadId, canEditDesignRef, + canPersistDesignSource, canPersistDesignSourceRef, rawServerFilesByIdRef, historyFilesRef, diff --git a/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts b/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts index 6e4b0b425e8..ea9183d7433 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts @@ -44,14 +44,7 @@ import { import { isRunningAppSourceType } from "@shared/source-mode"; import { normalizeDesignSourceType } from "@shared/source-mode"; import { useTheme } from "next-themes"; -import { - useState, - useEffect, - useLayoutEffect, - useCallback, - useRef, - useMemo, -} from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { toast } from "sonner"; import * as Y from "yjs"; @@ -84,6 +77,7 @@ import { } from "../editor-state"; import { runAdoptDbFileContent } from "../effects/adopt-db-file-content"; import { runObserveCollabText } from "../effects/observe-collab-text"; +import { usePublishActiveSourceContent } from "../effects/publish-active-source-content"; import { runSeedCollabContent } from "../effects/seed-collab-content"; import { useSyncLatestActiveContent } from "../effects/sync-latest-active-content"; import { forwardYjsUndoStackItemMeta, MAX_DESIGN_UNDO_STACK } from "../history"; @@ -159,6 +153,7 @@ export function useEditorLiveEditsAndPresence({ generating, pendingGenerationActive, canEditDesign, + canPersistDesignSource, canEditLiveScreens, publicVisualEdit, rawServerFilesByIdRef, @@ -668,21 +663,16 @@ export function useEditorLiveEditsAndPresence({ } }, }); - useLayoutEffect(() => { - if (activeFile?.id && rawActiveContent !== activeContent) - publishCanonicalContent( - activeFile.id, - rawActiveContent, - activeFile.fileType, - ); - }, [ - activeFile?.id, - activeFile?.fileType, - rawActiveContent, + usePublishActiveSourceContent({ + activeFile, activeContent, - publishCanonicalContent, + rawActiveContent, canEditDesign, - ]); + canPersistDesignSource, + isSynced, + publishCanonicalContent, + ydoc, + }); useSyncLatestActiveContent({ activeContent, activeFile, diff --git a/templates/design/app/pages/design-editor/editor-state.ts b/templates/design/app/pages/design-editor/editor-state.ts index aaa8192d610..88ac882d066 100644 --- a/templates/design/app/pages/design-editor/editor-state.ts +++ b/templates/design/app/pages/design-editor/editor-state.ts @@ -428,6 +428,7 @@ export interface PendingLocalFileContent { identityMigrationSourceContent?: string; identityMigrationStoredContent?: string; identityMigrationStoredUpdatedAt?: string | null; + identityMigrationRefused?: true; } export function createPendingLocalFileContent(args: { @@ -437,6 +438,7 @@ export function createPendingLocalFileContent(args: { baseUpdatedAt?: string | null; identityMigrationSourceContent?: string; deferCollabReconcile?: boolean; + identityMigrationRefused?: boolean; }): PendingLocalFileContent { const { current, @@ -445,6 +447,7 @@ export function createPendingLocalFileContent(args: { baseUpdatedAt, identityMigrationSourceContent, deferCollabReconcile, + identityMigrationRefused, } = args; const sameContent = current?.content === content; const sameMigration = @@ -477,6 +480,9 @@ export function createPendingLocalFileContent(args: { : sameContent && sameMigration ? current.identityMigrationStoredUpdatedAt : (file?.updatedAt ?? null), + ...(identityMigrationRefused && identityMigrationSourceContent !== undefined + ? { identityMigrationRefused: true } + : {}), }; } diff --git a/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts b/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts new file mode 100644 index 00000000000..cd4db35dfe0 --- /dev/null +++ b/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts @@ -0,0 +1,55 @@ +import { useLayoutEffect, useRef } from "react"; +import type * as Y from "yjs"; + +export function usePublishActiveSourceContent({ + activeFile, + activeContent, + rawActiveContent, + canEditDesign, + canPersistDesignSource, + isSynced, + publishCanonicalContent, + ydoc, +}: { + activeFile: { id: string; fileType?: string } | null | undefined; + activeContent: string; + rawActiveContent: string; + canEditDesign: boolean; + canPersistDesignSource: boolean; + isSynced: boolean; + publishCanonicalContent: ( + fileId: string, + sourceContent: string, + fileType?: string, + ) => string; + ydoc: Y.Doc | null; +}): void { + const sourcePersistenceDeniedRef = useRef(!canPersistDesignSource); + useLayoutEffect(() => { + const regained = + canPersistDesignSource && sourcePersistenceDeniedRef.current; + sourcePersistenceDeniedRef.current = !canPersistDesignSource; + if (!activeFile?.id) return; + // A synced screen's collab content is already canonical, so publishing it + // finds nothing to persist; regained access must publish the doc's raw text. + const syncedText = + regained && ydoc && isSynced ? ydoc.getText("content").toJSON() : ""; + const sourceContent = syncedText || rawActiveContent; + if (sourceContent !== activeContent) + publishCanonicalContent( + activeFile.id, + sourceContent, + activeFile.fileType, + ); + }, [ + activeFile?.id, + activeFile?.fileType, + rawActiveContent, + activeContent, + publishCanonicalContent, + canEditDesign, + canPersistDesignSource, + isSynced, + ydoc, + ]); +} diff --git a/templates/design/app/pages/design-editor/types.ts b/templates/design/app/pages/design-editor/types.ts index bd0abc3716a..293f416b348 100644 --- a/templates/design/app/pages/design-editor/types.ts +++ b/templates/design/app/pages/design-editor/types.ts @@ -96,5 +96,6 @@ export interface DesignData { visibility?: "private" | "org" | "public"; data?: string | null; accessRole?: DesignAccessRole; + canPersistSource?: boolean; files: DesignFile[]; } diff --git a/templates/design/changelog/2026-10-24-visual-edit-links-keep-their-editing-access-after-sign-in-wi.md b/templates/design/changelog/2026-10-24-visual-edit-links-keep-their-editing-access-after-sign-in-wi.md new file mode 100644 index 00000000000..87c70dcf2f9 --- /dev/null +++ b/templates/design/changelog/2026-10-24-visual-edit-links-keep-their-editing-access-after-sign-in-wi.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-24 +--- + +Visual Edit links keep their editing access after sign-in without background save errors. From 7266bf6e1765752e3eb086a39415e0255bb54bd4 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:19:33 -0400 Subject: [PATCH 12/15] Preserve scoped manifests and browser consent --- .changeset/tidy-embed-session-scope.md | 2 + .../core/src/server/action-routes.spec.ts | 199 ++++++++++++-- packages/core/src/server/action-routes.ts | 89 +++++-- .../core/src/server/embed-session.spec.ts | 53 ++++ packages/core/src/server/embed-session.ts | 10 +- packages/core/src/server/index.ts | 2 + .../grant-localhost-write-consent.spec.ts | 88 ++++++- .../actions/grant-localhost-write-consent.ts | 33 ++- .../LocalhostWriteConsentDialog.test.tsx | 245 ++++++++++++++++++ .../design/LocalhostWriteConsentDialog.tsx | 102 ++++++-- ...ent-asks-you-to-sign-in-before-approvin.md | 6 + 11 files changed, 754 insertions(+), 75 deletions(-) create mode 100644 templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx create mode 100644 templates/design/changelog/2026-10-24-local-file-write-consent-asks-you-to-sign-in-before-approvin.md diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md index 7ee6c9313a2..fcf819242cc 100644 --- a/.changeset/tidy-embed-session-scope.md +++ b/.changeset/tidy-embed-session-scope.md @@ -11,3 +11,5 @@ MCP Apps embeds behind a proxy require the public origin configured through `APP Use the embed origin consistently for widget CSP and keep ordinary sign-in session lookup independent of embed origin validation. Keep Visual Edit capabilities on their declared action set and automatic source synchronization on the signed-in user's own write access. + +Keep card read manifests limited to their declared reads, and require a browser session for localhost write consent. diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index c7e75cb27a4..0e5a37f1d26 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -42,6 +42,15 @@ const mockHasPresentedEmbedSessionCredential = vi.hoisted(() => ), ), ); +const mockHasEmbedSessionRequestMarker = vi.hoisted(() => + vi.fn((event: any) => + Boolean( + event._query?.__an_embed_token || + event._query?.__an_embed_target || + event._headers?.["x-agent-native-embed-target"], + ), + ), +); const mockIsExpiredMcpDirectoryWidgetSessionRequest = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => false), ); @@ -134,6 +143,8 @@ vi.mock("./auth.js", () => ({ isLoopbackRequest: () => false, })); vi.mock("./embed-session.js", () => ({ + hasEmbedSessionRequestMarker: (event: any) => + mockHasEmbedSessionRequestMarker(event), hasLiveEmbedCapabilityCookie: (event: any) => event._hasLiveEmbedCapabilityCookie ?? false, hasPresentedEmbedSessionCredential: (event: any) => @@ -2063,14 +2074,19 @@ describe("mountActionRoutes", () => { }, ].flatMap((credential) => [ - { inside: true, requiresAuth: true }, - { inside: false, requiresAuth: true }, - { inside: false, requiresAuth: false }, + { inside: true, requiresAuth: true, name: "update-screen-source" }, + { inside: false, requiresAuth: true, name: "future-visual-action" }, + { inside: false, requiresAuth: false, name: "future-visual-action" }, + { + inside: false, + requiresAuth: true, + name: "grant-localhost-write-consent", + }, ].map((action) => ({ ...credential, ...action })), ), )( - "keeps $credential authority independent (Visual Edit: $inside, account required: $requiresAuth)", - async ({ browserUser, scope, inside, requiresAuth }) => { + "keeps $credential authority independent for $name (Visual Edit: $inside, account required: $requiresAuth)", + async ({ browserUser, scope, inside, requiresAuth, name }) => { const { createMcpDirectoryWidgetReadCapability } = await import("../shared/embed-auth.js"); const { mountActionRoutes } = await import("./action-routes.js"); @@ -2105,7 +2121,6 @@ describe("mountActionRoutes", () => { userEmail: ctx?.userEmail, authCapability: getRequestAuthCapability(), })); - const name = inside ? "update-screen-source" : "future-visual-action"; mountActionRoutes( { use: vi.fn((path: string, handler: any) => @@ -2137,7 +2152,10 @@ describe("mountActionRoutes", () => { }, req: { url: `http://app.test/_agent-native/actions/${name}`, - json: async () => (inside ? { designId: "design_1" } : {}), + json: async () => + inside || name === "grant-localhost-write-consent" + ? { designId: "design_1", connectionId: "conn_1" } + : {}, }, }; if (scope === "card") { @@ -7914,11 +7932,13 @@ describe("mountWebMcpActionRoutes", () => { }, ); - it.each([false, true])( - "refuses card metadata but keeps scoped reads (ineligible cookie: %s)", - async (ineligibleCookie) => { - const { createMcpDirectoryWidgetReadCapability } = - await import("../shared/embed-auth.js"); + it.each(["read", "write"] as const)( + "keeps a %s card manifest limited to its explicit read grants", + async (kind) => { + const { + createMcpDirectoryWidgetReadCapability, + createMcpDirectoryWidgetWriteCapability, + } = await import("../shared/embed-auth.js"); const { mountWebMcpActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; const run = vi.fn(async (_args, context) => ({ @@ -7927,12 +7947,26 @@ describe("mountWebMcpActionRoutes", () => { mcpDirectoryWidgetReadOnly: context.mcpDirectoryWidgetReadOnly, })); const runUnscopedPublicRead = vi.fn(); - const capability = createMcpDirectoryWidgetReadCapability({ + const resource = { appId: "design", resourceUri: "ui://design/shell-v69", resourceIds: { designId: "d1" }, - actionArguments: { "get-design": { id: "d1" } }, - })!; + }; + const readArguments = { "get-design": { id: "d1" } }; + const capability = ( + kind === "read" + ? createMcpDirectoryWidgetReadCapability({ + ...resource, + actionArguments: readArguments, + }) + : createMcpDirectoryWidgetWriteCapability({ + ...resource, + userEmail: "ticket-owner@example.com", + expiresAtMs: Date.now() + 60_000, + readActionArguments: readArguments, + writeActionArguments: { "update-design": { id: "d1" } }, + }) + )!; mockResolveEmbedSessionFromRequest.mockResolvedValue({ email: "ticket-owner@example.com", token: "signed-directory-capability", @@ -7966,6 +8000,13 @@ describe("mountWebMcpActionRoutes", () => { http: { method: "GET" }, readOnly: true, } as any, + "get-visual-edit-snapshot": { + tool: { description: "Visual Edit read", parameters: {} }, + run: vi.fn(), + http: { method: "GET" }, + readOnly: true, + capabilityScopes: ["visual-edit"], + } as any, "search-public-docs": { tool: { description: "Search public docs", parameters: {} }, run: runUnscopedPublicRead, @@ -7984,7 +8025,10 @@ describe("mountWebMcpActionRoutes", () => { owner: "cookie-owner@example.com", anonymous: false, }), - mcpDirectoryWidgetReadActionArguments: { "get-design": ["id"] }, + mcpDirectoryWidgetReadActionArguments: { + "get-design": ["id"], + "get-visual-edit-snapshot": ["designId"], + }, mcpDirectoryWidgetReadOnlyActions: ["get-design"], mcpDirectoryWidgetAppId: "design", mcpDirectoryWidgetResourceUri: "ui://design/shell-v69", @@ -8004,15 +8048,21 @@ describe("mountWebMcpActionRoutes", () => { const mcpToolRoute = mounted.find( ({ path }) => path === "/mcp/tool/get-design", ); - if (ineligibleCookie) - mockResolveEmbedSessionFromRequest.mockResolvedValueOnce(null); await expect( manifestRoute?.handler({ _method: "GET", _headers: {}, _hasLiveEmbedCapabilityCookie: true, }), - ).rejects.toMatchObject({ statusCode: 401 }); + ).resolves.toEqual([ + { + name: "get-design", + title: "Get design", + description: "Read this design", + inputSchema: {}, + readOnly: true, + }, + ]); await expect( webMcpRoute?.handler({ @@ -8051,6 +8101,117 @@ describe("mountWebMcpActionRoutes", () => { }, ); + it.each( + (["card", "visual-edit"] as const).flatMap((scope) => + (["unmarked", "query", "bearer", "signed-out"] as const).map( + (context) => ({ scope, context }), + ), + ), + )( + "separates an unresolved $scope cookie from $context manifest authority", + async ({ scope, context }) => { + const embedSessions = + await vi.importActual( + "./embed-session.js", + ); + const { createMcpDirectoryWidgetReadCapability, EMBED_SESSION_COOKIE } = + await import("../shared/embed-auth.js"); + const { mountWebMcpActionRoutes } = await import("./action-routes.js"); + const capability = + scope === "visual-edit" + ? "capability:visual-edit:design:d1" + : createMcpDirectoryWidgetReadCapability({ + appId: "design", + resourceUri: "ui://design/shell-v69", + resourceIds: { designId: "d1" }, + actionArguments: { "get-design": { id: "d1" } }, + })!; + const token = embedSessions.signEmbedSessionToken({ + ownerEmail: "ticket-owner@example.com", + audienceHost: "design.example.com", + targetPath: "/design/d1", + scope: capability, + ...(scope === "card" ? { ticketCreatedAtMs: Date.now() } : {}), + }); + expect(embedSessions.verifyEmbedSessionToken(token).ok).toBe(true); + mockResolveEmbedSessionFromRequest.mockResolvedValue(null); + mockHasPresentedEmbedSessionCredential.mockImplementation( + embedSessions.hasPresentedEmbedSessionCredential, + ); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(); + mountWebMcpActionRoutes( + { + use: (path: string, handler: any) => mounted.push({ path, handler }), + }, + { + "get-design": { + tool: { description: "Read design", parameters: {} }, + run, + readOnly: true, + } as any, + "update-screen-source": { + tool: { description: "Edit screen", parameters: {} }, + capabilityScopes: ["visual-edit"], + run, + } as any, + "account-action": { + tool: { description: "Account action", parameters: {} }, + run, + } as any, + }, + { + getOwnerFromEvent: async () => { + if (context !== "signed-out") return "browser@example.com"; + throw Object.assign(new Error("Unauthorized"), { + statusCode: 401, + }); + }, + }, + ); + const event = { + _method: "GET", + url: new URL( + "https://design.example.com/_agent-native/webmcp/manifest", + ), + _headers: { + host: "design.example.com", + cookie: `${context === "signed-out" ? "" : "browser-session=signed-in; "}${EMBED_SESSION_COOKIE}=${token}`, + origin: "https://design.example.com", + referer: "https://design.example.com/design/d1", + "sec-fetch-site": "same-origin", + ...(context === "bearer" + ? { + authorization: `Bearer ${token}`, + "x-agent-native-embed-target": "/design/d1", + } + : {}), + }, + _query: + context === "query" + ? { __an_embed_token: token, __an_embed_target: "/design/d1" } + : {}, + _hasLiveEmbedCapabilityCookie: true, + }; + const manifest = mounted.find( + ({ path }) => path === "/_agent-native/webmcp/manifest", + )!; + if (context === "unmarked") { + const result = await manifest.handler(event); + expect(result.map((entry: { name: string }) => entry.name)).toEqual([ + "get-design", + "update-screen-source", + "account-action", + ]); + } else { + await expect(manifest.handler(event)).rejects.toMatchObject({ + statusCode: 401, + }); + } + expect(run).not.toHaveBeenCalled(); + }, + ); + it("does not let a bootstrap capability match ordinary visual-edit actions", async () => { const { mountWebMcpActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index fdaa9de8239..aa6e5a30876 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -111,7 +111,7 @@ import { readCorsAllowedOrigins, } from "./cors-origins.js"; import { - hasLiveEmbedCapabilityCookie, + hasEmbedSessionRequestMarker, hasPresentedEmbedSessionCredential, isExpiredMcpDirectoryWidgetSessionRequest, resolveEmbedSessionFromRequest, @@ -587,7 +587,6 @@ const VISUAL_EDIT_CAPABILITY_ACTIONS = new Set([ "get-localhost-write-consent-request", "get-visual-edit-pending", "get-visual-edit-snapshot", - "grant-localhost-write-consent", "list-design-source-capabilities", "list-design-states", "list-files", @@ -621,6 +620,34 @@ function allowsWebMcpCapability( ); } +function allowsMcpDirectoryWidgetReadManifestAction( + name: string, + entry: ActionEntry, + session: ResolvedEmbedSession, + options: MountWebMcpActionRoutesOptions | undefined, +): boolean { + const allowedArgumentNames = + options?.mcpDirectoryWidgetReadActionArguments?.[name]; + return ( + entry.http !== false && + entry.http?.method === "GET" && + (entry.readOnly === true || + options?.mcpDirectoryWidgetReadOnlyActions?.includes(name) === true) && + (entry.requiresAuth !== false || + options?.mcpDirectoryWidgetReadPublicActions?.includes(name) === true) && + allowedArgumentNames !== undefined && + allowsMcpDirectoryWidgetReadAction(resolvedEmbedCapabilityScope(session), { + actionName: name, + appId: options?.mcpDirectoryWidgetAppId ?? options?.appId, + resourceUri: options?.mcpDirectoryWidgetResourceUri, + userEmail: session.email, + orgId: session.orgId, + allowedArgumentNames, + requireArgumentMatch: false, + }) + ); +} + function allowsWebMcpCapabilityResource( actionName: string, authCapability: string | undefined, @@ -639,25 +666,17 @@ function allowsWebMcpCapabilityResource( } } -async function resolveWebMcpManifestCapability( - event: any, -): Promise { - const session = await resolveRequestEmbedSession(event); - const capability = resolvedEmbedCapabilityScope(session); - if ( - isMcpDirectoryWidgetCapabilityScope(capability) || - (!session && hasLiveEmbedCapabilityCookie(event)) - ) { - throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); - } - return capability; -} - async function resolveRequestEmbedSession( event: any, + options?: { ignoreUnresolvedCookie?: boolean }, ): Promise { const session = await resolveEmbedSessionFromRequest(event); - if (!session && hasPresentedEmbedSessionCredential(event)) { + if ( + !session && + (options?.ignoreUnresolvedCookie + ? hasEmbedSessionRequestMarker(event) + : hasPresentedEmbedSessionCredential(event)) + ) { if (await isExpiredMcpDirectoryWidgetSessionRequest(event)) { setResponseHeader( event, @@ -1970,7 +1989,14 @@ export function mountWebMcpActionRoutes( if (!isAuthResolutionFailure(error)) throw error; } } - const authCapability = await resolveWebMcpManifestCapability(event); + const embedSession = await resolveRequestEmbedSession(event, { + ignoreUnresolvedCookie: true, + }); + const authCapability = resolvedEmbedCapabilityScope(embedSession); + const directoryWidgetSession = + embedSession && isMcpDirectoryWidgetCapabilityScope(authCapability) + ? embedSession + : null; const visibleCapabilityActions = authCapability ? Object.fromEntries( Object.entries(eligible).filter(([name, entry]) => @@ -1978,19 +2004,34 @@ export function mountWebMcpActionRoutes( ), ) : {}; + const visibleDirectoryWidgetReadActions = directoryWidgetSession + ? Object.fromEntries( + Object.entries(eligible).filter(([name, entry]) => + allowsMcpDirectoryWidgetReadManifestAction( + name, + entry, + directoryWidgetSession, + options, + ), + ), + ) + : {}; if ( !authenticated && Object.keys(publicEligible).length === 0 && - Object.keys(visibleCapabilityActions).length === 0 + Object.keys(visibleCapabilityActions).length === 0 && + Object.keys(visibleDirectoryWidgetReadActions).length === 0 ) { throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); } setResponseHeader(event, "Cache-Control", "no-store"); - const visible = authCapability?.startsWith("capability:visual-edit:") - ? visibleCapabilityActions - : authenticated - ? eligible - : { ...publicEligible, ...visibleCapabilityActions }; + const visible = directoryWidgetSession + ? visibleDirectoryWidgetReadActions + : authCapability?.startsWith("capability:visual-edit:") + ? visibleCapabilityActions + : authenticated + ? eligible + : { ...publicEligible, ...visibleCapabilityActions }; return Object.entries(visible).map(([name, entry]) => ({ name, title: agentNativeToolTitle(name, entry.tool.title), diff --git a/packages/core/src/server/embed-session.spec.ts b/packages/core/src/server/embed-session.spec.ts index 47c1a629a5a..1504a156b91 100644 --- a/packages/core/src/server/embed-session.spec.ts +++ b/packages/core/src/server/embed-session.spec.ts @@ -43,6 +43,7 @@ import { createEmbedSessionTicket as createEmbedSessionTicketForAudience, hasExplicitEmbedSessionCredential, hasPresentedEmbedSessionCredential, + hasEmbedSessionRequestMarker, isExpiredMcpDirectoryWidgetSessionRequest, readMcpDirectoryWidgetRenewalTicket as readMcpDirectoryWidgetRenewalTicketForAudience, renewMcpDirectoryWidgetSession as renewMcpDirectoryWidgetSessionForAudience, @@ -1159,6 +1160,58 @@ describe("requestMatchesEmbedTarget", () => { ).resolves.toBeNull(); }); + it("resolves card read metadata without admitting other framework routes", async () => { + const scope = createMcpDirectoryWidgetReadCapability({ + appId: "content", + resourceUri: "ui://content/card", + resourceIds: { documentId: "doc-1" }, + actionArguments: { "get-document": { id: "doc-1" } }, + })!; + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/page/doc-1", + scope, + ticketCreatedAtMs: Date.now(), + }); + for (const path of [ + "/_agent-native/webmcp/manifest", + "/_agent-native/webmcp/manifest/", + ]) { + await expect( + resolveEmbedSessionFromRequest(credentialEvent(path, token, "query")), + ).resolves.toMatchObject({ scope }); + } + await expect( + resolveEmbedSessionFromRequest( + credentialEvent("/_agent-native/org/me", token, "query"), + ), + ).resolves.toBeNull(); + }); + + it("distinguishes explicit embed traffic from an ambient capability cookie", () => { + const token = signEmbedSessionToken({ + ownerEmail: "owner@example.com", + audienceHost: "mail.test", + targetPath: "/visual-edit/design_1", + scope: "capability:visual-edit:design:design_1", + }); + const path = "/_agent-native/webmcp/manifest"; + expect( + hasEmbedSessionRequestMarker(credentialEvent(path, token, "cookie")), + ).toBe(false); + expect( + hasEmbedSessionRequestMarker(credentialEvent(path, token, "query")), + ).toBe(true); + expect( + hasEmbedSessionRequestMarker( + credentialEvent(path, token, "bearer", { + [EMBED_TARGET_HEADER]: "/visual-edit/design_1", + }), + ), + ).toBe(true); + }); + it.each([ "/page/github%3Aowner%2Frepo", "/page/local-file%3Adocs%2Freadme%2emd", diff --git a/packages/core/src/server/embed-session.ts b/packages/core/src/server/embed-session.ts index ba5fefe99a4..bbdf1df611a 100644 --- a/packages/core/src/server/embed-session.ts +++ b/packages/core/src/server/embed-session.ts @@ -827,7 +827,8 @@ function isEmbedCapabilityRuntimeRequest( const method = (event as any).req?.method ?? (event as any).node?.req?.method ?? "GET"; return ( - scope.startsWith("capability:visual-edit:design:") && + (scope.startsWith("capability:visual-edit:design:") || + isMcpDirectoryWidgetCapabilityScope(scope)) && method === "GET" && (pathname === "/_agent-native/webmcp/manifest" || pathname === "/_agent-native/webmcp/manifest/") @@ -1960,7 +1961,7 @@ export function hasExplicitEmbedSessionCredential(event: H3Event): boolean { ); } -export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { +export function hasEmbedSessionRequestMarker(event: H3Event): boolean { if ( queryToken(event) || isEmbedModeRequest(event) || @@ -1972,6 +1973,11 @@ export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { if (verifyEmbedSessionToken(bearerToken(event), { allowExpired: true }).ok) { return true; } + return false; +} + +export function hasPresentedEmbedSessionCredential(event: H3Event): boolean { + if (hasEmbedSessionRequestMarker(event)) return true; const cookie = verifyEmbedSessionToken( getCookie(event, EMBED_SESSION_COOKIE), ); diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 956f49a9f59..31822ba5057 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -98,6 +98,7 @@ export { autoMountAuth, registerAuthPublicPaths, getSession, + getBrowserCookieSession, isSessionResolutionUnavailable, getMcpOAuthBearerSession, logout, @@ -121,6 +122,7 @@ export { type AuthSession, type AuthOptions, } from "./auth.js"; +export { hasSameOriginBrowserEvidence } from "./request-origin.js"; export { handleIdentitySso, ensureIdentityUser, diff --git a/templates/design/actions/grant-localhost-write-consent.spec.ts b/templates/design/actions/grant-localhost-write-consent.spec.ts index 94488f73e46..dda1df4edbb 100644 --- a/templates/design/actions/grant-localhost-write-consent.spec.ts +++ b/templates/design/actions/grant-localhost-write-consent.spec.ts @@ -1,3 +1,4 @@ +import type { ActionRunContext } from "@agent-native/core/action"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => { mockExistingGrant: null as GrantRow | null, insertedValues: null as Record | null, updatedSet: null as Record | null, + authCapability: undefined as string | undefined, }; const makeSelectChain = (rows: unknown[]) => ({ @@ -87,6 +89,7 @@ const mocks = vi.hoisted(() => { }); return { + getBrowserCookieSession: vi.fn(), assertAccess, database, resolveLocalhostConnectionScope, @@ -99,8 +102,14 @@ vi.mock("@agent-native/core/sharing", () => ({ assertAccess: mocks.assertAccess, })); +vi.mock("@agent-native/core/server", () => ({ + getBrowserCookieSession: mocks.getBrowserCookieSession, + hasSameOriginBrowserEvidence: () => true, +})); + vi.mock("@agent-native/core/server/request-context", () => ({ - getRequestAuthCapability: () => undefined, + getRequestAuthCapability: () => mocks.state.authCapability, + getRequestContext: () => ({ requestOrigin: "https://design.example.com" }), getRequestUserEmail: () => "user@example.com", getRequestOrgId: () => "org_1", })); @@ -141,6 +150,25 @@ vi.mock("nanoid", () => ({ nanoid: () => "fixed_grant_id" })); import action from "./grant-localhost-write-consent.js"; +function browserContext(): ActionRunContext { + return { + caller: "frontend", + userEmail: "user@example.com", + requestHeaders: new Headers({ + origin: "https://design.example.com", + "sec-fetch-site": "same-origin", + cookie: "better-auth.session_token=fixture-browser-session", + }), + }; +} + +function runAction( + args: Parameters[0], + context = browserContext(), +) { + return action.run(args, context); +} + beforeEach(() => { mocks.state.events.length = 0; mocks.state.inMutation = false; @@ -149,6 +177,10 @@ beforeEach(() => { mocks.state.mockExistingGrant = null; mocks.state.insertedValues = null; mocks.state.updatedSet = null; + mocks.state.authCapability = undefined; + mocks.getBrowserCookieSession.mockReset().mockResolvedValue({ + email: "user@example.com", + }); mocks.assertAccess.mockClear(); mocks.resolveLocalhostConnectionScope.mockClear(); mocks.withDesignSourceMutationTransaction.mockClear(); @@ -169,15 +201,51 @@ describe("grant-localhost-write-consent", () => { ...overrides, }); - it("is available to the capability-scoped visual-edit editor", () => { - expect(action.capabilityScopes).toEqual(["visual-edit"]); - expect(action.agentTool).toBe(false); + it.each([ + "capability-only", + "capability identity with a browser cookie", + "bearer-only", + "MCP card", + "different browser user", + ])("refuses localhost consent from %s", async (credential) => { + mocks.state.mockConnection = connection(); + const context = browserContext(); + if (credential.startsWith("capability")) { + mocks.state.authCapability = "capability:visual-edit:design:design_1"; + } + if (credential === "MCP card") { + context.caller = "mcp-widget"; + mocks.state.authCapability = "capability:mcp-directory-widget:fixture"; + } + if (credential === "capability-only" || credential === "MCP card") { + context.userEmail = undefined; + } + if (credential === "different browser user") { + mocks.getBrowserCookieSession.mockResolvedValue({ + email: "other@example.com", + }); + } else if (credential !== "capability identity with a browser cookie") { + mocks.getBrowserCookieSession.mockResolvedValue(null); + context.requestHeaders?.delete("cookie"); + context.requestHeaders?.set("authorization", "Bearer fixture-token"); + } + + await expect( + runAction({ designId: "design_1", connectionId: "conn_1" }, context), + ).rejects.toMatchObject({ + actionContractError: true, + errorCode: "browser_session_required", + statusCode: 401, + }); + expect(mocks.assertAccess).not.toHaveBeenCalled(); + expect(mocks.state.insertedValues).toBeNull(); + expect(mocks.state.updatedSet).toBeNull(); }); it("persists the connection bridge token without returning it", async () => { mocks.state.mockConnection = connection(); - const result = await action.run({ + const result = await runAction({ designId: "design_1", connectionId: "conn_1", }); @@ -200,7 +268,7 @@ describe("grant-localhost-write-consent", () => { mocks.state.rejectInside = true; await expect( - action.run({ designId: "design_1", connectionId: "conn_1" }), + runAction({ designId: "design_1", connectionId: "conn_1" }), ).rejects.toThrow("editor access revoked"); expect(mocks.state.events).toEqual([ @@ -234,7 +302,7 @@ describe("grant-localhost-write-consent", () => { mocks.state.mockConnection = connection(patch); await expect( - action.run({ designId: "design_1", connectionId: "conn_1" }), + runAction({ designId: "design_1", connectionId: "conn_1" }), ).rejects.toMatchObject({ actionContractError: true, errorCode, @@ -248,7 +316,7 @@ describe("grant-localhost-write-consent", () => { it("returns a typed error when the connection row is not found", async () => { await expect( - action.run({ designId: "design_1", connectionId: "conn_missing" }), + runAction({ designId: "design_1", connectionId: "conn_missing" }), ).rejects.toMatchObject({ actionContractError: true, errorCode: "connection-not-found", @@ -263,7 +331,7 @@ describe("grant-localhost-write-consent", () => { }); mocks.state.mockExistingGrant = { id: "existing_grant_id" }; - const result = await action.run({ + const result = await runAction({ designId: "design_1", connectionId: "conn_1", }); @@ -282,7 +350,7 @@ describe("grant-localhost-write-consent", () => { mocks.state.mockConnection = connection(); const before = Date.now(); - const result = await action.run({ + const result = await runAction({ designId: "design_1", connectionId: "conn_1", }); diff --git a/templates/design/actions/grant-localhost-write-consent.ts b/templates/design/actions/grant-localhost-write-consent.ts index 25f045350c7..18cd1466e2d 100644 --- a/templates/design/actions/grant-localhost-write-consent.ts +++ b/templates/design/actions/grant-localhost-write-consent.ts @@ -1,6 +1,16 @@ import { defineAction, fail } from "@agent-native/core/action"; +import { + getBrowserCookieSession, + hasSameOriginBrowserEvidence, +} from "@agent-native/core/server"; +import { + getRequestAuthCapability, + getRequestContext, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, isNull } from "drizzle-orm"; +import { H3Event } from "h3"; import { nanoid } from "nanoid"; import { z } from "zod"; @@ -28,14 +38,33 @@ export default defineAction({ // token intentionally stays server-side: browser callers only need grant // metadata because write-local-file adds bridge authentication itself. agentTool: false, - capabilityScopes: ["visual-edit"], schema: z.object({ designId: z.string().describe("Design ID."), connectionId: z .string() .describe("Localhost connection ID (from list-localhost-connections)."), }), - run: async ({ designId, connectionId }) => { + run: async ({ designId, connectionId }, ctx) => { + const origin = getRequestContext()?.requestOrigin; + const event = + origin && ctx?.requestHeaders + ? new H3Event(new Request(origin, { headers: ctx.requestHeaders })) + : null; + const session = event ? await getBrowserCookieSession(event) : null; + // Consent uses the account's own access, never the link's editor role. + if ( + !session?.email || + session.email.trim().toLowerCase() !== + getRequestUserEmail()?.trim().toLowerCase() || + getRequestAuthCapability() || + !event || + !hasSameOriginBrowserEvidence(event) + ) { + fail("Unauthorized", { + errorCode: "browser_session_required", + statusCode: 401, + }); + } await assertAccess("design", designId, "editor"); const { ownerEmail, orgId } = await resolveLocalhostConnectionScope({ diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx new file mode 100644 index 00000000000..6228a2660cd --- /dev/null +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment happy-dom + +import type { useSession } from "@agent-native/core/client/hooks"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; + +type SessionStatus = ReturnType["status"]; + +const mocks = vi.hoisted(() => ({ + sessionStatus: "loading" as SessionStatus, + messages: {} as Record, +})); + +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => ({ + ...(await importOriginal()), + useSession: () => ({ + session: + mocks.sessionStatus === "authenticated" + ? { email: "editor@example.test" } + : null, + status: mocks.sessionStatus, + isLoading: mocks.sessionStatus === "loading", + error: null, + retry: () => {}, + }), +})); + +vi.mock("@agent-native/core/client/i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useT: () => (key: string) => { + let value: unknown = mocks.messages; + for (const part of key.split(".")) { + value = (value as Record | undefined)?.[part]; + } + return typeof value === "string" ? value : key; + }, +})); + +import { i18nCatalog } from "@/i18n"; + +import { + LocalhostWriteConsentDialog, + type LocalhostWriteConsentPayload, +} from "./LocalhostWriteConsentDialog"; + +const GRANT_PATH = "/_agent-native/actions/grant-localhost-write-consent"; +const GRANT = { + grantId: "grant-1", + rootPath: "/repo", + grantedUntil: "2026-10-24T08:00:00.000Z", +}; + +describe("LocalhostWriteConsentDialog", () => { + let container: HTMLDivElement; + let root: Root; + let grantResponse: () => Response; + let fetchMock: ReturnType; + let payload: LocalhostWriteConsentPayload; + let onOpenChange: Mock<(open: boolean) => void>; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.messages = i18nCatalog.messages; + mocks.sessionStatus = "loading"; + grantResponse = () => Response.json(GRANT); + fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes(GRANT_PATH)) return grantResponse(); + return Response.json({ error: "Not authenticated" }); + }); + vi.stubGlobal("fetch", fetchMock); + payload = { + rootPath: "/repo", + files: ["src/App.tsx"], + onGranted: vi.fn(), + onCancel: vi.fn(), + onError: vi.fn(), + }; + onOpenChange = vi.fn<(open: boolean) => void>(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + function render() { + act(() => + root.render( + , + ), + ); + } + + function grantCalls() { + return fetchMock.mock.calls.filter(([input]) => + String(input).includes(GRANT_PATH), + ); + } + + function button(label: string) { + return [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + } + + function signInLink() { + return [...document.body.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Sign in", + ); + } + + async function settle() { + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + } + + async function allowWrites() { + const allow = button("Allow writes"); + if (!allow) throw new Error("Allow writes was not rendered"); + await act(async () => allow.click()); + await settle(); + } + + it("asks a signed-out visitor to sign in and sends no grant", async () => { + mocks.sessionStatus = "unauthenticated"; + render(); + await settle(); + + expect(signInLink()?.getAttribute("href")).toContain("/sign-in"); + expect(document.body.querySelector('[role="alert"]')?.textContent).toBe( + "Sign in with an account that has access.", + ); + expect(button("Allow writes")).toBeUndefined(); + expect(grantCalls()).toHaveLength(0); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(payload.onCancel).not.toHaveBeenCalled(); + }); + + it("holds the grant while the session is loading", async () => { + render(); + const allow = button("Allow writes"); + expect(allow?.disabled).toBe(true); + + await act(async () => allow?.click()); + await settle(); + expect(grantCalls()).toHaveLength(0); + }); + + it("grants once the visitor's session resolves signed in", async () => { + mocks.sessionStatus = "unauthenticated"; + render(); + expect(signInLink()).toBeDefined(); + + mocks.sessionStatus = "authenticated"; + render(); + expect(signInLink()).toBeUndefined(); + await allowWrites(); + + expect(grantCalls()).toHaveLength(1); + const [, init] = grantCalls()[0]!; + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + designId: "design-1", + connectionId: "connection-1", + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(payload.onGranted).toHaveBeenCalledWith(GRANT); + expect(payload.onError).not.toHaveBeenCalled(); + expect(payload.onCancel).not.toHaveBeenCalled(); + }); + + it("switches to sign-in when the server refuses a session without browser sign-in", async () => { + // A Visual Edit embed credential can read as a session client-side; only + // the server knows the browser cookie is missing. + mocks.sessionStatus = "authenticated"; + grantResponse = () => + Response.json( + { + error: "Sign in to allow file writes.", + errorCode: "browser_session_required", + }, + { status: 401 }, + ); + render(); + await allowWrites(); + + expect(grantCalls()).toHaveLength(1); + expect(signInLink()).toBeDefined(); + expect(document.body.querySelector('[role="alert"]')?.textContent).toBe( + "Sign in with an account that has access.", + ); + expect(button("Allow writes")).toBeUndefined(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(payload.onError).not.toHaveBeenCalled(); + expect(payload.onCancel).not.toHaveBeenCalled(); + + await settle(); + expect(grantCalls()).toHaveLength(1); + }); + + it("shows a signed-in account's access refusal without asking it to sign in", async () => { + mocks.sessionStatus = "authenticated"; + grantResponse = () => + Response.json({ error: "Forbidden" }, { status: 403 }); + render(); + await allowWrites(); + + expect(grantCalls()).toHaveLength(1); + expect(document.body.querySelector('[role="alert"]')?.textContent).toBe( + "You don't have access", + ); + expect(signInLink()).toBeUndefined(); + expect(button("Allow writes")).toBeUndefined(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(payload.onError).not.toHaveBeenCalled(); + + const cancel = button("Cancel"); + await act(async () => cancel?.click()); + expect(payload.onCancel).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(grantCalls()).toHaveLength(1); + }); +}); diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx index 9586c8c7126..a21248bec25 100644 --- a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx @@ -1,5 +1,12 @@ -import { callAction } from "@agent-native/core/client/hooks"; -import { IconDeviceFloppy, IconFolderOpen } from "@tabler/icons-react"; +import { callAction, useSession } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { buildSignInReturnHref } from "@agent-native/core/client/sign-in-return"; +import { + IconDeviceFloppy, + IconFolderOpen, + IconLock, + IconLogin2, +} from "@tabler/icons-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; @@ -25,6 +32,18 @@ export interface LocalhostWriteConsentPayload { onError?: (error: unknown) => void; } +type ConsentRefusal = "sign-in" | "denied"; + +// The grant needs the visitor's own browser sign-in, which a Visual Edit link +// or embed credential never supplies: 401 asks for that sign-in, 403 means the +// signed-in account itself lacks editor access. +function refusalForGrantError(error: unknown): ConsentRefusal | null { + const status = (error as { status?: unknown } | null)?.status; + if (status === 401) return "sign-in"; + if (status === 403) return "denied"; + return null; +} + interface LocalhostWriteConsentDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -40,10 +59,24 @@ export function LocalhostWriteConsentDialog({ connectionId, payload, }: LocalhostWriteConsentDialogProps) { + const t = useT(); + const { status: sessionStatus } = useSession(); const [granting, setGranting] = useState(false); + const [refusal, setRefusal] = useState<{ + payload: LocalhostWriteConsentPayload; + kind: ConsentRefusal; + } | null>(null); + const sessionPending = + sessionStatus === "loading" || sessionStatus === "signing-out"; + const blocked = + (refusal?.payload === payload ? refusal.kind : null) ?? + (sessionStatus === "unauthenticated" ? "sign-in" : null); async function handleAllowWrites() { - if (!payload || !designId || !connectionId) return; + if (!payload || !designId || !connectionId || sessionPending || blocked) { + return; + } + const requested = payload; setGranting(true); try { const result = await callAction<{ @@ -55,11 +88,16 @@ export function LocalhostWriteConsentDialog({ connectionId, }); onOpenChange(false); - payload.onGranted(result); + requested.onGranted(result); } catch (error) { - payload.onError?.(error); + const kind = refusalForGrantError(error); + if (kind) { + setRefusal({ payload: requested, kind }); + return; + } + requested.onError?.(error); onOpenChange(false); - payload.onCancel(); + requested.onCancel(); } finally { setGranting(false); } @@ -125,24 +163,52 @@ export function LocalhostWriteConsentDialog({ "Only text and code files can be written — never secrets like .env or key files. Paths outside the root folder are always blocked." /* i18n-ignore */ }

+ + {blocked ? ( +
+ + { + blocked === "sign-in" + ? t("agentChat.accessGate.signedOutDescription") // i18n-key-ignore shared framework catalog + : t("agentChat.accessGate.deniedTitle") // i18n-key-ignore shared framework catalog + } +
+ ) : null} - + {blocked === "sign-in" ? ( + + ) : blocked === "denied" ? null : ( + + )} diff --git a/templates/design/changelog/2026-10-24-local-file-write-consent-asks-you-to-sign-in-before-approvin.md b/templates/design/changelog/2026-10-24-local-file-write-consent-asks-you-to-sign-in-before-approvin.md new file mode 100644 index 00000000000..3fd94cb8a4d --- /dev/null +++ b/templates/design/changelog/2026-10-24-local-file-write-consent-asks-you-to-sign-in-before-approvin.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-24 +--- + +Local file write consent asks you to sign in before approving access. From 971f9c347349074d5aa7728d02aad69a668a2de5 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:42:35 -0400 Subject: [PATCH 13/15] Resume localhost consent after sign-in Co-Authored-By: Claude Opus 5.5 --- .../clear-localhost-write-consent-request.ts | 10 +- .../get-localhost-write-consent-request.ts | 42 +- .../localhost-consent-request-state.spec.ts | 206 +++++- .../localhost-consent-request-state.ts | 4 + .../request-localhost-write-consent.ts | 28 +- .../LocalhostWriteConsentDialog.test.tsx | 232 ++++++- .../design/LocalhostWriteConsentDialog.tsx | 447 +++++++++++- templates/design/app/i18n/ar-SA.ts | 12 + templates/design/app/i18n/de-DE.ts | 14 + templates/design/app/i18n/en-US.ts | 13 + templates/design/app/i18n/es-ES.ts | 13 + templates/design/app/i18n/fr-FR.ts | 13 + templates/design/app/i18n/hi-IN.ts | 13 + templates/design/app/i18n/ja-JP.ts | 14 + templates/design/app/i18n/ko-KR.ts | 13 + templates/design/app/i18n/pt-BR.ts | 13 + templates/design/app/i18n/zh-CN.ts | 12 + templates/design/app/i18n/zh-TW.ts | 12 + .../DesignEditor.localhost-write.test.ts | 4 +- .../use-editor-active-screen-and-geometry.ts | 84 +-- .../use-editor-generation-and-access.ts | 28 - .../domains/use-editor-source-and-sync.ts | 21 +- .../localhost-consent-request.ts | 18 +- .../localhost-consent-resume.test.tsx | 646 ++++++++++++++++++ .../design-editor/responsive-interact.test.ts | 30 - .../node-rewrite-and-localhost-dialogs.tsx | 33 +- ...log-brings-you-back-to-the-same-request.md | 6 + templates/design/e2e/helpers.ts | 29 + .../e2e/motion-html-source.browser.spec.ts | 3 +- .../e2e/motion-react-source.browser.spec.ts | 3 +- .../e2e/url-react-jsx-writeback.spec.ts | 4 +- 31 files changed, 1804 insertions(+), 216 deletions(-) create mode 100644 templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx create mode 100644 templates/design/changelog/2026-10-24-signing-in-from-the-file-write-consent-dialog-brings-you-back-to-the-same-request.md diff --git a/templates/design/actions/clear-localhost-write-consent-request.ts b/templates/design/actions/clear-localhost-write-consent-request.ts index cf9bb504e91..a0c3046a102 100644 --- a/templates/design/actions/clear-localhost-write-consent-request.ts +++ b/templates/design/actions/clear-localhost-write-consent-request.ts @@ -13,14 +13,20 @@ export default defineAction({ schema: z.object({ designId: z.string(), requestedAt: z.string(), + // Two requests can share a millisecond; the ID tells them apart. + requestId: z.string().optional(), }), agentTool: false, capabilityScopes: ["visual-edit"], - run: async ({ designId, requestedAt }) => { + run: async ({ designId, requestedAt, requestId }) => { await assertAccess("design", designId, "editor"); const { key, sessionId } = localhostConsentRequestStateAddress(designId); const current = await appStateGet(sessionId, key); - if (!current || current.requestedAt !== requestedAt) { + if ( + !current || + current.requestedAt !== requestedAt || + (requestId !== undefined && current.requestId !== requestId) + ) { return { cleared: false }; } return { diff --git a/templates/design/actions/get-localhost-write-consent-request.ts b/templates/design/actions/get-localhost-write-consent-request.ts index fbe7fa36faa..58af5d95e76 100644 --- a/templates/design/actions/get-localhost-write-consent-request.ts +++ b/templates/design/actions/get-localhost-write-consent-request.ts @@ -3,15 +3,26 @@ import { appStateGet } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { z } from "zod"; -import { localhostConsentRequestStateAddress } from "./localhost-consent-request-state.js"; +import { + LOCALHOST_CONSENT_REQUEST_TTL_MS, + localhostConsentRequestStateAddress, +} from "./localhost-consent-request-state.js"; -const requestSchema = z.object({ - designId: z.string(), - connectionId: z.string(), - rootPath: z.string(), - files: z.array(z.string()), - requestedAt: z.string(), -}); +// Requests queued before IDs existed have no requestId field: a readable one +// still opens (it never matches a sign-in return marker), and an unreadable +// one is skipped. Any record carrying the field claims the current shape, so +// a malformed one fails loudly instead of passing as legacy. +function storedRequestSchemas(designId: string) { + const legacy = z.object({ + designId: z.literal(designId), + connectionId: z.string(), + rootPath: z.string(), + files: z.array(z.string()), + requestedAt: z.iso.datetime({ offset: true }), + requestId: z.string().optional(), + }); + return { legacy, current: legacy.extend({ requestId: z.string().min(1) }) }; +} export default defineAction({ description: "Internal editor handoff for pending localhost write consent.", @@ -25,11 +36,16 @@ export default defineAction({ const { key, sessionId } = localhostConsentRequestStateAddress(designId); const value = await appStateGet(sessionId, key); if (value == null) return { request: null }; - const request = requestSchema.parse(value); - if (request.designId !== designId) { - throw new Error( - "Localhost consent request design does not match its key", - ); + const { legacy, current } = storedRequestSchemas(designId); + const request = Object.prototype.hasOwnProperty.call(value, "requestId") + ? current.parse(value) + : legacy.safeParse(value).data; + if (!request) return { request: null }; + if ( + Date.now() - Date.parse(request.requestedAt) > + LOCALHOST_CONSENT_REQUEST_TTL_MS + ) { + return { request: null, expired: true as const }; } return { request }; }, diff --git a/templates/design/actions/localhost-consent-request-state.spec.ts b/templates/design/actions/localhost-consent-request-state.spec.ts index 2506a6245d2..7f2c384621f 100644 --- a/templates/design/actions/localhost-consent-request-state.spec.ts +++ b/templates/design/actions/localhost-consent-request-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const selectChain = { @@ -68,13 +68,17 @@ import getAction from "./get-localhost-write-consent-request.js"; import { localhostConsentRequestStateAddress } from "./localhost-consent-request-state.js"; import requestAction from "./request-localhost-write-consent.js"; +beforeEach(() => { + vi.clearAllMocks(); +}); + it("reads and clears consent requests from the design capability session", async () => { const request = { designId: "design/public", connectionId: "connection-1", rootPath: "/workspace", files: ["index.html"], - requestedAt: "2026-09-30T20:00:00.000Z", + requestedAt: new Date().toISOString(), }; const key = "design-localhost-write-consent-request:design/public"; const sessionId = "capability:capability:visual-edit:design:design%2Fpublic"; @@ -166,3 +170,201 @@ it("writes requests to the design session for identified and capability-only cal ); } }); + +it("stops offering a consent request once it is older than the TTL", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-10-24T08:00:00.000Z")); + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + const request = { + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: ["index.html"], + requestedAt: "2026-10-24T07:31:00.000Z", + requestId: "request-1", + }; + mocks.appStateGet.mockResolvedValue(request); + await expect(getAction.run({ designId: "design-1" })).resolves.toEqual({ + request, + }); + + mocks.appStateGet.mockResolvedValue({ + ...request, + requestedAt: "2026-10-24T07:29:59.000Z", + }); + await expect(getAction.run({ designId: "design-1" })).resolves.toEqual({ + request: null, + expired: true, + }); + expect(mocks.appStatePut).not.toHaveBeenCalled(); + expect(mocks.appStateCompareAndSet).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } +}); + +it("fails loudly on a stored request with an unreadable time", async () => { + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + mocks.appStateGet.mockResolvedValue({ + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: [], + requestedAt: "not-a-time", + requestId: "request-1", + }); + await expect(getAction.run({ designId: "design-1" })).rejects.toThrow( + /requestedAt/, + ); + expect(mocks.appStateCompareAndSet).not.toHaveBeenCalled(); +}); + +it("still reads requests queued before request IDs existed", async () => { + const legacy = { + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: [], + requestedAt: new Date().toISOString(), + }; + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + mocks.appStateGet.mockResolvedValue(legacy); + await expect(getAction.run({ designId: "design-1" })).resolves.toEqual({ + request: legacy, + }); +}); + +it("skips an unreadable request queued before request IDs existed", async () => { + const legacy = { + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: [], + requestedAt: new Date().toISOString(), + }; + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + for (const stored of [ + { ...legacy, requestedAt: "not-a-time" }, + { ...legacy, files: "index.html" }, + { ...legacy, designId: "design-2" }, + "not-a-request", + ]) { + mocks.appStateGet.mockResolvedValue(stored); + await expect(getAction.run({ designId: "design-1" })).resolves.toEqual({ + request: null, + }); + } + expect(mocks.appStatePut).not.toHaveBeenCalled(); + expect(mocks.appStateCompareAndSet).not.toHaveBeenCalled(); +}); + +it("holds any record with a request ID field to the current shape", async () => { + const current = { + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: [], + requestedAt: new Date().toISOString(), + requestId: "request-1", + }; + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + for (const requestId of ["", null, 42]) { + mocks.appStateGet.mockResolvedValue({ ...current, requestId }); + await expect(getAction.run({ designId: "design-1" })).rejects.toThrow( + /requestId/, + ); + } + mocks.appStateGet.mockResolvedValue({ ...current, designId: "design-2" }); + await expect(getAction.run({ designId: "design-1" })).rejects.toThrow( + /designId/, + ); +}); + +it("keeps a newer request that shares the cleared request's timestamp", async () => { + const requestedAt = "2026-10-24T08:00:00.000Z"; + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + mocks.appStateCompareAndSet.mockReset().mockResolvedValue(true); + mocks.appStateGet.mockResolvedValue({ + designId: "design-1", + connectionId: "connection-1", + rootPath: "/workspace", + files: [], + requestedAt, + requestId: "newer-request", + }); + + await expect( + clearAction.run({ + designId: "design-1", + requestedAt, + requestId: "older-request", + }), + ).resolves.toEqual({ cleared: false }); + expect(mocks.appStateCompareAndSet).not.toHaveBeenCalled(); + + await expect( + clearAction.run({ + designId: "design-1", + requestedAt, + requestId: "newer-request", + }), + ).resolves.toEqual({ cleared: true }); + expect(mocks.appStateCompareAndSet).toHaveBeenCalledTimes(1); +}); + +it("queues an ID'd request for sign-in even while a grant is valid", async () => { + const designId = "design-1"; + const connectionId = "connection-1"; + const validGrant = [ + { grantedUntil: new Date(Date.now() + 60_000).toISOString() }, + ]; + mocks.assertAccess.mockResolvedValue({ role: "editor" }); + mocks.resolveLocalhostConnectionScope.mockResolvedValue({ + ownerEmail: "owner@example.test", + orgId: null, + }); + mocks.readAppStateForCurrentTab.mockResolvedValue(null); + const connection = [ + { rootPath: "/workspace", bridgeToken: "fake-bridge-token" }, + ]; + + mocks.appStatePut.mockClear(); + mocks.selectChain.limit + .mockReset() + .mockResolvedValueOnce(connection) + .mockResolvedValueOnce(validGrant); + await expect( + requestAction.run({ designId, connectionId, files: ["index.html"] }), + ).resolves.toMatchObject({ alreadyGranted: true }); + expect(mocks.appStatePut).not.toHaveBeenCalled(); + + mocks.selectChain.limit + .mockReset() + .mockResolvedValueOnce(connection) + .mockResolvedValueOnce(validGrant); + const queued = await requestAction.run({ + designId, + connectionId, + files: ["index.html"], + resumeAfterSignIn: true, + }); + expect(queued).toMatchObject({ + requestId: expect.any(String), + requestedAt: expect.any(String), + }); + expect(queued).not.toHaveProperty("alreadyGranted"); + expect(mocks.appStatePut).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + { + designId, + connectionId, + rootPath: "/workspace", + files: ["index.html"], + requestedAt: (queued as { requestedAt: string }).requestedAt, + requestId: (queued as { requestId: string }).requestId, + }, + { requestSource: "agent" }, + ); +}); diff --git a/templates/design/actions/localhost-consent-request-state.ts b/templates/design/actions/localhost-consent-request-state.ts index a102d154a3e..7415b8127f7 100644 --- a/templates/design/actions/localhost-consent-request-state.ts +++ b/templates/design/actions/localhost-consent-request-state.ts @@ -1,5 +1,9 @@ import { appStatePut } from "@agent-native/core/application-state"; +// Long enough for a sign-in round trip; an older request no longer reopens +// the dialog on its own. +export const LOCALHOST_CONSENT_REQUEST_TTL_MS = 30 * 60 * 1000; + export function localhostConsentRequestStateAddress(designId: string) { const capability = `capability:visual-edit:design:${encodeURIComponent(designId)}`; return { diff --git a/templates/design/actions/request-localhost-write-consent.ts b/templates/design/actions/request-localhost-write-consent.ts index 51c643c0b76..122426e9bab 100644 --- a/templates/design/actions/request-localhost-write-consent.ts +++ b/templates/design/actions/request-localhost-write-consent.ts @@ -2,6 +2,7 @@ import { defineAction } from "@agent-native/core/action"; import { readAppStateForCurrentTab } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, isNull } from "drizzle-orm"; +import { nanoid } from "nanoid"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -25,9 +26,16 @@ export default defineAction({ .array(z.string()) .optional() .describe("File paths about to be written, shown in the dialog."), + resumeAfterSignIn: z + .boolean() + .optional() + .describe( + "Editor-only: queue the request even while a grant is valid, so the " + + "dialog can reopen after the visitor signs in. Agents omit this.", + ), }), capabilityScopes: ["visual-edit"], - run: async ({ designId, connectionId, files }) => { + run: async ({ designId, connectionId, files, resumeAfterSignIn }) => { await assertAccess("design", designId, "editor"); const { ownerEmail, orgId } = await resolveLocalhostConnectionScope({ @@ -82,7 +90,11 @@ export default defineAction({ ), ) .limit(1); - if (grant && grant.grantedUntil > new Date().toISOString()) { + if ( + !resumeAfterSignIn && + grant && + grant.grantedUntil > new Date().toISOString() + ) { return { designId, connectionId, @@ -92,12 +104,15 @@ export default defineAction({ }; } + const requestId = nanoid(); + const requestedAt = new Date().toISOString(); await putLocalhostConsentRequest(designId, { designId, connectionId, rootPath: connection.rootPath, files: files ?? [], - requestedAt: new Date().toISOString(), + requestedAt, + requestId, }); const navigation = await readAppStateForCurrentTab("navigation").catch( @@ -111,6 +126,8 @@ export default defineAction({ return { designId, connectionId, + requestId, + requestedAt, surfaced: true, message: "Opened the write-consent dialog. Ask the user to click 'Allow " + @@ -121,11 +138,14 @@ export default defineAction({ return { designId, connectionId, + requestId, + requestedAt, surfaced: false, message: "Queued a write-consent request, but the user is not on this design's " + "editor so no dialog is showing yet. Ask them to open the design; the " + - "prompt appears then, and you can retry write-local-file after they approve.", + "prompt appears then, and you can retry write-local-file after they " + + "approve. The request expires after 30 minutes.", }; }, }); diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx index 6228a2660cd..17855c57af6 100644 --- a/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.test.tsx @@ -18,8 +18,11 @@ type SessionStatus = ReturnType["status"]; const mocks = vi.hoisted(() => ({ sessionStatus: "loading" as SessionStatus, messages: {} as Record, + toastError: vi.fn(), })); +vi.mock("sonner", () => ({ toast: { error: mocks.toastError } })); + vi.mock("@agent-native/core/client/hooks", async (importOriginal) => ({ ...(await importOriginal()), useSession: () => ({ @@ -45,6 +48,8 @@ vi.mock("@agent-native/core/client/i18n", async (importOriginal) => ({ }, })); +import { decodeContinuation } from "@agent-native/core/shared/sign-in-journey"; + import { i18nCatalog } from "@/i18n"; import { @@ -53,6 +58,7 @@ import { } from "./LocalhostWriteConsentDialog"; const GRANT_PATH = "/_agent-native/actions/grant-localhost-write-consent"; +const REQUEST_PATH = "/_agent-native/actions/request-localhost-write-consent"; const GRANT = { grantId: "grant-1", rootPath: "/repo", @@ -63,6 +69,9 @@ describe("LocalhostWriteConsentDialog", () => { let container: HTMLDivElement; let root: Root; let grantResponse: () => Response; + let requestResponse: () => Response; + let requestBodies: unknown[]; + let requestHold: Promise | null; let fetchMock: ReturnType; let payload: LocalhostWriteConsentPayload; let onOpenChange: Mock<(open: boolean) => void>; @@ -72,9 +81,25 @@ describe("LocalhostWriteConsentDialog", () => { mocks.messages = i18nCatalog.messages; mocks.sessionStatus = "loading"; grantResponse = () => Response.json(GRANT); - fetchMock = vi.fn(async (input: RequestInfo | URL) => { + requestResponse = () => + Response.json({ + designId: "design-1", + connectionId: "connection-1", + requestId: "request-2", + requestedAt: "2026-10-24T08:00:00.000Z", + surfaced: true, + }); + requestBodies = []; + requestHold = null; + mocks.toastError.mockReset(); + fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); if (url.includes(GRANT_PATH)) return grantResponse(); + if (url.includes(REQUEST_PATH)) { + requestBodies.push(JSON.parse(String(init?.body))); + if (requestHold) await requestHold; + return requestResponse(); + } return Response.json({ error: "Not authenticated" }); }); vi.stubGlobal("fetch", fetchMock); @@ -97,15 +122,17 @@ describe("LocalhostWriteConsentDialog", () => { vi.unstubAllGlobals(); }); - function render() { + function render( + props: { designId?: string; payload?: LocalhostWriteConsentPayload } = {}, + ) { act(() => root.render( , ), ); @@ -242,4 +269,201 @@ describe("LocalhostWriteConsentDialog", () => { expect(onOpenChange).toHaveBeenCalledWith(false); expect(grantCalls()).toHaveLength(1); }); + + async function clickSignIn(): Promise { + const assigned: string[] = []; + const assign = vi + .spyOn(window.location, "assign") + .mockImplementation((url: string | URL) => { + assigned.push(String(url)); + }); + try { + const link = signInLink(); + if (!link) throw new Error("Sign in was not rendered"); + await act(async () => link.click()); + await settle(); + } finally { + assign.mockRestore(); + } + return assigned; + } + + it("saves the request before sign-in and returns with only its ID", async () => { + mocks.sessionStatus = "unauthenticated"; + window.history.replaceState( + null, + "", + "/visual-edit/design-1?screen=s1&embed=fake-link-token#layer", + ); + try { + render(); + const assigned = await clickSignIn(); + + expect(requestBodies).toEqual([ + { + designId: "design-1", + connectionId: "connection-1", + files: ["src/App.tsx"], + resumeAfterSignIn: true, + }, + ]); + expect(assigned).toHaveLength(1); + const target = new URL(assigned[0]!, window.location.origin); + expect(target.pathname).toBe("/sign-in"); + expect(decodeContinuation(target.searchParams.get("c"))).toBe( + "/visual-edit/design-1?localhostConsent=request-2", + ); + expect(grantCalls()).toHaveLength(0); + expect(payload.onGranted).not.toHaveBeenCalled(); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(payload.onError).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + } finally { + window.history.replaceState(null, "", "/"); + } + }); + + it("stays put when the request cannot be saved for sign-in", async () => { + mocks.sessionStatus = "unauthenticated"; + render(); + + requestResponse = () => + Response.json({ error: "Connection is not ready" }, { status: 500 }); + expect(await clickSignIn()).toEqual([]); + + requestResponse = () => + Response.json({ designId: "design-1", alreadyGranted: true }); + expect(await clickSignIn()).toEqual([]); + + expect(requestBodies).toHaveLength(2); + expect(signInLink()).toBeDefined(); + expect(grantCalls()).toHaveLength(0); + expect(payload.onGranted).not.toHaveBeenCalled(); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("keeps a failed grant open so it can be tried again", async () => { + mocks.sessionStatus = "authenticated"; + grantResponse = () => + Response.json({ error: "Bridge unavailable" }, { status: 500 }); + render(); + await allowWrites(); + + expect(payload.onError).toHaveBeenCalledTimes(1); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(button("Allow writes")?.disabled).toBe(false); + + grantResponse = () => Response.json(GRANT); + await allowWrites(); + expect(grantCalls()).toHaveLength(2); + expect(payload.onGranted).toHaveBeenCalledWith(GRANT); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("reports a failed grant when the caller has no error handler", async () => { + mocks.sessionStatus = "authenticated"; + grantResponse = () => + Response.json({ error: "Bridge unavailable" }, { status: 500 }); + const { onError: _onError, ...withoutHandler } = payload; + render({ payload: withoutHandler }); + await allowWrites(); + + expect(mocks.toastError).toHaveBeenCalledWith("Bridge unavailable"); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(button("Allow writes")).toBeDefined(); + }); + + function holdRequests(): () => Promise { + let release!: () => void; + requestHold = new Promise((resolve) => { + release = resolve; + }); + return async () => { + requestHold = null; + release(); + await settle(); + }; + } + + function watchAssign() { + const assigned: string[] = []; + const spy = vi + .spyOn(window.location, "assign") + .mockImplementation((url: string | URL) => { + assigned.push(String(url)); + }); + return { assigned, restore: () => spy.mockRestore() }; + } + + async function startSignIn() { + const link = signInLink(); + if (!link) throw new Error("Sign in was not rendered"); + await act(async () => link.click()); + await settle(); + expect(requestBodies).toHaveLength(1); + } + + it("ignores a sign-in save that finishes after its request is gone", async () => { + mocks.sessionStatus = "unauthenticated"; + const assign = watchAssign(); + try { + const endings: Array<() => void> = [ + () => render({ designId: "design-2" }), + () => render({ payload: { ...payload, files: ["src/Other.tsx"] } }), + () => act(() => root.unmount()), + ]; + for (const end of endings) { + act(() => root.unmount()); + root = createRoot(container); + requestBodies = []; + render(); + const release = holdRequests(); + await startSignIn(); + end(); + await release(); + } + expect(assign.assigned).toEqual([]); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(payload.onGranted).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(mocks.toastError).not.toHaveBeenCalled(); + } finally { + assign.restore(); + } + }); + + it("keeps the dialog and its starting page while the sign-in save is in flight", async () => { + mocks.sessionStatus = "unauthenticated"; + window.history.replaceState(null, "", "/visual-edit/design-1?screen=s1"); + const assign = watchAssign(); + try { + render(); + const release = holdRequests(); + await startSignIn(); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(payload.onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.body.querySelector('[role="dialog"]')).not.toBeNull(); + + window.history.replaceState(null, "", "/somewhere-else"); + await release(); + expect(assign.assigned).toHaveLength(1); + const target = new URL(assign.assigned[0]!, window.location.origin); + expect(decodeContinuation(target.searchParams.get("c"))).toBe( + "/visual-edit/design-1?localhostConsent=request-2", + ); + } finally { + assign.restore(); + window.history.replaceState(null, "", "/"); + } + }); }); diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx index a21248bec25..11e00500c1e 100644 --- a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx @@ -1,13 +1,30 @@ -import { callAction, useSession } from "@agent-native/core/client/hooks"; +import { + actionErrorMessage, + callAction, + useActionMutation, + useActionQuery, + useSession, +} from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { buildSignInReturnHref } from "@agent-native/core/client/sign-in-return"; import { + IconCircleCheck, IconDeviceFloppy, IconFolderOpen, + IconInfoCircle, IconLock, IconLogin2, + IconMessage, } from "@tabler/icons-react"; -import { useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent, +} from "react"; +import { useLocation, useNavigate } from "react-router"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -19,6 +36,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; +import { sendToDesignAgentChat } from "@/lib/agent-chat"; +import { + LOCALHOST_CONSENT_RETURN_PARAM, + localhostConsentRequestDisposition, + localhostConsentRequestRefetchInterval, + localhostConsentReturnPath, +} from "@/pages/design-editor/localhost-consent-request"; export interface LocalhostWriteConsentPayload { rootPath: string; @@ -62,16 +86,30 @@ export function LocalhostWriteConsentDialog({ const t = useT(); const { status: sessionStatus } = useSession(); const [granting, setGranting] = useState(false); + const [preparingFor, setPreparingFor] = + useState(null); const [refusal, setRefusal] = useState<{ payload: LocalhostWriteConsentPayload; kind: ConsentRefusal; } | null>(null); + const preparingSignIn = preparingFor !== null && preparingFor === payload; const sessionPending = sessionStatus === "loading" || sessionStatus === "signing-out"; const blocked = (refusal?.payload === payload ? refusal.kind : null) ?? (sessionStatus === "unauthenticated" ? "sign-in" : null); + // A sign-in save belongs to the request, design, and connection it started + // from; once any of them changes or the dialog unmounts, its late result + // must not navigate. + const signInOwnerRef = useRef(null); + useEffect( + () => () => { + signInOwnerRef.current = null; + }, + [payload, designId, connectionId], + ); + async function handleAllowWrites() { if (!payload || !designId || !connectionId || sessionPending || blocked) { return; @@ -95,14 +133,53 @@ export function LocalhostWriteConsentDialog({ setRefusal({ payload: requested, kind }); return; } - requested.onError?.(error); - onOpenChange(false); - requested.onCancel(); + // The request stays open and pending so Allow writes can be retried. + if (requested.onError) requested.onError(error); + else toast.error(actionErrorMessage(error) ?? t("common.genericError")); } finally { setGranting(false); } } + // Sign-in reloads the page, so this mount's callback cannot survive it. + // Only the request metadata goes to the server and only its ID rides the + // return URL; the reopened dialog grants for whoever signed in. + async function handleSignIn(event: MouseEvent) { + event.preventDefault(); + if (!payload || !designId || !connectionId || preparingSignIn) return; + const requested = payload; + const owner = {}; + signInOwnerRef.current = owner; + const returnPath = window.location.pathname; + setPreparingFor(requested); + try { + const queued = await callAction<{ requestId?: string }>( + "request-localhost-write-consent", + { + designId, + connectionId, + files: requested.files, + resumeAfterSignIn: true, + }, + ); + if (signInOwnerRef.current !== owner) return; + if (!queued?.requestId) { + toast.error(t("designEditor.localhostConsent.prepareSignInError")); + return; + } + window.location.assign( + buildSignInReturnHref({ + returnTo: localhostConsentReturnPath(returnPath, queued.requestId), + }), + ); + } catch (error) { + if (signInOwnerRef.current !== owner) return; + toast.error(actionErrorMessage(error) ?? t("common.genericError")); + } finally { + setPreparingFor((current) => (current === requested ? null : current)); + } + } + function handleCancel() { payload?.onCancel(); onOpenChange(false); @@ -114,8 +191,11 @@ export function LocalhostWriteConsentDialog({ { - if (!next) handleCancel(); - else onOpenChange(next); + // Closing mid-save would leave the copy saved for sign-in pending, + // and it would reopen; the dialog waits for the save like Cancel does. + if (!next) { + if (!preparingSignIn) handleCancel(); + } else onOpenChange(next); }} > @@ -180,13 +260,31 @@ export function LocalhostWriteConsentDialog({ - {blocked === "sign-in" ? ( ); } + +export type LocalhostWriteConsentNotice = + | { kind: "approved"; rootPath: string } + | { kind: "unavailable" }; + +export interface LocalhostWriteConsentLocalRequest { + connectionId: string; + rootPath: string; + files: string[]; + onGranted: LocalhostWriteConsentPayload["onGranted"]; + onCancel?: () => void; + onError?: LocalhostWriteConsentPayload["onError"]; +} + +interface PendingConsentRequest { + designId: string; + connectionId: string; + rootPath: string; + files: string[]; + requestedAt: string; + requestId?: string; +} + +function consentRequestKey(designId: string, request: PendingConsentRequest) { + return `${designId}:${request.requestedAt}:${request.requestId ?? ""}`; +} + +/** + * Editor-wide owner of the write-consent dialog. Agent requests (and requests + * saved for sign-in) live on the server until the user grants or cancels, so + * a fresh mount after sign-in reopens the same request. A request that comes + * back from the server has no edit callback: approving it asks the user to run + * the edit again instead of replaying anything. + */ +export function useLocalhostWriteConsent({ + designId, + canEditDesign, +}: { + designId: string | undefined; + canEditDesign: boolean; +}) { + const t = useT(); + const location = useLocation(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [payload, setPayload] = useState( + null, + ); + const [connectionId, setConnectionId] = useState(""); + const [notice, setNotice] = useState( + null, + ); + const [failedClearKey, setFailedClearKey] = useState(null); + const lastHandledKeyRef = useRef(null); + const resolvedReturnRequestIdRef = useRef(null); + const payloadRef = useRef(payload); + payloadRef.current = payload; + const returnRequestId = new URLSearchParams(location.search).get( + LOCALHOST_CONSENT_RETURN_PARAM, + ); + + // Agent requests run in a design-scoped capability session, separate from + // the browser's app-state session, so retrieve the handoff through an editor + // action instead of reading it from client app state. + const requestQuery = useActionQuery( + "get-localhost-write-consent-request", + { designId: designId ?? "" }, + { + enabled: Boolean(designId && canEditDesign), + refetchInterval: (query) => { + const request = query.state.data?.request; + return localhostConsentRequestRefetchInterval({ + requestKey: + request && designId ? consentRequestKey(designId, request) : null, + failedClearKey, + queryFailed: query.state.status === "error", + }); + }, + notifyOnChangeProps: [ + "data", + "error", + "status", + ...(failedClearKey ? (["dataUpdatedAt"] as const) : []), + ...(returnRequestId ? (["isFetchedAfterMount"] as const) : []), + ], + }, + ); + const clearRequestAsync = useActionMutation( + "clear-localhost-write-consent-request", + ).mutateAsync; + const refetchRequest = requestQuery.refetch; + + const clearRequest = useCallback( + (request: PendingConsentRequest, quiet: boolean) => { + if (!designId) return; + const requestKey = consentRequestKey(designId, request); + void clearRequestAsync({ + designId, + requestedAt: request.requestedAt, + ...(request.requestId ? { requestId: request.requestId } : {}), + }) + .then(async () => { + setFailedClearKey(null); + await refetchRequest(); + }) + .catch((error) => { + setFailedClearKey(requestKey); + if (!quiet) { + toast.error(actionErrorMessage(error) ?? t("common.genericError")); + } + }); + }, + [clearRequestAsync, designId, refetchRequest, t], + ); + + const request = requestQuery.data?.request ?? null; + const requestLoaded = + requestQuery.status === "success" && requestQuery.isFetchedAfterMount; + + useEffect(() => { + if (!designId || !canEditDesign || !requestLoaded) return; + if ( + returnRequestId && + resolvedReturnRequestIdRef.current !== returnRequestId + ) { + resolvedReturnRequestIdRef.current = returnRequestId; + const search = new URLSearchParams(location.search); + search.delete(LOCALHOST_CONSENT_RETURN_PARAM); + const nextSearch = search.toString(); + void navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : "", + hash: location.hash, + }, + { replace: true }, + ); + if ( + !request || + request.designId !== designId || + !request.connectionId || + request.requestId !== returnRequestId + ) { + setNotice({ kind: "unavailable" }); + return; + } + } + if (!request || request.designId !== designId || !request.connectionId) { + return; + } + const requestKey = consentRequestKey(designId, request); + const disposition = localhostConsentRequestDisposition({ + requestKey, + lastHandledKey: lastHandledKeyRef.current, + failedClearKey, + }); + if (disposition === "ignore") return; + if (disposition === "retry-clear") { + clearRequest(request, true); + return; + } + // Wait for the dialog or notice on screen; this request stays pending. + if (payload || notice) return; + lastHandledKeyRef.current = requestKey; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearRequest(request, false); + }; + setConnectionId(request.connectionId); + setPayload({ + rootPath: request.rootPath, + files: request.files, + onGranted: () => { + settle(); + setNotice({ kind: "approved", rootPath: request.rootPath }); + }, + onCancel: settle, + onError: (error) => { + toast.error(actionErrorMessage(error) ?? t("common.genericError")); + }, + }); + setOpen(true); + }, [ + canEditDesign, + clearRequest, + designId, + failedClearKey, + location.hash, + location.pathname, + location.search, + navigate, + notice, + payload, + request, + requestLoaded, + requestQuery.dataUpdatedAt, + returnRequestId, + t, + ]); + + const openLocalConsent = useCallback( + (local: LocalhostWriteConsentLocalRequest) => { + setConnectionId(local.connectionId); + setPayload({ + rootPath: local.rootPath, + files: local.files, + onGranted: local.onGranted, + onCancel: local.onCancel ?? (() => {}), + onError: local.onError, + }); + setOpen(true); + }, + [], + ); + + const onOpenChange = useCallback((next: boolean) => { + if (!next) { + payloadRef.current?.onCancel(); + setPayload(null); + } + setOpen(next); + }, []); + + const closeNotice = useCallback(() => setNotice(null), []); + + return { + open, + payload, + connectionId, + notice, + openLocalConsent, + onOpenChange, + closeNotice, + }; +} + +export type LocalhostWriteConsentState = ReturnType< + typeof useLocalhostWriteConsent +>; + +function LocalhostWriteConsentNoticeDialog({ + notice, + onClose, +}: { + notice: LocalhostWriteConsentNotice | null; + onClose: () => void; +}) { + const t = useT(); + if (!notice) return null; + const approved = notice.kind === "approved"; + return ( + { + if (!next) onClose(); + }} + > + + + + {approved ? ( + + ) : ( + + )} + {approved + ? t("designEditor.localhostConsent.approvedTitle") + : t("designEditor.localhostConsent.unavailableTitle")} + + + {approved + ? t("designEditor.localhostConsent.approvedDescription") + : t("designEditor.localhostConsent.unavailableDescription")} + + + + + {notice.kind === "approved" ? ( + + ) : null} + + + + ); +} + +export function LocalhostWriteConsentSurface({ + designId, + consent, +}: { + designId: string; + consent: LocalhostWriteConsentState; +}) { + return ( + <> + {consent.connectionId ? ( + + ) : null} + + + ); +} diff --git a/templates/design/app/i18n/ar-SA.ts b/templates/design/app/i18n/ar-SA.ts index 292824a4239..02680f20500 100644 --- a/templates/design/app/i18n/ar-SA.ts +++ b/templates/design/app/i18n/ar-SA.ts @@ -2166,6 +2166,18 @@ export default { added: "تمت إضافة الشاشة", addFailed: "تعذرت إضافة الشاشة", }, + localhostConsent: { + approvedTitle: "تمت الموافقة — شغّل التعديل مرة أخرى", + approvedDescription: + "الكتابة إلى الملفات مسموح بها لمدة 8 ساعات. لم يُحفظ التعديل الذي طلب الوصول.", + retryInChat: "إعادة التشغيل في الدردشة", + retryPrompt: + "تمت الموافقة على الكتابة إلى الملفات في {{rootPath}}. شغّل التعديل الذي احتاج إليها مرة أخرى.", + unavailableTitle: "لم يعد طلب الكتابة هذا معلقًا", + unavailableDescription: "شغّل التعديل مرة أخرى لطلب الوصول.", + close: "إغلاق", + prepareSignInError: "تعذّر حفظ هذا الطلب قبل تسجيل الدخول. حاول مرة أخرى.", + }, nodeRewrite: { composerTitle: "اسأل عن التحديد أو غيّره", send: "إرسال", diff --git a/templates/design/app/i18n/de-DE.ts b/templates/design/app/i18n/de-DE.ts index 1e4076c1e44..c00d46ddc11 100644 --- a/templates/design/app/i18n/de-DE.ts +++ b/templates/design/app/i18n/de-DE.ts @@ -2214,6 +2214,20 @@ export default { added: "Screen hinzugefügt", addFailed: "Screen konnte nicht hinzugefügt werden", }, + localhostConsent: { + approvedTitle: "Genehmigt – führe die Änderung erneut aus", + approvedDescription: + "Dateischreibzugriff ist für 8 Stunden erlaubt. Die Änderung, die den Zugriff angefordert hat, wurde nicht gespeichert.", + retryInChat: "Im Chat erneut ausführen", + retryPrompt: + "Dateischreibzugriff für {{rootPath}} ist genehmigt. Führe die Änderung, die ihn brauchte, erneut aus.", + unavailableTitle: "Diese Schreibanfrage steht nicht mehr aus", + unavailableDescription: + "Führe die Änderung erneut aus, um Zugriff anzufordern.", + close: "Schließen", + prepareSignInError: + "Diese Anfrage konnte vor der Anmeldung nicht gespeichert werden. Versuche es erneut.", + }, nodeRewrite: { composerTitle: "Auswahl fragen oder ändern", send: "Senden", diff --git a/templates/design/app/i18n/en-US.ts b/templates/design/app/i18n/en-US.ts index 2d7ac7572cc..5e9ac38bf5b 100644 --- a/templates/design/app/i18n/en-US.ts +++ b/templates/design/app/i18n/en-US.ts @@ -2119,6 +2119,19 @@ export default { added: "Screen added", addFailed: "Could not add screen", }, + localhostConsent: { + approvedTitle: "Approved — run the edit again", + approvedDescription: + "File writes are allowed for 8 hours. The edit that asked for access wasn't saved.", + retryInChat: "Run again in chat", + retryPrompt: + "File writes are approved for {{rootPath}}. Run the edit that needed them again.", + unavailableTitle: "This write request is no longer pending", + unavailableDescription: "Run the edit again to ask for access.", + close: "Close", + prepareSignInError: + "Couldn't save this request before sign-in. Try again.", + }, nodeRewrite: { composerTitle: "Ask or change selection", send: "Send", diff --git a/templates/design/app/i18n/es-ES.ts b/templates/design/app/i18n/es-ES.ts index 5b84f74fbfa..e2623e45805 100644 --- a/templates/design/app/i18n/es-ES.ts +++ b/templates/design/app/i18n/es-ES.ts @@ -2206,6 +2206,19 @@ export default { added: "Pantalla añadida", addFailed: "No se pudo añadir la pantalla", }, + localhostConsent: { + approvedTitle: "Aprobado: vuelve a ejecutar la edición", + approvedDescription: + "La escritura de archivos está permitida durante 8 horas. La edición que pidió acceso no se guardó.", + retryInChat: "Volver a ejecutar en el chat", + retryPrompt: + "La escritura de archivos está aprobada para {{rootPath}}. Vuelve a ejecutar la edición que la necesitaba.", + unavailableTitle: "Esta solicitud de escritura ya no está pendiente", + unavailableDescription: "Vuelve a ejecutar la edición para pedir acceso.", + close: "Cerrar", + prepareSignInError: + "No se pudo guardar esta solicitud antes de iniciar sesión. Inténtalo de nuevo.", + }, nodeRewrite: { composerTitle: "Pregunta o cambia la selección", send: "Enviar", diff --git a/templates/design/app/i18n/fr-FR.ts b/templates/design/app/i18n/fr-FR.ts index 50c9b770e11..f507a6dcfb1 100644 --- a/templates/design/app/i18n/fr-FR.ts +++ b/templates/design/app/i18n/fr-FR.ts @@ -2214,6 +2214,19 @@ export default { added: "Écran ajouté", addFailed: "Impossible d’ajouter l’écran", }, + localhostConsent: { + approvedTitle: "Approuvé — relancez la modification", + approvedDescription: + "L'écriture de fichiers est autorisée pendant 8 heures. La modification qui a demandé l'accès n'a pas été enregistrée.", + retryInChat: "Relancer dans le chat", + retryPrompt: + "L'écriture de fichiers est approuvée pour {{rootPath}}. Relance la modification qui en avait besoin.", + unavailableTitle: "Cette demande d'écriture n'est plus en attente", + unavailableDescription: "Relancez la modification pour demander l'accès.", + close: "Fermer", + prepareSignInError: + "Impossible d'enregistrer cette demande avant la connexion. Réessayez.", + }, nodeRewrite: { composerTitle: "Questionner ou modifier la sélection", send: "Envoyer", diff --git a/templates/design/app/i18n/hi-IN.ts b/templates/design/app/i18n/hi-IN.ts index 871ffc523ae..a8ea4a385a7 100644 --- a/templates/design/app/i18n/hi-IN.ts +++ b/templates/design/app/i18n/hi-IN.ts @@ -2169,6 +2169,19 @@ export default { added: "Screen जोड़ी गई", addFailed: "Screen नहीं जोड़ी जा सकी", }, + localhostConsent: { + approvedTitle: "स्वीकृत — संपादन फिर से चलाएँ", + approvedDescription: + "फ़ाइल लिखने की अनुमति 8 घंटे के लिए है। जिस संपादन ने एक्सेस माँगा था, वह सहेजा नहीं गया।", + retryInChat: "चैट में फिर से चलाएँ", + retryPrompt: + "{{rootPath}} के लिए फ़ाइल लिखने की अनुमति स्वीकृत है। जिस संपादन को इसकी ज़रूरत थी, उसे फिर से चलाएँ।", + unavailableTitle: "यह लिखने का अनुरोध अब लंबित नहीं है", + unavailableDescription: "एक्सेस माँगने के लिए संपादन फिर से चलाएँ।", + close: "बंद करें", + prepareSignInError: + "साइन इन से पहले यह अनुरोध सहेजा नहीं जा सका। फिर से कोशिश करें।", + }, nodeRewrite: { composerTitle: "चयन के बारे में पूछें या बदलें", send: "भेजें", diff --git a/templates/design/app/i18n/ja-JP.ts b/templates/design/app/i18n/ja-JP.ts index 7316907c674..709f351dc3a 100644 --- a/templates/design/app/i18n/ja-JP.ts +++ b/templates/design/app/i18n/ja-JP.ts @@ -2204,6 +2204,20 @@ export default { added: "画面を追加しました", addFailed: "画面を追加できませんでした", }, + localhostConsent: { + approvedTitle: "承認済み — 編集をもう一度実行してください", + approvedDescription: + "ファイルの書き込みは 8 時間許可されます。アクセスを求めた編集は保存されていません。", + retryInChat: "チャットで再実行", + retryPrompt: + "{{rootPath}} へのファイル書き込みが承認されました。これを必要とした編集をもう一度実行してください。", + unavailableTitle: "この書き込みリクエストは保留中ではなくなりました", + unavailableDescription: + "アクセスを求めるには、編集をもう一度実行してください。", + close: "閉じる", + prepareSignInError: + "サインイン前にこのリクエストを保存できませんでした。もう一度お試しください。", + }, nodeRewrite: { composerTitle: "選択範囲について質問または変更", send: "送信", diff --git a/templates/design/app/i18n/ko-KR.ts b/templates/design/app/i18n/ko-KR.ts index 57d8aacd19b..70151e6ccff 100644 --- a/templates/design/app/i18n/ko-KR.ts +++ b/templates/design/app/i18n/ko-KR.ts @@ -2181,6 +2181,19 @@ export default { added: "화면이 추가되었습니다", addFailed: "화면을 추가할 수 없습니다", }, + localhostConsent: { + approvedTitle: "승인됨 — 편집을 다시 실행하세요", + approvedDescription: + "파일 쓰기가 8시간 동안 허용됩니다. 액세스를 요청한 편집은 저장되지 않았습니다.", + retryInChat: "채팅에서 다시 실행", + retryPrompt: + "{{rootPath}}에 대한 파일 쓰기가 승인되었습니다. 이 권한이 필요했던 편집을 다시 실행하세요.", + unavailableTitle: "이 쓰기 요청은 더 이상 대기 중이 아닙니다", + unavailableDescription: "액세스를 요청하려면 편집을 다시 실행하세요.", + close: "닫기", + prepareSignInError: + "로그인하기 전에 이 요청을 저장하지 못했습니다. 다시 시도하세요.", + }, nodeRewrite: { composerTitle: "선택 영역 질문 또는 변경", send: "보내기", diff --git a/templates/design/app/i18n/pt-BR.ts b/templates/design/app/i18n/pt-BR.ts index f3e54e21779..ff760ceb1a2 100644 --- a/templates/design/app/i18n/pt-BR.ts +++ b/templates/design/app/i18n/pt-BR.ts @@ -2198,6 +2198,19 @@ export default { added: "Tela adicionada", addFailed: "Não foi possível adicionar a tela", }, + localhostConsent: { + approvedTitle: "Aprovado — execute a edição novamente", + approvedDescription: + "A gravação de arquivos está permitida por 8 horas. A edição que pediu acesso não foi salva.", + retryInChat: "Executar novamente no chat", + retryPrompt: + "A gravação de arquivos foi aprovada para {{rootPath}}. Execute novamente a edição que precisava dela.", + unavailableTitle: "Esta solicitação de gravação não está mais pendente", + unavailableDescription: "Execute a edição novamente para pedir acesso.", + close: "Fechar", + prepareSignInError: + "Não foi possível salvar esta solicitação antes de entrar. Tente novamente.", + }, nodeRewrite: { composerTitle: "Pergunte ou altere a seleção", send: "Enviar", diff --git a/templates/design/app/i18n/zh-CN.ts b/templates/design/app/i18n/zh-CN.ts index e2477d36895..28d47f563c5 100644 --- a/templates/design/app/i18n/zh-CN.ts +++ b/templates/design/app/i18n/zh-CN.ts @@ -2131,6 +2131,18 @@ export default { added: "已添加屏幕", addFailed: "无法添加屏幕", }, + localhostConsent: { + approvedTitle: "已批准 — 请重新运行编辑", + approvedDescription: + "文件写入权限有效期为 8 小时。请求访问权限的那次编辑未保存。", + retryInChat: "在聊天中重新运行", + retryPrompt: + "已批准对 {{rootPath}} 的文件写入。请重新运行需要此权限的编辑。", + unavailableTitle: "此写入请求已不再待处理", + unavailableDescription: "重新运行编辑以请求访问权限。", + close: "关闭", + prepareSignInError: "无法在登录前保存此请求。请重试。", + }, nodeRewrite: { composerTitle: "询问或更改所选内容", send: "发送", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 1d9a1058e6b..9baa2ae43a0 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -2120,6 +2120,18 @@ export default { added: "已新增畫面", addFailed: "無法新增畫面", }, + localhostConsent: { + approvedTitle: "已核准 — 請重新執行編輯", + approvedDescription: + "檔案寫入權限有效期為 8 小時。要求存取權的那次編輯未儲存。", + retryInChat: "在聊天中重新執行", + retryPrompt: + "已核准對 {{rootPath}} 的檔案寫入。請重新執行需要此權限的編輯。", + unavailableTitle: "此寫入要求已不再待處理", + unavailableDescription: "重新執行編輯以要求存取權。", + close: "關閉", + prepareSignInError: "無法在登入前儲存此要求。請再試一次。", + }, capabilities: { "deterministic-style-edit": "安全樣式編輯", "deterministic-class-edit": "安全 class 編輯", diff --git a/templates/design/app/pages/DesignEditor.localhost-write.test.ts b/templates/design/app/pages/DesignEditor.localhost-write.test.ts index c9cd45c78f5..f90a12f5377 100644 --- a/templates/design/app/pages/DesignEditor.localhost-write.test.ts +++ b/templates/design/app/pages/DesignEditor.localhost-write.test.ts @@ -57,14 +57,14 @@ describe("resolveLocalhostSourceWriteContent", () => { }); describe("localhostConsentRequestDisposition", () => { - it("shows and clears a new request", () => { + it("shows a new request", () => { expect( localhostConsentRequestDisposition({ requestKey: "design:requested-at", lastHandledKey: null, failedClearKey: null, }), - ).toBe("show-and-clear"); + ).toBe("show"); }); it("retries a failed clear without showing the request again", () => { diff --git a/templates/design/app/pages/design-editor/domains/use-editor-active-screen-and-geometry.ts b/templates/design/app/pages/design-editor/domains/use-editor-active-screen-and-geometry.ts index 8d8345eea97..704f922f292 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-active-screen-and-geometry.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-active-screen-and-geometry.ts @@ -1,7 +1,6 @@ import { useActionQuery, useActionMutation, - actionErrorMessage, callAction, tryCallActionKeepalive, readClientAppState, @@ -36,7 +35,7 @@ import { toast } from "sonner"; import { trace } from "@/components/design/design-trace"; import type { DesignMigrationResult } from "@/components/design/editor/MakeRealDialog"; -import { type LocalhostWriteConsentPayload } from "@/components/design/LocalhostWriteConsentDialog"; +import { useLocalhostWriteConsent } from "@/components/design/LocalhostWriteConsentDialog"; import { deviceViewportFloorForWidth } from "@/components/design/multi-screen/frame-geometry"; import { resolveScreenHeightMode } from "@/components/design/multi-screen/screen-height"; import type { KScaleStyleChangesByFrameId } from "@/components/design/multi-screen/types"; @@ -97,7 +96,6 @@ import { warnIfPoisonedBoardCoordsNormalized, } from "../html-layer-positioning"; import { applyKScaleStyleChanges } from "../k-scale"; -import { localhostConsentRequestDisposition } from "../localhost-consent-request"; import { type MotionTimelineQueryResult } from "../motion-state"; import { clampOverviewDisplayZoom, @@ -354,10 +352,6 @@ export function useEditorActiveScreenAndGeometry({ activeBreakpointStateVersion, designAccessRole, canEditDesign, - failedLocalhostConsentClear, - setFailedLocalhostConsentClear, - localhostConsentRequestQuery, - clearLocalhostConsentRequestMutation, creativeContextLab, canEditDesignRef, updateDesignMutation, @@ -460,13 +454,10 @@ export function useEditorActiveScreenAndGeometry({ const frameGeometryMutationChainRef = useRef>( Promise.resolve(), ); - const [localhostWriteConsentOpen, setLocalhostWriteConsentOpen] = - useState(false); - const [localhostWriteConsentPayload, setLocalhostWriteConsentPayload] = - useState(null); - const [localhostConsentConnectionId, setLocalhostConsentConnectionId] = - useState(""); - const lastLocalhostConsentRequestRef = useRef(null); + const localhostWriteConsent = useLocalhostWriteConsent({ + designId: id, + canEditDesign, + }); const saveDesignDataAsync = useActionMutation("update-design", { skipActionQueryInvalidation: true, }).mutateAsync; @@ -1507,64 +1498,6 @@ export function useEditorActiveScreenAndGeometry({ }; }, [activeBreakpointStateVersion, designBreakpoints, id, isSignedIn]); - // Agent requests run in a design-scoped capability session, separate from - // the browser's app-state session, so retrieve the handoff through an editor - // action instead of reading it from client app state. - useEffect(() => { - const request = localhostConsentRequestQuery.data?.request; - if ( - !id || - !canEditDesign || - !request || - request.designId !== id || - !request.connectionId - ) { - return; - } - const requestKey = `${id}:${request.requestedAt}`; - const disposition = localhostConsentRequestDisposition({ - requestKey, - lastHandledKey: lastLocalhostConsentRequestRef.current, - failedClearKey: failedLocalhostConsentClear, - }); - if (disposition === "ignore") return; - const retryingClear = disposition === "retry-clear"; - if (!retryingClear) { - lastLocalhostConsentRequestRef.current = requestKey; - setLocalhostConsentConnectionId(request.connectionId); - setLocalhostWriteConsentPayload({ - rootPath: request.rootPath, - files: request.files, - onGranted: () => { - toast.success("File writes allowed for 8 hours." /* i18n-ignore */); - }, - onCancel: () => {}, - }); - setLocalhostWriteConsentOpen(true); - } - void clearLocalhostConsentRequestMutation - .mutateAsync({ designId: id, requestedAt: request.requestedAt }) - .then(async () => { - setFailedLocalhostConsentClear(null); - await localhostConsentRequestQuery.refetch(); - }) - .catch((error) => { - setFailedLocalhostConsentClear(requestKey); - if (!retryingClear) { - toast.error(actionErrorMessage(error) ?? t("common.genericError")); - } - }); - }, [ - canEditDesign, - clearLocalhostConsentRequestMutation.mutateAsync, - failedLocalhostConsentClear, - id, - localhostConsentRequestQuery.dataUpdatedAt, - localhostConsentRequestQuery.refetch, - localhostConsentRequestQuery.data?.request, - t, - ]); - const activeScreenBaseWidthPx = useMemo(() => { if (!activeFile?.id) return null; const metadataByFileId = getDesignDataRecord( @@ -2071,12 +2004,7 @@ export function useEditorActiveScreenAndGeometry({ reviewAuditedAt, reviewAuditError, applyGeometryHistoryContentChangesRef, - localhostWriteConsentOpen, - setLocalhostWriteConsentOpen, - localhostWriteConsentPayload, - setLocalhostWriteConsentPayload, - localhostConsentConnectionId, - setLocalhostConsentConnectionId, + localhostWriteConsent, persistActiveBreakpoint, makeRealDialogOpen, setMakeRealDialogOpen, diff --git a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts index 79e1e236eda..65245ae5bf5 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts @@ -90,7 +90,6 @@ import { import { type GeometryHistorySelection } from "../history"; import { createLatestWriteQueue } from "../latest-write-queue"; import { reconcileLiveCollaborationOverride } from "../live-collaboration-override"; -import { localhostConsentRequestRefetchInterval } from "../localhost-consent-request"; import { applyMcpDirectoryWidgetReadOnlyPolicy, applyMcpDirectoryWidgetWritePolicy, @@ -907,29 +906,6 @@ export function useEditorGenerationAndAccess({ ), readOnlyWidget, ); - const [failedLocalhostConsentClear, setFailedLocalhostConsentClear] = - useState(null); - const localhostConsentRequestQuery = useActionQuery( - "get-localhost-write-consent-request", - { designId: id ?? "" }, - { - enabled: Boolean(id && canEditDesign), - refetchInterval: (query) => { - const request = query.state.data?.request; - return localhostConsentRequestRefetchInterval({ - requestKey: request ? `${id}:${request.requestedAt}` : null, - failedClearKey: failedLocalhostConsentClear, - queryFailed: query.state.status === "error", - }); - }, - notifyOnChangeProps: failedLocalhostConsentClear - ? ["data", "error", "status", "dataUpdatedAt"] - : ["data", "error", "status"], - }, - ); - const clearLocalhostConsentRequestMutation = useActionMutation( - "clear-localhost-write-consent-request", - ); const visualEditSnapshotPublicationStateRef = useRef(null); if (!visualEditSnapshotPublicationStateRef.current) { @@ -1681,10 +1657,6 @@ export function useEditorGenerationAndAccess({ publicVisualEdit, canCommentDesign, canRenderAuthenticatedShare, - failedLocalhostConsentClear, - setFailedLocalhostConsentClear, - localhostConsentRequestQuery, - clearLocalhostConsentRequestMutation, visualEditSnapshotPublicationState, liveCollaborationSaving, liveCollaborationEnabled, diff --git a/templates/design/app/pages/design-editor/domains/use-editor-source-and-sync.ts b/templates/design/app/pages/design-editor/domains/use-editor-source-and-sync.ts index 736b5e1e211..800be40df02 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-source-and-sync.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-source-and-sync.ts @@ -240,9 +240,7 @@ export function useEditorSourceAndSync({ remoteVisualEditPending, } = editorFilesAndSaving; const { - setLocalhostWriteConsentOpen, - setLocalhostWriteConsentPayload, - setLocalhostConsentConnectionId, + localhostWriteConsent, activeFile, activeOverviewScreen, activeScreenBridgeUrl, @@ -254,6 +252,7 @@ export function useEditorSourceAndSync({ handleZoomOut, handleCycleFile, } = editorActiveScreenAndGeometry; + const { openLocalConsent } = localhostWriteConsent; const { localhostConnectionRootPathByIdRef, setHoveredElementScreenId, @@ -1788,15 +1787,14 @@ export function useEditorSourceAndSync({ : undefined) ?? connectionId; - setLocalhostConsentConnectionId(connectionId); - setLocalhostWriteConsentPayload({ + openLocalConsent({ + connectionId, rootPath, files: opts.files, onGranted: opts.onGranted, - onCancel: opts.onCancel ?? (() => {}), + onCancel: opts.onCancel, onError: opts.onError, }); - setLocalhostWriteConsentOpen(true); return "opened"; }, [ @@ -1806,6 +1804,7 @@ export function useEditorSourceAndSync({ canEditDesign, id, localhostConnectionRootPathByIdRef, + openLocalConsent, ], ); requestLocalhostWriteRef.current = requestLocalhostWrite; @@ -1847,8 +1846,8 @@ export function useEditorSourceAndSync({ const handleWorkbenchLocalWriteConsent = useCallback( (connectionId: string, retry: () => void, filePath?: string) => { if (!id || !canEditDesign) return; - setLocalhostConsentConnectionId(connectionId); - setLocalhostWriteConsentPayload({ + openLocalConsent({ + connectionId, rootPath: workbenchLocalhostConnections.find( (connection) => connection.connectionId === connectionId, @@ -1859,11 +1858,9 @@ export function useEditorSourceAndSync({ connectionId, files: filePath ? [filePath] : [], onGranted: () => retry(), - onCancel: () => {}, }); - setLocalhostWriteConsentOpen(true); }, - [canEditDesign, id, workbenchLocalhostConnections], + [canEditDesign, id, openLocalConsent, workbenchLocalhostConnections], ); return { diff --git a/templates/design/app/pages/design-editor/localhost-consent-request.ts b/templates/design/app/pages/design-editor/localhost-consent-request.ts index 140d6537880..b3b11ddea08 100644 --- a/templates/design/app/pages/design-editor/localhost-consent-request.ts +++ b/templates/design/app/pages/design-editor/localhost-consent-request.ts @@ -3,6 +3,20 @@ export type LocalhostWriteConsentRequestOutcome = | "missing-connection" | "not-editable"; +// Carries only the server request ID through sign-in. It selects which +// pending request to reopen; the grant still checks the signed-in account. +export const LOCALHOST_CONSENT_RETURN_PARAM = "localhostConsent"; + +export function localhostConsentReturnPath( + pathname: string, + requestId: string, +): string { + const search = new URLSearchParams({ + [LOCALHOST_CONSENT_RETURN_PARAM]: requestId, + }); + return `${pathname}?${search}`; +} + export function isLocalhostWriteConsentError(error: unknown): boolean { return ( error instanceof Error && @@ -20,8 +34,8 @@ export function localhostConsentRequestDisposition({ requestKey: string; lastHandledKey: string | null; failedClearKey: string | null; -}): "show-and-clear" | "retry-clear" | "ignore" { - if (lastHandledKey !== requestKey) return "show-and-clear"; +}): "show" | "retry-clear" | "ignore" { + if (lastHandledKey !== requestKey) return "show"; return failedClearKey === requestKey ? "retry-clear" : "ignore"; } diff --git a/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx b/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx new file mode 100644 index 00000000000..c28c4312290 --- /dev/null +++ b/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx @@ -0,0 +1,646 @@ +// @vitest-environment happy-dom + +import type { useSession } from "@agent-native/core/client/hooks"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, useLocation } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type SessionStatus = ReturnType["status"]; + +// The editor's three consent actions run for real behind a fetch-level fake +// server; only their storage, access, and database edges are stubbed. The +// grant itself is answered at the transport because it needs browser cookies. +const mocks = vi.hoisted(() => { + const appState = new Map>(); + const tables = { + designLocalhostConnections: { + id: "c.id", + ownerEmail: "c.ownerEmail", + orgId: "c.orgId", + rootPath: "c.rootPath", + bridgeToken: "c.bridgeToken", + }, + designLocalhostWriteGrants: { + designId: "g.designId", + connectionId: "g.connectionId", + ownerEmail: "g.ownerEmail", + orgId: "g.orgId", + grantedUntil: "g.grantedUntil", + }, + }; + return { + appState, + tables, + sessionStatus: "authenticated" as SessionStatus, + messages: {} as Record, + db: { + select: () => { + let table: unknown; + const chain = { + from: (from: unknown) => { + table = from; + return chain; + }, + where: () => chain, + limit: async () => + table === tables.designLocalhostConnections + ? [{ rootPath: "/repo", bridgeToken: "fake-bridge-token" }] + : [], + }; + return chain; + }, + }, + }; +}); + +vi.mock("@agent-native/core/action", () => ({ + defineAction: (config: unknown) => config, +})); +vi.mock("@agent-native/core/application-state", () => ({ + appStateGet: async (sessionId: string, key: string) => + mocks.appState.get(`${sessionId}|${key}`) ?? null, + appStatePut: async ( + sessionId: string, + key: string, + value: Record, + ) => { + mocks.appState.set(`${sessionId}|${key}`, value); + }, + appStateCompareAndSet: async ( + sessionId: string, + key: string, + expected: unknown, + next: Record | null, + ) => { + const address = `${sessionId}|${key}`; + if ( + JSON.stringify(mocks.appState.get(address) ?? null) !== + JSON.stringify(expected) + ) { + return false; + } + if (next === null) mocks.appState.delete(address); + else mocks.appState.set(address, next); + return true; + }, + readAppStateForCurrentTab: async () => null, +})); +vi.mock("@agent-native/core/sharing", () => ({ + assertAccess: async () => ({ role: "editor" }), +})); +vi.mock("../../../server/db/index.js", () => ({ + getDb: () => mocks.db, + schema: mocks.tables, +})); +vi.mock("../../../server/lib/localhost-connection.js", () => ({ + resolveLocalhostConnectionScope: async () => ({ + ownerEmail: "owner@example.test", + orgId: null, + }), +})); +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => ({ + ...(await importOriginal()), + useSession: () => ({ + session: + mocks.sessionStatus === "authenticated" + ? { email: "editor@example.test" } + : null, + status: mocks.sessionStatus, + isLoading: mocks.sessionStatus === "loading", + error: null, + retry: () => {}, + }), +})); +vi.mock("@agent-native/core/client/i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useT: () => (key: string, values?: Record) => { + let value: unknown = mocks.messages; + for (const part of key.split(".")) { + value = (value as Record | undefined)?.[part]; + } + if (typeof value !== "string") return key; + return value.replace(/\{\{(\w+)\}\}/g, (_, name) => values?.[name] ?? ""); + }, +})); + +import { decodeContinuation } from "@agent-native/core/shared/sign-in-journey"; + +import { + LocalhostWriteConsentSurface, + useLocalhostWriteConsent, + type LocalhostWriteConsentState, +} from "@/components/design/LocalhostWriteConsentDialog"; +import { i18nCatalog } from "@/i18n"; + +import clearAction from "../../../actions/clear-localhost-write-consent-request"; +import getAction from "../../../actions/get-localhost-write-consent-request"; +import { localhostConsentRequestStateAddress } from "../../../actions/localhost-consent-request-state"; +import requestAction from "../../../actions/request-localhost-write-consent"; + +const DESIGN_ID = "design-1"; +const CONNECTION_ID = "connection-1"; +const ACTIONS = "/_agent-native/actions/"; +const GRANT = { + grantId: "grant-1", + rootPath: "/repo", + grantedUntil: "2026-10-24T16:00:00.000Z", +}; + +type ServerRecord = { + designId: string; + connectionId: string; + rootPath: string; + files: string[]; + requestedAt: string; + requestId?: string; +}; + +const runnable: Record< + string, + { run: (input: never) => Promise } | undefined +> = { + "get-localhost-write-consent-request": getAction as never, + "clear-localhost-write-consent-request": clearAction as never, + "request-localhost-write-consent": requestAction as never, +}; + +const { key: STATE_KEY, sessionId: STATE_SESSION } = + localhostConsentRequestStateAddress(DESIGN_ID); + +function serverRecord(): ServerRecord | null { + return (mocks.appState.get(`${STATE_SESSION}|${STATE_KEY}`) ?? + null) as ServerRecord | null; +} + +function setServerRecord(record: ServerRecord) { + mocks.appState.set(`${STATE_SESSION}|${STATE_KEY}`, record); +} + +describe("localhost write consent across sign-in", () => { + let container: HTMLDivElement; + let root: Root | null; + let latest: LocalhostWriteConsentState | null; + let routerLocation: { pathname: string; search: string } | null; + let grantStatus: 200 | 401 | 500; + let requestHold: Promise | null; + let calls: Array<{ name: string; body: Record }>; + let chatMessages: Array>; + + const onChatMessage = (event: MessageEvent) => { + if (event.data?.type === "agentNative.submitChat") { + chatMessages.push(event.data.data); + } + }; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.messages = i18nCatalog.messages; + mocks.sessionStatus = "authenticated"; + mocks.appState.clear(); + grantStatus = 200; + requestHold = null; + calls = []; + chatMessages = []; + latest = null; + routerLocation = null; + window.addEventListener("message", onChatMessage); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL( + String(input instanceof Request ? input.url : input), + window.location.origin, + ); + const name = url.pathname.slice( + url.pathname.indexOf(ACTIONS) + ACTIONS.length, + ); + const body = + init?.method === "GET" || !init?.body + ? Object.fromEntries(url.searchParams) + : (JSON.parse(String(init.body)) as Record); + calls.push({ name, body }); + if (name === "grant-localhost-write-consent") { + if (grantStatus === 500) { + return Response.json( + { error: "Bridge unavailable" }, + { status: 500 }, + ); + } + return grantStatus === 200 + ? Response.json(GRANT) + : Response.json( + { + error: "Sign in to allow file writes.", + errorCode: "browser_session_required", + }, + { status: 401 }, + ); + } + const action = runnable[name]; + if (!action) { + throw new Error(`Unexpected action request: ${url.pathname}`); + } + try { + const result = await action.run(body as never); + if (requestHold && name === "request-localhost-write-consent") { + await requestHold; + } + return Response.json(result); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + }), + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + unmount(); + container.remove(); + window.removeEventListener("message", onChatMessage); + window.history.replaceState(null, "", "/"); + vi.unstubAllGlobals(); + }); + + function Harness({ canEditDesign }: { canEditDesign: boolean }) { + const consent = useLocalhostWriteConsent({ + designId: DESIGN_ID, + canEditDesign, + }); + const location = useLocation(); + latest = consent; + routerLocation = location; + return ( + + ); + } + + // Each mount is a page load: its own query cache and URL. + async function mountAt(path: string, canEditDesign = true) { + window.history.replaceState(null, "", path); + root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + act(() => + root!.render( + + + + + , + ), + ); + await settle(); + } + + function unmount() { + if (!root) return; + act(() => root!.unmount()); + root = null; + } + + async function settle() { + for (let i = 0; i < 8; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + } + + function dialogTitled(title: string) { + return [...document.body.querySelectorAll('[role="dialog"]')].find( + (dialog) => dialog.textContent?.includes(title), + ); + } + + function button(label: string) { + return [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + } + + async function click(label: string) { + const target = button(label); + if (!target) throw new Error(`${label} was not rendered`); + await act(async () => target.click()); + await settle(); + } + + // Sign-in is a full-page navigation: capture it, then end this mount the way + // a page unload would, without closing the dialog. + async function signInAndLeave(): Promise { + const assigned: string[] = []; + const assign = vi + .spyOn(window.location, "assign") + .mockImplementation((url: string | URL) => { + assigned.push(String(url)); + }); + try { + const link = [...document.body.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Sign in", + ); + if (!link) throw new Error("Sign in was not rendered"); + await act(async () => link.click()); + await settle(); + } finally { + assign.mockRestore(); + } + expect(assigned).toHaveLength(1); + unmount(); + const target = new URL(assigned[0]!, window.location.origin); + expect(target.pathname).toBe("/sign-in"); + const returnTo = decodeContinuation(target.searchParams.get("c")); + if (!returnTo) throw new Error("Sign-in link has no return path"); + return returnTo; + } + + function callsTo(name: string) { + return calls.filter((call) => call.name === name); + } + + function lastBodyTo(name: string) { + const matching = callsTo(name); + return matching[matching.length - 1]?.body; + } + + const copy = () => + (i18nCatalog.messages as any).designEditor.localhostConsent as Record< + string, + string + >; + + it("reopens an agent's request after sign-in and offers an explicit retry", async () => { + await requestAction.run({ + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + files: ["src/App.tsx"], + }); + const agentRequest = serverRecord()!; + expect(agentRequest.requestId).toEqual(expect.any(String)); + + // A Visual Edit link session reads as signed in on the client; only the + // grant learns the browser has no sign-in of its own. + grantStatus = 401; + await mountAt(`/visual-edit/${DESIGN_ID}?screen=s1`); + expect(dialogTitled("Allow file writes")?.textContent).toContain( + "src/App.tsx", + ); + expect(serverRecord()).toEqual(agentRequest); + + await click("Allow writes"); + expect(callsTo("grant-localhost-write-consent")).toHaveLength(1); + const returnTo = await signInAndLeave(); + + const resumable = serverRecord()!; + expect(resumable.requestId).not.toBe(agentRequest.requestId); + expect(returnTo).toBe( + `/visual-edit/${DESIGN_ID}?localhostConsent=${resumable.requestId}`, + ); + expect(callsTo("clear-localhost-write-consent-request")).toHaveLength(0); + + grantStatus = 200; + await mountAt(returnTo); + expect(dialogTitled("Allow file writes")?.textContent).toContain( + "src/App.tsx", + ); + expect(routerLocation?.search).toBe(""); + + await click("Allow writes"); + expect(lastBodyTo("grant-localhost-write-consent")).toEqual({ + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + }); + expect(lastBodyTo("clear-localhost-write-consent-request")).toEqual({ + designId: DESIGN_ID, + requestedAt: resumable.requestedAt, + requestId: resumable.requestId, + }); + expect(serverRecord()).toBeNull(); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + expect(dialogTitled(copy().approvedTitle)).toBeDefined(); + expect(chatMessages).toHaveLength(0); + + await click(copy().retryInChat); + expect(chatMessages).toEqual([ + expect.objectContaining({ + message: + "File writes are approved for /repo. Run the edit that needed them again.", + submit: false, + openSidebar: true, + }), + ]); + expect(dialogTitled(copy().approvedTitle)).toBeUndefined(); + }); + + it("never replays a local edit's callback after sign-in", async () => { + mocks.sessionStatus = "unauthenticated"; + await mountAt(`/design/${DESIGN_ID}`); + const onGranted = vi.fn(); + const onCancel = vi.fn(); + act(() => + latest!.openLocalConsent({ + connectionId: CONNECTION_ID, + rootPath: "/repo", + files: ["src/Card.tsx"], + onGranted, + onCancel, + }), + ); + await settle(); + expect(serverRecord()).toBeNull(); + + const returnTo = await signInAndLeave(); + expect(lastBodyTo("request-localhost-write-consent")).toEqual({ + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + files: ["src/Card.tsx"], + resumeAfterSignIn: true, + }); + expect(serverRecord()).toMatchObject({ files: ["src/Card.tsx"] }); + + mocks.sessionStatus = "authenticated"; + await mountAt(returnTo); + await click("Allow writes"); + + expect(dialogTitled(copy().approvedTitle)).toBeDefined(); + expect(serverRecord()).toBeNull(); + expect(onGranted).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + expect(chatMessages).toHaveLength(0); + }); + + it("runs a local edit's callback after its own grant on the same page", async () => { + await mountAt(`/design/${DESIGN_ID}`); + const onGranted = vi.fn(); + act(() => + latest!.openLocalConsent({ + connectionId: CONNECTION_ID, + rootPath: "/repo", + files: ["src/Card.tsx"], + onGranted, + }), + ); + await settle(); + await click("Allow writes"); + + expect(onGranted).toHaveBeenCalledWith(GRANT); + expect(dialogTitled(copy().approvedTitle)).toBeUndefined(); + expect(callsTo("request-localhost-write-consent")).toHaveLength(0); + }); + + it("says a returned request is no longer pending when it was cleared, expired, or replaced", async () => { + // Edit access gates the handoff, not a browser sign-in. + mocks.sessionStatus = "unauthenticated"; + const now = Date.now(); + const base = { + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + rootPath: "/repo", + files: ["src/App.tsx"], + }; + + await mountAt(`/design/${DESIGN_ID}?localhostConsent=cleared-request`); + expect(dialogTitled(copy().unavailableTitle)).toBeDefined(); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + expect(routerLocation?.search).toBe(""); + unmount(); + + setServerRecord({ + ...base, + requestedAt: new Date(now - 31 * 60 * 1000).toISOString(), + requestId: "expired-request", + }); + await mountAt(`/design/${DESIGN_ID}?localhostConsent=expired-request`); + expect(dialogTitled(copy().unavailableTitle)).toBeDefined(); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + unmount(); + + setServerRecord({ + ...base, + requestedAt: new Date(now).toISOString(), + requestId: "newer-request", + }); + await mountAt(`/design/${DESIGN_ID}?localhostConsent=returned-request`); + expect(dialogTitled(copy().unavailableTitle)).toBeDefined(); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + + await click(copy().close); + expect(dialogTitled(copy().unavailableTitle)).toBeUndefined(); + expect(dialogTitled("Allow file writes")).toBeDefined(); + + expect(callsTo("grant-localhost-write-consent")).toHaveLength(0); + expect(callsTo("clear-localhost-write-consent-request")).toHaveLength(0); + expect(serverRecord()?.requestId).toBe("newer-request"); + }); + + it("cancels only the request it showed, never a newer one", async () => { + const requestedAt = new Date().toISOString(); + const shown = { + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + rootPath: "/repo", + files: ["src/App.tsx"], + requestedAt, + requestId: "shown-request", + }; + setServerRecord(shown); + await mountAt(`/design/${DESIGN_ID}`); + expect(dialogTitled("Allow file writes")).toBeDefined(); + + const newer = { ...shown, files: ["src/Next.tsx"], requestId: "newer" }; + setServerRecord(newer); + await click("Cancel"); + + expect(lastBodyTo("clear-localhost-write-consent-request")).toEqual({ + designId: DESIGN_ID, + requestedAt, + requestId: "shown-request", + }); + expect(serverRecord()).toEqual(newer); + expect(dialogTitled("Allow file writes")?.textContent).toContain( + "src/Next.tsx", + ); + }); + + it("keeps a request pending through a failed grant and approves it on retry", async () => { + await requestAction.run({ + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + files: ["src/App.tsx"], + }); + const pending = serverRecord()!; + + grantStatus = 500; + await mountAt(`/design/${DESIGN_ID}`); + await click("Allow writes"); + expect(dialogTitled("Allow file writes")).toBeDefined(); + expect(serverRecord()).toEqual(pending); + expect(callsTo("clear-localhost-write-consent-request")).toHaveLength(0); + + grantStatus = 200; + await click("Allow writes"); + expect(callsTo("grant-localhost-write-consent")).toHaveLength(2); + expect(callsTo("clear-localhost-write-consent-request")).toEqual([ + { + name: "clear-localhost-write-consent-request", + body: { + designId: DESIGN_ID, + requestedAt: pending.requestedAt, + requestId: pending.requestId, + }, + }, + ]); + expect(serverRecord()).toBeNull(); + expect(dialogTitled(copy().approvedTitle)).toBeDefined(); + }); + + it("drops a sign-in save that finishes after the editor has gone", async () => { + await requestAction.run({ + designId: DESIGN_ID, + connectionId: CONNECTION_ID, + files: ["src/App.tsx"], + }); + grantStatus = 401; + await mountAt(`/visual-edit/${DESIGN_ID}`); + await click("Allow writes"); + + let release!: () => void; + requestHold = new Promise((resolve) => { + release = resolve; + }); + const assigned: string[] = []; + const assign = vi + .spyOn(window.location, "assign") + .mockImplementation((url: string | URL) => { + assigned.push(String(url)); + }); + try { + const link = [...document.body.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Sign in", + ); + if (!link) throw new Error("Sign in was not rendered"); + await act(async () => link.click()); + await settle(); + const saved = serverRecord()!; + + unmount(); + requestHold = null; + release(); + await settle(); + + expect(assigned).toEqual([]); + expect(callsTo("clear-localhost-write-consent-request")).toHaveLength(0); + expect(serverRecord()).toEqual(saved); + } finally { + assign.mockRestore(); + } + }); +}); diff --git a/templates/design/app/pages/design-editor/responsive-interact.test.ts b/templates/design/app/pages/design-editor/responsive-interact.test.ts index 68fb80e147a..92a2c699b9f 100644 --- a/templates/design/app/pages/design-editor/responsive-interact.test.ts +++ b/templates/design/app/pages/design-editor/responsive-interact.test.ts @@ -161,36 +161,6 @@ describe("responsive Interact wiring", () => { }); it("gates the visual-edit loop on edit access, never on sign-in", () => { - const consentAnchor = - "const localhostConsentRequestQuery = useActionQuery("; - const consentIndex = source.indexOf(consentAnchor); - expect(consentIndex).toBeGreaterThan(0); - const consentQuery = source.slice(consentIndex, consentIndex + 700); - expect(consentQuery).toContain("enabled: Boolean(id && canEditDesign)"); - expect(consentQuery).not.toContain("isSignedIn"); - expect(consentQuery).toContain("localhostConsentRequestRefetchInterval({"); - expect(consentQuery).toContain( - "failedClearKey: failedLocalhostConsentClear", - ); - - const consentEffect = source.slice( - source.indexOf( - "// Agent requests run in a design-scoped capability session", - ), - source.indexOf("const activeScreenBaseWidthPx"), - ); - expect(consentEffect).toMatch(/!id\s*\|\|\s*!canEditDesign/); - expect(consentEffect).not.toMatch(/!id\s*\|\|\s*!isSignedIn/); - expect(consentEffect).toContain("localhostConsentRequestQuery.refetch()"); - expect(consentEffect).toContain("setFailedLocalhostConsentClear"); - expect(consentEffect).not.toContain( - "lastLocalhostConsentRequestRef.current ===", - ); - expect(consentEffect).toContain('if (disposition === "ignore") return;'); - expect(consentEffect).toContain( - 'const retryingClear = disposition === "retry-clear";', - ); - const commandChannel = source.slice( source.indexOf("designEditorCommandFromSearchParams(\n") - 600, source.indexOf("designEditorCommandKey()];"), diff --git a/templates/design/app/pages/design-editor/view/node-rewrite-and-localhost-dialogs.tsx b/templates/design/app/pages/design-editor/view/node-rewrite-and-localhost-dialogs.tsx index db1cca6fde0..48a32061d8a 100644 --- a/templates/design/app/pages/design-editor/view/node-rewrite-and-localhost-dialogs.tsx +++ b/templates/design/app/pages/design-editor/view/node-rewrite-and-localhost-dialogs.tsx @@ -1,5 +1,5 @@ import { AddLocalhostScreenDialog } from "@/components/design/AddLocalhostScreenDialog"; -import { LocalhostWriteConsentDialog } from "@/components/design/LocalhostWriteConsentDialog"; +import { LocalhostWriteConsentSurface } from "@/components/design/LocalhostWriteConsentDialog"; import { NodeRewriteProposal as NodeRewriteProposalPanel } from "@/components/visual-editor/NodeRewriteProposal"; import type { NodeRewriteProposal } from "../../../../shared/node-rewrite"; @@ -12,7 +12,6 @@ export function renderNodeRewriteAndLocalhostDialogs({ editorFilesAndSaving, editorActiveScreenAndGeometry, editorLayerActions, - editorSourceAndSync, id, activeNodeRewriteProposal, addLocalhostScreenConnectionId, @@ -26,19 +25,12 @@ export function renderNodeRewriteAndLocalhostDialogs({ addLocalhostScreenConnectionId: string | undefined; }) { const { addLocalhostScreenPosition } = editorFilesAndSaving; - const { - localhostConsentConnectionId, - localhostWriteConsentOpen, - localhostWriteConsentPayload, - setLocalhostWriteConsentPayload, - setLocalhostWriteConsentOpen, - } = editorActiveScreenAndGeometry; + const { localhostWriteConsent } = editorActiveScreenAndGeometry; const { addLocalhostScreenOpen, setAddLocalhostScreenOpen, addLocalhostScreenFallbackPaths, } = editorLayerActions; - const { activeLocalhostConnectionId } = editorSourceAndSync; return ( <> @@ -52,23 +44,14 @@ export function renderNodeRewriteAndLocalhostDialogs({ ) : null} {/* Localhost write-consent dialog: shown when the agent or editor wants to - persist an edit to a local HTML/CSS source file and no valid grant - exists for the active connection yet. */} - {id && (activeLocalhostConnectionId || localhostConsentConnectionId) && ( - { - if (!next) { - localhostWriteConsentPayload?.onCancel(); - setLocalhostWriteConsentPayload(null); - } - setLocalhostWriteConsentOpen(next); - }} + persist an edit to a local source file and no valid grant exists for + that connection yet. */} + {id ? ( + - )} + ) : null} {id ? ( { + const url = appPath("/_agent-native/actions/grant-localhost-write-consent"); + expect(new URL(page.url()).origin, "consent setup needs the app page").toBe( + new URL(url).origin, + ); + const result = await page.evaluate( + async ({ url, input }) => { + const response = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-Agent-Native-Frontend": "1", + }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.text() }; + }, + { url, input }, + ); + expect( + result.status, + `grant-localhost-write-consent failed: ${result.body}`, + ).toBe(200); +} + export async function createFixtureDesign( page: Page, title = SEED_TITLE, diff --git a/templates/design/e2e/motion-html-source.browser.spec.ts b/templates/design/e2e/motion-html-source.browser.spec.ts index c3c406de533..94777435410 100644 --- a/templates/design/e2e/motion-html-source.browser.spec.ts +++ b/templates/design/e2e/motion-html-source.browser.spec.ts @@ -27,6 +27,7 @@ import { enableLab, enterDirectMode, gotoEditor, + grantLocalhostWriteConsent, installBridge, } from "./helpers"; @@ -390,7 +391,7 @@ test.describe.serial("HTML Motion source browser writeback", () => { if (!Array.isArray(authoredTracks) || authoredTracks.length !== 2) { throw new Error("Authored HTML CSS Motion tracks were not recovered."); } - await postAction(request, "grant-localhost-write-consent", { + await grantLocalhostWriteConsent(page, { designId, connectionId, }); diff --git a/templates/design/e2e/motion-react-source.browser.spec.ts b/templates/design/e2e/motion-react-source.browser.spec.ts index b793ec14719..cc848aec5e5 100644 --- a/templates/design/e2e/motion-react-source.browser.spec.ts +++ b/templates/design/e2e/motion-react-source.browser.spec.ts @@ -30,6 +30,7 @@ import { enableLab, expandAllLayers, gotoEditor, + grantLocalhostWriteConsent, selectByText, waitForDesignPreviewReady, } from "./helpers"; @@ -295,7 +296,7 @@ test.describe.serial("React Motion source browser writeback", () => { .digest("hex"), ); - await postAction(request, "grant-localhost-write-consent", { + await grantLocalhostWriteConsent(page, { designId, connectionId, }); diff --git a/templates/design/e2e/url-react-jsx-writeback.spec.ts b/templates/design/e2e/url-react-jsx-writeback.spec.ts index c6b74f8528b..6907c667f5f 100644 --- a/templates/design/e2e/url-react-jsx-writeback.spec.ts +++ b/templates/design/e2e/url-react-jsx-writeback.spec.ts @@ -12,7 +12,7 @@ import { type APIRequestContext } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { e2eBaseURL } from "./base-url"; -import { appPath } from "./helpers"; +import { appPath, grantLocalhostWriteConsent } from "./helpers"; import { expect, test } from "./rails"; const baseURL = e2eBaseURL(); @@ -194,7 +194,7 @@ test.describe("URL-backed React JSX writeback", () => { expect(fs.readFileSync(filePath, "utf8")).toBe(cardSource("red")); await expect(card()).toHaveCSS("color", "rgb(255, 0, 0)"); - await postAction(request, "grant-localhost-write-consent", { + await grantLocalhostWriteConsent(page, { designId, connectionId, }); From 95f0b3e087eb2c1e2f6d2d87dab84758cd662375 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:58:26 -0400 Subject: [PATCH 14/15] Preserve local consent callback lifecycle --- .../design/LocalhostWriteConsentDialog.tsx | 63 ++++--- .../localhost-consent-resume.test.tsx | 175 +++++++++++++++++- ...writes-no-longer-undoes-the-edit-that-a.md | 6 + 3 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 templates/design/changelog/2026-10-24-approving-local-file-writes-no-longer-undoes-the-edit-that-a.md diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx index 11e00500c1e..562c492b6b6 100644 --- a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx @@ -85,37 +85,41 @@ export function LocalhostWriteConsentDialog({ }: LocalhostWriteConsentDialogProps) { const t = useT(); const { status: sessionStatus } = useSession(); - const [granting, setGranting] = useState(false); + const [grantingFor, setGrantingFor] = + useState(null); const [preparingFor, setPreparingFor] = useState(null); const [refusal, setRefusal] = useState<{ payload: LocalhostWriteConsentPayload; kind: ConsentRefusal; } | null>(null); + const granting = grantingFor !== null && grantingFor === payload; const preparingSignIn = preparingFor !== null && preparingFor === payload; + const awaitingAnswer = granting || preparingSignIn; const sessionPending = sessionStatus === "loading" || sessionStatus === "signing-out"; const blocked = (refusal?.payload === payload ? refusal.kind : null) ?? (sessionStatus === "unauthenticated" ? "sign-in" : null); - // A sign-in save belongs to the request, design, and connection it started - // from; once any of them changes or the dialog unmounts, its late result - // must not navigate. - const signInOwnerRef = useRef(null); - useEffect( - () => () => { - signInOwnerRef.current = null; - }, - [payload, designId, connectionId], - ); + // A grant or sign-in save belongs to the request, design, and connection it + // started from; once any of them changes or the dialog unmounts, its late + // result must not run the edit, report, close a newer request, or navigate. + const liveRequestRef = useRef(null); + useEffect(() => { + liveRequestRef.current = {}; + return () => { + liveRequestRef.current = null; + }; + }, [payload, designId, connectionId]); async function handleAllowWrites() { if (!payload || !designId || !connectionId || sessionPending || blocked) { return; } const requested = payload; - setGranting(true); + const live = liveRequestRef.current; + setGrantingFor(requested); try { const result = await callAction<{ grantId: string; @@ -125,9 +129,11 @@ export function LocalhostWriteConsentDialog({ designId, connectionId, }); - onOpenChange(false); + if (liveRequestRef.current !== live) return; requested.onGranted(result); + onOpenChange(false); } catch (error) { + if (liveRequestRef.current !== live) return; const kind = refusalForGrantError(error); if (kind) { setRefusal({ payload: requested, kind }); @@ -137,7 +143,7 @@ export function LocalhostWriteConsentDialog({ if (requested.onError) requested.onError(error); else toast.error(actionErrorMessage(error) ?? t("common.genericError")); } finally { - setGranting(false); + setGrantingFor((current) => (current === requested ? null : current)); } } @@ -148,8 +154,7 @@ export function LocalhostWriteConsentDialog({ event.preventDefault(); if (!payload || !designId || !connectionId || preparingSignIn) return; const requested = payload; - const owner = {}; - signInOwnerRef.current = owner; + const live = liveRequestRef.current; const returnPath = window.location.pathname; setPreparingFor(requested); try { @@ -162,7 +167,7 @@ export function LocalhostWriteConsentDialog({ resumeAfterSignIn: true, }, ); - if (signInOwnerRef.current !== owner) return; + if (liveRequestRef.current !== live) return; if (!queued?.requestId) { toast.error(t("designEditor.localhostConsent.prepareSignInError")); return; @@ -173,14 +178,18 @@ export function LocalhostWriteConsentDialog({ }), ); } catch (error) { - if (signInOwnerRef.current !== owner) return; + if (liveRequestRef.current !== live) return; toast.error(actionErrorMessage(error) ?? t("common.genericError")); } finally { setPreparingFor((current) => (current === requested ? null : current)); } } + // Cancel, Escape, and the backdrop all wait for a grant or sign-in save in + // flight: cancelling under it would end a request whose answer is still + // coming, and a copy saved for sign-in would stay pending and reopen. function handleCancel() { + if (awaitingAnswer) return; payload?.onCancel(); onOpenChange(false); } @@ -191,11 +200,8 @@ export function LocalhostWriteConsentDialog({ { - // Closing mid-save would leave the copy saved for sign-in pending, - // and it would reopen; the dialog waits for the save like Cancel does. - if (!next) { - if (!preparingSignIn) handleCancel(); - } else onOpenChange(next); + if (next) onOpenChange(next); + else handleCancel(); }} > @@ -263,7 +269,7 @@ export function LocalhostWriteConsentDialog({ @@ -367,8 +373,6 @@ export function useLocalhostWriteConsent({ const [failedClearKey, setFailedClearKey] = useState(null); const lastHandledKeyRef = useRef(null); const resolvedReturnRequestIdRef = useRef(null); - const payloadRef = useRef(payload); - payloadRef.current = payload; const returnRequestId = new URLSearchParams(location.search).get( LOCALHOST_CONSENT_RETURN_PARAM, ); @@ -529,11 +533,10 @@ export function useLocalhostWriteConsent({ [], ); + // Closing is not cancelling: a granted request closes through here too, so + // only the dialog's own Cancel calls onCancel. const onOpenChange = useCallback((next: boolean) => { - if (!next) { - payloadRef.current?.onCancel(); - setPayload(null); - } + if (!next) setPayload(null); setOpen(next); }, []); diff --git a/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx b/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx index c28c4312290..06de9c2641d 100644 --- a/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx +++ b/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx @@ -184,6 +184,7 @@ describe("localhost write consent across sign-in", () => { let latest: LocalhostWriteConsentState | null; let routerLocation: { pathname: string; search: string } | null; let grantStatus: 200 | 401 | 500; + let grantHold: Promise | null; let requestHold: Promise | null; let calls: Array<{ name: string; body: Record }>; let chatMessages: Array>; @@ -200,6 +201,7 @@ describe("localhost write consent across sign-in", () => { mocks.sessionStatus = "authenticated"; mocks.appState.clear(); grantStatus = 200; + grantHold = null; requestHold = null; calls = []; chatMessages = []; @@ -222,6 +224,7 @@ describe("localhost write consent across sign-in", () => { : (JSON.parse(String(init.body)) as Record); calls.push({ name, body }); if (name === "grant-localhost-write-consent") { + if (grantHold) await grantHold; if (grantStatus === 500) { return Response.json( { error: "Bridge unavailable" }, @@ -334,6 +337,68 @@ describe("localhost write consent across sign-in", () => { await settle(); } + function openLocalRequest(files: string[]) { + const events: string[] = []; + act(() => + latest!.openLocalConsent({ + connectionId: CONNECTION_ID, + rootPath: "/repo", + files, + onGranted: () => events.push("granted"), + onCancel: () => events.push("cancelled"), + onError: () => events.push("error"), + }), + ); + return events; + } + + function holdGrant(): () => Promise { + let release!: () => void; + grantHold = new Promise((resolve) => { + release = resolve; + }); + return async () => { + grantHold = null; + release(); + await settle(); + }; + } + + // Every way a person can dismiss the dialog. + const dismissals: Array<[string, () => Promise]> = [ + ["Cancel", () => click("Cancel")], + [ + "Escape", + async () => { + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + await settle(); + }, + ], + [ + "a press outside the dialog", + async () => { + await act(async () => { + for (const type of [ + "pointerdown", + "mousedown", + "pointerup", + "mouseup", + "click", + ]) { + document.body.dispatchEvent( + new MouseEvent(type, { bubbles: true, button: 0 }), + ); + } + }); + await settle(); + }, + ], + ]; + // Sign-in is a full-page navigation: capture it, then end this mount the way // a page unload would, without closing the dialog. async function signInAndLeave(): Promise { @@ -477,25 +542,122 @@ describe("localhost write consent across sign-in", () => { expect(chatMessages).toHaveLength(0); }); - it("runs a local edit's callback after its own grant on the same page", async () => { + it("runs a local edit's callback after its own grant on the same page and never cancels it", async () => { await mountAt(`/design/${DESIGN_ID}`); const onGranted = vi.fn(); + const onCancel = vi.fn(); act(() => latest!.openLocalConsent({ connectionId: CONNECTION_ID, rootPath: "/repo", files: ["src/Card.tsx"], onGranted, + onCancel, }), ); await settle(); await click("Allow writes"); + expect(onGranted).toHaveBeenCalledTimes(1); expect(onGranted).toHaveBeenCalledWith(GRANT); + expect(onCancel).not.toHaveBeenCalled(); + expect(dialogTitled("Allow file writes")).toBeUndefined(); expect(dialogTitled(copy().approvedTitle)).toBeUndefined(); expect(callsTo("request-localhost-write-consent")).toHaveLength(0); }); + it.each(dismissals)( + "cancels a local edit exactly once when %s dismisses it", + async (_dismissal, dismiss) => { + await mountAt(`/design/${DESIGN_ID}`); + const events = openLocalRequest(["src/Card.tsx"]); + await settle(); + expect(dialogTitled("Allow file writes")).toBeDefined(); + + await dismiss(); + expect(events).toEqual(["cancelled"]); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + expect(callsTo("grant-localhost-write-consent")).toHaveLength(0); + }, + ); + + it.each(dismissals)( + "keeps a granting dialog open through %s until its grant answers", + async (_dismissal, dismiss) => { + await mountAt(`/design/${DESIGN_ID}`); + const events = openLocalRequest(["src/Card.tsx"]); + await settle(); + const release = holdGrant(); + await click("Allow writes"); + expect(button("Cancel")?.disabled).toBe(true); + + await dismiss(); + expect(events).toEqual([]); + expect(dialogTitled("Allow file writes")?.textContent).toContain( + "Granting…", + ); + + await release(); + expect(events).toEqual(["granted"]); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + expect(callsTo("grant-localhost-write-consent")).toHaveLength(1); + }, + ); + + const endings: Array<[string, () => void]> = [ + ["the editor unmounts", () => unmount()], + [ + "the editor closes the request", + () => act(() => latest!.onOpenChange(false)), + ], + ]; + + it.each( + endings.flatMap(([ending, end]) => + ([200, 500] as const).map((status) => [status, ending, end] as const), + ), + )("drops a grant answering %i after %s", async (status, _ending, end) => { + await mountAt(`/design/${DESIGN_ID}`); + const events = openLocalRequest(["src/Card.tsx"]); + await settle(); + const release = holdGrant(); + await click("Allow writes"); + + end(); + grantStatus = status; + await release(); + expect(events).toEqual([]); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + }); + + it.each([200, 500] as const)( + "drops an older grant answering %i after a newer request for the same connection replaced it", + async (status) => { + await mountAt(`/design/${DESIGN_ID}`); + const older = openLocalRequest(["src/Card.tsx"]); + await settle(); + const release = holdGrant(); + await click("Allow writes"); + + const newer = openLocalRequest(["src/Next.tsx"]); + await settle(); + expect(button("Allow writes")?.disabled).toBe(false); + grantStatus = status; + await release(); + expect(older).toEqual([]); + expect(newer).toEqual([]); + expect(dialogTitled("Allow file writes")?.textContent).toContain( + "src/Next.tsx", + ); + + grantStatus = 200; + await click("Allow writes"); + expect(newer).toEqual(["granted"]); + expect(older).toEqual([]); + expect(dialogTitled("Allow file writes")).toBeUndefined(); + }, + ); + it("says a returned request is no longer pending when it was cleared, expired, or replaced", async () => { // Edit access gates the handoff, not a browser sign-in. mocks.sessionStatus = "unauthenticated"; @@ -559,11 +721,12 @@ describe("localhost write consent across sign-in", () => { setServerRecord(newer); await click("Cancel"); - expect(lastBodyTo("clear-localhost-write-consent-request")).toEqual({ - designId: DESIGN_ID, - requestedAt, - requestId: "shown-request", - }); + expect(callsTo("clear-localhost-write-consent-request")).toEqual([ + { + name: "clear-localhost-write-consent-request", + body: { designId: DESIGN_ID, requestedAt, requestId: "shown-request" }, + }, + ]); expect(serverRecord()).toEqual(newer); expect(dialogTitled("Allow file writes")?.textContent).toContain( "src/Next.tsx", diff --git a/templates/design/changelog/2026-10-24-approving-local-file-writes-no-longer-undoes-the-edit-that-a.md b/templates/design/changelog/2026-10-24-approving-local-file-writes-no-longer-undoes-the-edit-that-a.md new file mode 100644 index 00000000000..0d9e8be2def --- /dev/null +++ b/templates/design/changelog/2026-10-24-approving-local-file-writes-no-longer-undoes-the-edit-that-a.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-24 +--- + +Approving local file writes no longer undoes the edit that asked for them, and the consent dialog stays open until approval finishes. From 5efe0067806217ddee5c1bf9d994e1db6c371229 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:04:07 -0400 Subject: [PATCH 15/15] Keep Motion consent setup in the signed-in page Use the browser session for the three new localhost consent setups while retaining the Motion source assertions. Co-Authored-By: Claude Opus 5.5 --- templates/design/e2e/motion-html-source.browser.spec.ts | 3 ++- templates/design/e2e/motion-react-source.browser.spec.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/templates/design/e2e/motion-html-source.browser.spec.ts b/templates/design/e2e/motion-html-source.browser.spec.ts index 94777435410..41dfc0d3595 100644 --- a/templates/design/e2e/motion-html-source.browser.spec.ts +++ b/templates/design/e2e/motion-html-source.browser.spec.ts @@ -988,7 +988,8 @@ test.describe.serial("HTML Motion source browser writeback", () => { } if (!screenId) throw new Error("HTML Motion screen is not registered"); - await postAction(request, "grant-localhost-write-consent", { + await page.goto(appPath("/"), { waitUntil: "domcontentloaded" }); + await grantLocalhostWriteConsent(page, { designId, connectionId, }); diff --git a/templates/design/e2e/motion-react-source.browser.spec.ts b/templates/design/e2e/motion-react-source.browser.spec.ts index cc848aec5e5..03e760aad1d 100644 --- a/templates/design/e2e/motion-react-source.browser.spec.ts +++ b/templates/design/e2e/motion-react-source.browser.spec.ts @@ -1139,7 +1139,8 @@ test.describe.serial("React Motion source browser writeback", () => { if (!screenId) throw new Error("React Motion screen is not registered"); const nativeNodeId = "motion-native-target"; - await postAction(request, "grant-localhost-write-consent", { + await page.goto(appPath("/"), { waitUntil: "domcontentloaded" }); + await grantLocalhostWriteConsent(page, { designId, connectionId, }); @@ -1346,7 +1347,8 @@ test.describe.serial("React Motion source browser writeback", () => { } if (!screenId) throw new Error("React Motion screen is not registered"); - await postAction(request, "grant-localhost-write-consent", { + await page.goto(appPath("/"), { waitUntil: "domcontentloaded" }); + await grantLocalhostWriteConsent(page, { designId, connectionId, });