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
+
+ {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({
);
}
+
+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 (
+
+ );
+}
+
+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({