diff --git a/.changeset/tidy-embed-session-scope.md b/.changeset/tidy-embed-session-scope.md
new file mode 100644
index 00000000000..fcf819242cc
--- /dev/null
+++ b/.changeset/tidy-embed-session-scope.md
@@ -0,0 +1,15 @@
+---
+"@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.
+
+Apps with a custom `AuthOptions.getSession` resolver must also configure the cookie-only `getBrowserSession` hook to use UI capabilities and browser approvals. Without it, these operations return `401` and the server logs a migration warning.
+
+MCP Apps embeds behind a proxy require the public origin configured through `APP_URL` or the workspace gateway URL unless the proxy connects over loopback.
+
+Use the embed origin consistently for widget CSP and keep ordinary sign-in session lookup independent of embed origin validation.
+
+Keep Visual Edit capabilities on their declared action set and automatic source synchronization on the signed-in user's own write access.
+
+Keep card read manifests limited to their declared reads, and require a browser session for localhost write consent.
diff --git a/packages/core/docs/content/authentication.mdx b/packages/core/docs/content/authentication.mdx
index 16e090bd68d..edb298a0177 100644
--- a/packages/core/docs/content/authentication.mdx
+++ b/packages/core/docs/content/authentication.mdx
@@ -395,6 +395,8 @@ export default createAuthPlugin({
});
```
+Custom authentication must also configure the optional `AuthOptions.getBrowserSession` callback for UI capabilities and browser approvals. It accepts an `H3Event` and returns `AuthSession | null`, like `getSession`, but must read browser cookies only, never bearer tokens, query tokens, or other headers. Without this explicit hook, those browser-only operations return `401`.
+
## Public Workspace Apps {#public-workspace-apps}
Workspace apps are internal by default. To let anonymous visitors load a public
diff --git a/packages/core/docs/content/locales/ar-SA/authentication.mdx b/packages/core/docs/content/locales/ar-SA/authentication.mdx
index c688ab5a3e1..c38847eec4b 100644
--- a/packages/core/docs/content/locales/ar-SA/authentication.mdx
+++ b/packages/core/docs/content/locales/ar-SA/authentication.mdx
@@ -337,6 +337,8 @@ export default createAuthPlugin({
});
```
+يجب أيضًا تهيئة رد الاتصال الاختياري `AuthOptions.getBrowserSession` للمصادقة المخصصة من أجل إمكانات واجهة المستخدم وموافقات المتصفح. يقبل `H3Event` ويعيد `AuthSession | null`، مثل `getSession`، لكن يجب أن يقرأ ملفات تعريف ارتباط المتصفح فقط، ولا يقرأ مطلقًا رموز الحامل أو رموز الاستعلام أو أي ترويسات أخرى. من دون هذا الخطاف الصريح، تُرجع هذه العمليات المخصصة للمتصفح `401`.
+
## تطبيقات مساحة العمل العامة {#public-workspace-apps}
تعتبر تطبيقات مساحة العمل داخلية بشكل افتراضي. للسماح للزائرين المجهولين بتحميل ملف عام
diff --git a/packages/core/docs/content/locales/de-DE/authentication.mdx b/packages/core/docs/content/locales/de-DE/authentication.mdx
index 79e68d7adb1..98e17dc58fb 100644
--- a/packages/core/docs/content/locales/de-DE/authentication.mdx
+++ b/packages/core/docs/content/locales/de-DE/authentication.mdx
@@ -342,6 +342,8 @@ export default createAuthPlugin({
});
```
+Bei benutzerdefinierter Authentifizierung muss auch der optionale Rückruf `AuthOptions.getBrowserSession` für UI-Funktionen und Browserfreigaben konfiguriert werden. Er akzeptiert ein `H3Event` und gibt wie `getSession` `AuthSession | null` zurück, darf aber ausschließlich Browser-Cookies und niemals Bearer-Token, Query-Token oder andere Header lesen. Ohne diesen expliziten Hook geben diese reinen Browseroperationen `401` zurück.
+
## Öffentliche Workspace-Apps {#public-workspace-apps}
Workspace-Apps sind standardmäßig intern. Damit anonyme Besucher eine öffentliche laden können
diff --git a/packages/core/docs/content/locales/es-ES/authentication.mdx b/packages/core/docs/content/locales/es-ES/authentication.mdx
index abe047c5763..361df1ed88d 100644
--- a/packages/core/docs/content/locales/es-ES/authentication.mdx
+++ b/packages/core/docs/content/locales/es-ES/authentication.mdx
@@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```
+La autenticación personalizada también debe configurar la devolución de llamada opcional `AuthOptions.getBrowserSession` para las funciones de la interfaz de usuario y las aprobaciones del navegador. Acepta un `H3Event` y devuelve `AuthSession | null`, como `getSession`, pero solo debe leer cookies del navegador, nunca tokens bearer, tokens de consulta ni otros encabezados. Sin este hook explícito, esas operaciones exclusivas del navegador devuelven `401`.
+
## Aplicaciones de espacios de trabajo públicos {#public-workspace-apps}
Las aplicaciones de Workspace son internas de forma predeterminada. Para permitir que visitantes anónimos carguen un público
diff --git a/packages/core/docs/content/locales/fr-FR/authentication.mdx b/packages/core/docs/content/locales/fr-FR/authentication.mdx
index 5f6e8e1cde4..2d45ef3eaa5 100644
--- a/packages/core/docs/content/locales/fr-FR/authentication.mdx
+++ b/packages/core/docs/content/locales/fr-FR/authentication.mdx
@@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```
+L’authentification personnalisée doit également configurer le rappel facultatif `AuthOptions.getBrowserSession` pour les fonctionnalités de l’interface et les approbations du navigateur. Il accepte un `H3Event` et renvoie `AuthSession | null`, comme `getSession`, mais doit lire uniquement les cookies du navigateur, jamais les jetons bearer, les jetons de requête ni d’autres en-têtes. Sans ce hook explicite, ces opérations réservées au navigateur renvoient `401`.
+
## Applications de l'espace de travail public {#public-workspace-apps}
Les applications Workspace sont internes par défaut. Pour permettre aux visiteurs anonymes de charger un public
diff --git a/packages/core/docs/content/locales/hi-IN/authentication.mdx b/packages/core/docs/content/locales/hi-IN/authentication.mdx
index 388639dd5ec..3ea707dda25 100644
--- a/packages/core/docs/content/locales/hi-IN/authentication.mdx
+++ b/packages/core/docs/content/locales/hi-IN/authentication.mdx
@@ -340,6 +340,8 @@ export default createAuthPlugin({
});
```
+कस्टम प्रमाणीकरण में UI क्षमताओं और ब्राउज़र अनुमतियों के लिए वैकल्पिक `AuthOptions.getBrowserSession` कॉलबैक भी कॉन्फ़िगर करना होगा। यह `H3Event` स्वीकार करता है और `getSession` की तरह `AuthSession | null` लौटाता है, लेकिन इसे केवल ब्राउज़र कुकीज़ पढ़नी चाहिए; bearer token, query token या अन्य headers कभी नहीं। इस स्पष्ट hook के बिना, केवल ब्राउज़र के लिए उपलब्ध ये कार्रवाइयाँ `401` लौटाती हैं।
+
## सार्वजनिक कार्यक्षेत्र ऐप्स {#public-workspace-apps}
वर्कस्पेस ऐप्स डिफ़ॉल्ट रूप से आंतरिक होते हैं। अनाम आगंतुकों को सार्वजनिक रूप से लोड करने देने के लिए
diff --git a/packages/core/docs/content/locales/ja-JP/authentication.mdx b/packages/core/docs/content/locales/ja-JP/authentication.mdx
index 49a2a897c58..c2e2ef2c7eb 100644
--- a/packages/core/docs/content/locales/ja-JP/authentication.mdx
+++ b/packages/core/docs/content/locales/ja-JP/authentication.mdx
@@ -339,6 +339,8 @@ export default createAuthPlugin({
});
```
+カスタム認証では、UI 機能とブラウザー承認のために、オプションの `AuthOptions.getBrowserSession` コールバックも設定する必要があります。これは `H3Event` を受け取り、`getSession` と同様に `AuthSession | null` を返しますが、読み取るのはブラウザーの Cookie のみで、Bearer トークン、クエリトークン、その他のヘッダーは読み取ってはいけません。この明示的なフックがない場合、ブラウザー専用のこれらの操作は `401` を返します。
+
## パブリック ワークスペース アプリ {#public-workspace-apps}
ワークスペース アプリはデフォルトで内部にあります。匿名の訪問者がパブリックをロードできるようにするには
diff --git a/packages/core/docs/content/locales/ko-KR/authentication.mdx b/packages/core/docs/content/locales/ko-KR/authentication.mdx
index ad02356b033..e821b4d08e4 100644
--- a/packages/core/docs/content/locales/ko-KR/authentication.mdx
+++ b/packages/core/docs/content/locales/ko-KR/authentication.mdx
@@ -339,6 +339,8 @@ export default createAuthPlugin({
});
```
+사용자 지정 인증은 UI 기능과 브라우저 승인에 사용할 선택적 `AuthOptions.getBrowserSession` 콜백도 구성해야 합니다. 이 콜백은 `H3Event`를 받고 `getSession`처럼 `AuthSession | null`을 반환하지만, 브라우저 쿠키만 읽어야 하며 bearer 토큰, 쿼리 토큰 또는 다른 헤더는 절대 읽으면 안 됩니다. 이 명시적 훅이 없으면 브라우저 전용 작업은 `401`을 반환합니다.
+
## 공용 작업 공간 앱 {#public-workspace-apps}
Workspace 앱은 기본적으로 내부용입니다. 익명의 방문자가 공개를 로드할 수 있도록 하려면
diff --git a/packages/core/docs/content/locales/pt-BR/authentication.mdx b/packages/core/docs/content/locales/pt-BR/authentication.mdx
index 16e22b3185b..665f1d7cdd2 100644
--- a/packages/core/docs/content/locales/pt-BR/authentication.mdx
+++ b/packages/core/docs/content/locales/pt-BR/authentication.mdx
@@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```
+A autenticação personalizada também deve configurar o retorno de chamada opcional `AuthOptions.getBrowserSession` para recursos da interface e aprovações do navegador. Ele aceita um `H3Event` e retorna `AuthSession | null`, como `getSession`, mas deve ler apenas cookies do navegador, nunca tokens bearer, tokens de consulta ou outros cabeçalhos. Sem esse hook explícito, essas operações exclusivas do navegador retornam `401`.
+
## Aplicativos de espaço de trabalho público {#public-workspace-apps}
Os aplicativos do Workspace são internos por padrão. Para permitir que visitantes anônimos carreguem um arquivo público
diff --git a/packages/core/docs/content/locales/zh-CN/authentication.mdx b/packages/core/docs/content/locales/zh-CN/authentication.mdx
index 6392aaa7609..3e2388490b9 100644
--- a/packages/core/docs/content/locales/zh-CN/authentication.mdx
+++ b/packages/core/docs/content/locales/zh-CN/authentication.mdx
@@ -338,6 +338,8 @@ export default createAuthPlugin({
});
```
+自定义身份验证还必须配置可选回调 `AuthOptions.getBrowserSession`,用于 UI 功能和浏览器审批。它接收 `H3Event` 并返回 `AuthSession | null`,与 `getSession` 相同,但只能读取浏览器 Cookie,绝不能读取 bearer token、查询 token 或其他标头。没有此显式 hook 时,这些仅限浏览器的操作会返回 `401`。
+
## 公共工作区应用 {#public-workspace-apps}
工作区应用程序默认为内部应用程序。让匿名访问者加载公共
diff --git a/packages/core/docs/content/locales/zh-TW/authentication.mdx b/packages/core/docs/content/locales/zh-TW/authentication.mdx
index 5f28d1f9282..9a578801559 100644
--- a/packages/core/docs/content/locales/zh-TW/authentication.mdx
+++ b/packages/core/docs/content/locales/zh-TW/authentication.mdx
@@ -338,6 +338,8 @@ export default createAuthPlugin({
});
```
+自訂驗證也必須設定選用回呼 `AuthOptions.getBrowserSession`,供 UI 功能和瀏覽器核准使用。它接受 `H3Event` 並傳回 `AuthSession | null`,與 `getSession` 相同,但只能讀取瀏覽器 Cookie,絕不可讀取 bearer token、查詢 token 或其他標頭。沒有這個明確的 hook 時,這些僅限瀏覽器的操作會傳回 `401`。
+
## 公開工作區應用 {#public-workspace-apps}
工作區應用程式預設為內部應用程式。讓匿名訪客載入公開
diff --git a/packages/core/src/a2a/server.spec.ts b/packages/core/src/a2a/server.spec.ts
index 0aa491a7147..3c29d869281 100644
--- a/packages/core/src/a2a/server.spec.ts
+++ b/packages/core/src/a2a/server.spec.ts
@@ -17,6 +17,7 @@ const setResponseStatusMock = vi.hoisted(() =>
);
const setResponseHeaderMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
+const getBrowserCookieSessionMock = vi.hoisted(() => vi.fn());
const runWithRequestContextMock = vi.hoisted(() =>
vi.fn((_context: unknown, fn: () => unknown) => fn()),
);
@@ -92,7 +93,10 @@ vi.mock("../org/service-principal-guard.js", async (importActual) => ({
recordServicePrincipalDenial: recordServicePrincipalDenialMock,
}));
-vi.mock("../server/auth.js", () => ({ getSession: getSessionMock }));
+vi.mock("../server/auth.js", () => ({
+ getSession: getSessionMock,
+ getBrowserCookieSession: getBrowserCookieSessionMock,
+}));
vi.mock("../server/request-context.js", () => ({
runWithRequestContext: runWithRequestContextMock,
@@ -142,6 +146,7 @@ describe("mountA2A auth", () => {
evaluateServicePrincipalMock.mockResolvedValue({ status: "not-service" });
recordServicePrincipalDenialMock.mockReset();
getSessionMock.mockReset();
+ getBrowserCookieSessionMock.mockReset().mockResolvedValue(null);
runWithRequestContextMock.mockClear();
getApprovalMock.mockReset();
claimApprovalMock.mockReset();
@@ -623,11 +628,83 @@ describe("mountA2A auth", () => {
expect(getApprovalMock).not.toHaveBeenCalled();
});
- it("prevents approval pages from being framed", async () => {
+ it.each(["GET", "POST"])(
+ "refuses a bearer-only approval %s without executing",
+ async (method) => {
+ getSessionMock.mockResolvedValue({
+ email: "owner@example.com",
+ orgId: "org-1",
+ });
+ const approval = {
+ id: "approval-1",
+ taskId: "task-1",
+ ownerEmail: "owner@example.com",
+ orgId: "org-1",
+ tool: "send-email",
+ input: {},
+ status: "pending",
+ expiresAt: Date.now() + 10_000,
+ };
+ getApprovalMock.mockResolvedValue(approval);
+ claimApprovalMock.mockResolvedValue(approval);
+ const executeApproval = vi.fn(async () => ({
+ status: "completed" as const,
+ output: "Email sent",
+ }));
+ const handler = await mountedA2AApprovalHandler({
+ ...config,
+ executeApproval,
+ });
+ const event = {
+ method,
+ path: "/approval-1",
+ headers: { authorization: "Bearer test-login-session" },
+ context: {},
+ };
+ const response = await handler(event);
+ expect(executeApproval).not.toHaveBeenCalled();
+ expect(response).toEqual({
+ error: "Sign in to review this approval",
+ });
+ expect(event).toMatchObject({ _status: 401 });
+ expect(getApprovalMock).not.toHaveBeenCalled();
+ expect(claimApprovalMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it("refuses an approval POST without browser origin evidence", async () => {
+ getBrowserCookieSessionMock.mockResolvedValue({
+ email: "owner@example.com",
+ orgId: "org-1",
+ });
getSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
+ const executeApproval = vi.fn();
+ const handler = await mountedA2AApprovalHandler({
+ ...config,
+ executeApproval,
+ });
+ const event = {
+ method: "POST",
+ path: "/approval-1",
+ headers: { cookie: "an_session=test-browser-session" },
+ context: {},
+ };
+ await expect(handler(event)).resolves.toEqual({
+ error: "Cross-origin approval denied",
+ });
+ expect(event).toMatchObject({ _status: 403 });
+ expect(claimApprovalMock).not.toHaveBeenCalled();
+ expect(executeApproval).not.toHaveBeenCalled();
+ });
+
+ it("prevents approval pages from being framed", async () => {
+ getBrowserCookieSessionMock.mockResolvedValue({
+ email: "owner@example.com",
+ orgId: "org-1",
+ });
getApprovalMock.mockResolvedValue({
id: "approval-1",
ownerEmail: "owner@example.com",
@@ -642,6 +719,7 @@ describe("mountA2A auth", () => {
const response = await handler({
method: "GET",
path: "/approval-1",
+ headers: { cookie: "an_session=test-browser-session" },
context: {},
});
@@ -664,7 +742,7 @@ describe("mountA2A auth", () => {
});
it("makes an interrupted processing approval available for safe recovery", async () => {
- getSessionMock.mockResolvedValue({
+ getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
@@ -683,6 +761,7 @@ describe("mountA2A auth", () => {
const response = await handler({
method: "GET",
path: "/approval-1",
+ headers: { cookie: "an_session=test-browser-session" },
context: {},
});
@@ -706,11 +785,13 @@ describe("mountA2A auth", () => {
result: null,
expiresAt: Date.now() + 10_000,
};
- getSessionMock.mockResolvedValue({
+ getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
- claimApprovalMock.mockResolvedValue(approval);
+ claimApprovalMock
+ .mockResolvedValueOnce(approval)
+ .mockResolvedValueOnce(null);
getApprovalMock.mockResolvedValue(approval);
const executeApproval = vi.fn(async () => ({
status: "completed" as const,
@@ -730,6 +811,7 @@ describe("mountA2A auth", () => {
host: "mail.example",
origin: "https://mail.example",
"sec-fetch-site": "same-origin",
+ cookie: "an_session=test-browser-session",
},
}),
).resolves.toEqual({ status: "completed", output: "Email sent" });
@@ -752,6 +834,21 @@ describe("mountA2A auth", () => {
"Email sent",
);
expect(runLeaseMocks.releaseTurnExecutionLease).toHaveBeenCalledTimes(1);
+
+ const replay = {
+ method: "POST",
+ path: "/approval-1",
+ context: {},
+ headers: {
+ cookie: "an_session=test-browser-session",
+ "sec-fetch-site": "same-origin",
+ },
+ };
+ await expect(handler(replay)).resolves.toMatchObject({
+ error: expect.stringContaining("cannot be run again"),
+ });
+ expect(replay).toMatchObject({ _status: 409 });
+ expect(executeApproval).toHaveBeenCalledOnce();
});
it("allows legacy apiKeyEnv bearer auth even when A2A_SECRET is configured", async () => {
diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts
index b7757fdf4f1..a446c810402 100644
--- a/packages/core/src/a2a/server.ts
+++ b/packages/core/src/a2a/server.ts
@@ -37,7 +37,7 @@ import { readDeployCredentialEnv } from "../server/credential-provider.js";
import { getH3App } from "../server/framework-request-handler.js";
import { publicFrameworkPath } from "../server/framework-route-prefix.js";
import { readBody } from "../server/h3-helpers.js";
-import { isSameOriginRequest } from "../server/request-origin.js";
+import { hasSameOriginBrowserEvidence } from "../server/request-origin.js";
import type { AgentProfileMetadata } from "../shared/agent-profile.js";
import { generateAgentCard } from "./agent-card.js";
import { addAgentProfileExamples } from "./app-profile.js";
@@ -834,8 +834,8 @@ export function mountA2A(
return { error: "Approval not found" };
}
- const { getSession } = await import("../server/auth.js");
- const session = await getSession(event);
+ const { getBrowserCookieSession } = await import("../server/auth.js");
+ const session = await getBrowserCookieSession(event);
if (!session?.email) {
setResponseStatus(event, 401);
return { error: "Sign in to review this approval" };
@@ -885,7 +885,7 @@ export function mountA2A(
setResponseStatus(event, 405);
return { error: "Method not allowed" };
}
- if (!isSameOriginRequest(event)) {
+ if (!hasSameOriginBrowserEvidence(event)) {
setResponseStatus(event, 403);
return { error: "Cross-origin approval denied" };
}
diff --git a/packages/core/src/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..26541d69937 100644
--- a/packages/core/src/deploy/build.spec.ts
+++ b/packages/core/src/deploy/build.spec.ts
@@ -2298,13 +2298,9 @@ export default { run: async () => ({ ok: true }) };
expect(source).toContain(
"hasUiActionCapability as hasGeneratedUiActionCapability",
);
- expect(source).toContain(
- "isSameOriginRequest as isGeneratedSameOriginRequest",
- );
expect(source).toContain(
"mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute",
);
- expect(source).toContain("!isGeneratedSameOriginRequest(event)");
expect(source).toContain(
'setResponseHeader(event, "Cache-Control", "no-" + "store");',
);
@@ -2407,6 +2403,7 @@ export default {
const capability = await worker.fetch(
new Request("https://app.test/_agent-native/ui-capability", {
method: "GET",
+ headers: { origin: "https://app.test" },
}),
{},
{},
@@ -2417,6 +2414,18 @@ export default {
it.each([
["needsApproval: true", "frontend", 403, "ui_capability_required"],
["needsApproval: true", "http", 409, "approval_required"],
+ [
+ 'needsApproval: (_args, ctx) => ctx.caller !== "frontend"',
+ "frontend bearer",
+ 409,
+ "approval_required",
+ ],
+ [
+ 'needsApproval: (_args, ctx) => ctx.caller !== "frontend"',
+ "frontend",
+ 403,
+ "ui_capability_required",
+ ],
[
'needsApproval: () => { throw new Error("x"); }',
"http",
@@ -2452,9 +2461,12 @@ export default {
method: "POST",
headers: {
"content-type": "application/json",
- ...(caller === "frontend"
+ ...(caller.startsWith("frontend")
? { "x-agent-native-frontend": "1" }
: {}),
+ ...(caller === "frontend bearer"
+ ? { authorization: "Bearer test-session" }
+ : {}),
},
body: JSON.stringify({ id: "thing-1" }),
}),
diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts
index 260c6b1cda4..a3c5dd9952a 100644
--- a/packages/core/src/deploy/build.ts
+++ b/packages/core/src/deploy/build.ts
@@ -1602,7 +1602,7 @@ ${["post", "put", "delete"]
getFrameworkRoutePrefix as getAgentNativeFrameworkRoutePrefix,
getSsrAuthRedirectScript as getAgentNativeSsrAuthRedirectScript,
resolveAppHomePath as resolveAgentNativeAppHomePath,
-${hasActions ? " directHttpActionApprovalRefusal as generatedActionApprovalRefusal,\n getSession as getGeneratedSession,\n hasUiActionCapability as hasGeneratedUiActionCapability,\n isSameOriginRequest as isGeneratedSameOriginRequest,\n mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute,\n resolveOrgIdForEmailViaEvent as resolveGeneratedOrgId,\n runWithRequestContext as runWithGeneratedRequestContext,\n" : ""}
+${hasActions ? " directHttpActionApprovalRefusal as generatedActionApprovalRefusal,\n getSession as getGeneratedSession,\n getBrowserCookieSession as getGeneratedBrowserCookieSession,\n hasPresentedEmbedSessionCredential as hasGeneratedEmbedCredential,\n hasUiActionCapability as hasGeneratedUiActionCapability,\n hasSameOriginBrowserEvidence as hasGeneratedSameOriginBrowserEvidence,\n mountUiActionCapabilityRoute as mountGeneratedUiActionCapabilityRoute,\n resolveOrgIdForEmailViaEvent as resolveGeneratedOrgId,\n runWithRequestContext as runWithGeneratedRequestContext,\n" : ""}
} from "${EDGE_SERVER_ENTRYPOINT}";`,
);
@@ -2558,12 +2558,15 @@ ${pluginCalls.join("\n")}
${
hasActions
? ` async function getGeneratedUiActionContext(event) {
- const session = await getGeneratedSession(event);
+ if (event.req.headers.has("authorization") || hasGeneratedEmbedCredential(event)) {
+ return null;
+ }
+ const session = await getGeneratedBrowserCookieSession(event);
const userEmail =
typeof session?.email === "string" ? session.email.trim().toLowerCase() : undefined;
if (
!userEmail ||
- !isGeneratedSameOriginRequest(event) ||
+ !hasGeneratedSameOriginBrowserEvidence(event) ||
!hasGeneratedUiActionCapability(event, userEmail)
) {
return null;
diff --git a/packages/core/src/mcp/__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..fcb01408d94 100644
--- a/packages/core/src/mcp/build-server.call-parity.spec.ts
+++ b/packages/core/src/mcp/build-server.call-parity.spec.ts
@@ -134,13 +134,13 @@ vi.mock("./credential-membership.js", async (importOriginal) => {
};
});
-vi.mock("h3", () => ({
+vi.mock("h3", async (importOriginal) => ({
+ ...(await importOriginal()),
defineEventHandler: (fn: any) => fn,
getMethod: (event: any) => event.method ?? "GET",
getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()],
getRequestHeader: (event: any, name: string) =>
event._headers?.[name.toLowerCase()],
- getRequestIP: () => undefined,
getQuery: () => ({}),
setResponseStatus: (event: any, code: number) => {
event._status = code;
@@ -463,7 +463,7 @@ function makeEvent(
const path = "/";
return {
method,
- url: { pathname: path },
+ url: new URL(`${ORIGIN}${path}`),
path,
req: new Request(`${ORIGIN}${path}`, { method, headers: allHeaders }),
_headers: allHeaders,
@@ -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..e93ea4d60a1 100644
--- a/packages/core/src/mcp/build-server.ts
+++ b/packages/core/src/mcp/build-server.ts
@@ -54,6 +54,7 @@ import {
toDesktopOpenUrl,
toVsCodeOpenUrl,
} from "../server/deep-link.js";
+import { getEmbedSessionOrigin } from "../server/embed-request-origin.js";
import {
getRequestContext,
getRequestOrgId,
@@ -318,6 +319,7 @@ export function actionApprovalError(message: string): CallToolResult {
export interface MCPRequestMeta {
localAgentTaskClient?: import("../a2a/mcp-task-client.js").McpAgentTaskClient;
origin?: string;
+ resolveEmbedRequestOrigin?: () => string;
basePath?: string;
target?: "browser" | "desktop" | "terminal";
clientName?: string;
@@ -420,8 +422,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]) =>
@@ -617,7 +619,7 @@ export function validateMcpDirectoryProfile(
`[agent-native] MCP directory widget action "${deniedWidgetAction}" is listed in externalAgents.denyActions.`,
);
}
- const widgetActionNames = names.filter((name) =>
+ const widgetActionNames = Object.keys(actions).filter((name) =>
Boolean(actions[name]?.mcpApp?.resource),
);
const widgetReadOnlyActions = new Set(profile?.widgetReadOnlyActions ?? []);
@@ -648,7 +650,7 @@ export function validateMcpDirectoryProfile(
);
if (unknownTarget) {
throw new McpDirectoryProfileValidationError(
- `[agent-native] MCP directory widget target "${unknownTarget}" must name a listed action with an mcpApp resource. Listed widget actions without a target resolver serve as plain tools.`,
+ `[agent-native] MCP directory widget target "${unknownTarget}" must name an MCP action with an mcpApp resource. Listed widget actions without a target resolver serve as plain tools.`,
);
}
}
@@ -1040,6 +1042,7 @@ interface ResolvedMcpAppResource {
title?: string;
description?: string;
html: ActionMcpAppResourceConfig["html"];
+ requestOrigin?: string;
mimeType: typeof MCP_APP_MIME_TYPE;
_meta?: Record;
}
@@ -1274,7 +1277,6 @@ export function mcpDirectoryWidgetSourceMeta(
export async function withServerMintedMcpAppEmbedStart(
result: unknown,
meta: MCPRequestMeta | undefined,
- directoryLinkUrl?: string,
directoryWidget?: {
targetPath: string;
capability:
@@ -1297,75 +1299,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 = getEmbedSessionOrigin();
+ if (!ownerEmail || !requestOrigin) {
+ return resultWithoutExistingEmbedTicket;
}
const { normalizeEmbedTargetPath, createEmbedSessionTicket } =
@@ -1373,12 +1328,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 +1352,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 +1387,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 +1418,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 +1436,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 +1660,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 +1674,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 +1694,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 +1714,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 = getEmbedSessionOrigin();
+ 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 +1733,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 +1803,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.");
}
@@ -2111,34 +2075,33 @@ function getMcpAppResourceUri(
function expandRequestOriginSources(
sources: string[] | undefined,
- requestMeta?: MCPRequestMeta,
+ requestOrigin?: string,
): string[] | undefined {
if (!sources) return undefined;
- const origin = requestMeta?.origin;
return sources.flatMap((source) =>
- source === MCP_APP_REQUEST_ORIGIN_CSP_SOURCE && origin
- ? [origin]
+ source === MCP_APP_REQUEST_ORIGIN_CSP_SOURCE && requestOrigin
+ ? [requestOrigin]
: [source],
);
}
function openAiWidgetCsp(
cspConfig: ActionMcpAppCsp | undefined,
- requestMeta?: MCPRequestMeta,
+ requestOrigin?: string,
): Record | undefined {
if (!cspConfig) return undefined;
const csp: Record = {};
const connectDomains = expandRequestOriginSources(
cspConfig.connectDomains,
- requestMeta,
+ requestOrigin,
);
const resourceDomains = expandRequestOriginSources(
cspConfig.resourceDomains,
- requestMeta,
+ requestOrigin,
);
const frameDomains = expandRequestOriginSources(
cspConfig.frameDomains,
- requestMeta,
+ requestOrigin,
);
if (connectDomains?.length) csp.connect_domains = connectDomains;
if (resourceDomains?.length) csp.resource_domains = resourceDomains;
@@ -2149,7 +2112,7 @@ function openAiWidgetCsp(
function mcpAppUiMeta(
resource: ActionMcpAppResourceConfig,
resolvedCsp: ActionMcpAppCsp | undefined,
- requestMeta?: MCPRequestMeta,
+ requestOrigin?: string,
description?: string,
widgetDomain?: string,
directoryMode = false,
@@ -2181,21 +2144,21 @@ function mcpAppUiMeta(
...csp,
connectDomains: expandRequestOriginSources(
resolvedCsp.connectDomains,
- requestMeta,
+ requestOrigin,
),
resourceDomains: expandRequestOriginSources(
resolvedCsp.resourceDomains,
- requestMeta,
+ requestOrigin,
),
frameDomains: expandRequestOriginSources(
resolvedCsp.frameDomains,
- requestMeta,
+ requestOrigin,
),
...(!directoryMode
? {
baseUriDomains: expandRequestOriginSources(
resolvedCsp.baseUriDomains,
- requestMeta,
+ requestOrigin,
),
}
: {}),
@@ -2212,7 +2175,7 @@ function mcpAppUiMeta(
originString(resource.domain) ??
originString(ui.domain) ??
originString(existingUi.domain) ??
- originString(requestMeta?.origin);
+ originString(requestOrigin);
if (typeof resource.prefersBorder === "boolean") {
ui.prefersBorder = resource.prefersBorder;
}
@@ -2226,7 +2189,7 @@ function mcpAppUiMeta(
) {
base["openai/widgetPrefersBorder"] = resource.prefersBorder;
}
- const openAiCsp = openAiWidgetCsp(resolvedCsp, requestMeta);
+ const openAiCsp = openAiWidgetCsp(resolvedCsp, requestOrigin);
if (directoryMode) {
const directoryCsp = { ...(openAiCsp ?? {}) };
const redirectDomain = originString(widgetDomain);
@@ -2263,7 +2226,7 @@ async function resolveMcpAppCsp(
: resource.csp;
}
-async function resolveMcpAppResource(
+export async function resolveMcpAppResourceSafely(
config: MCPConfig,
actionName: string,
entry: ActionEntry,
@@ -2271,53 +2234,46 @@ 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;
}
const resolvedUri = getMcpAppResourceUri(config, actionName, entry);
if (!resolvedUri) return null;
- const description = resource.description ?? entry.tool.description;
- const resolvedCsp = await resolveMcpAppCsp(resource, {
- actionName,
- appId: config.appId,
- requestOrigin: requestMeta?.origin,
- catalogMode: config.catalogMode,
- });
- const resourceMeta = mcpAppUiMeta(
- resource,
- resolvedCsp,
- requestMeta,
- description,
- config.catalogMode === "directory" ? config.widgetDomain : undefined,
- config.catalogMode === "directory",
- );
- return {
- uri: resolvedUri.uri,
- ...(resolvedUri.legacyUris ? { legacyUris: resolvedUri.legacyUris } : {}),
- name: resource.name?.trim() || actionName,
- ...(resource.title ? { title: resource.title } : {}),
- ...(description ? { description } : {}),
- html: resource.html,
- mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE,
- ...(resourceMeta ? { _meta: resourceMeta } : {}),
- };
-}
-
-export async function resolveMcpAppResourceSafely(
- config: MCPConfig,
- actionName: string,
- entry: ActionEntry,
- requestMeta?: MCPRequestMeta,
-): Promise {
+ const requestOrigin = requestMeta?.resolveEmbedRequestOrigin
+ ? requestMeta.resolveEmbedRequestOrigin()
+ : requestMeta?.origin;
try {
- return await resolveMcpAppResource(config, actionName, entry, requestMeta);
+ const description = resource.description ?? entry.tool.description;
+ const resolvedCsp = await resolveMcpAppCsp(resource, {
+ actionName,
+ appId: config.appId,
+ requestOrigin,
+ catalogMode: config.catalogMode,
+ });
+ const resourceMeta = mcpAppUiMeta(
+ resource,
+ resolvedCsp,
+ requestOrigin,
+ description,
+ config.catalogMode === "directory" ? config.widgetDomain : undefined,
+ config.catalogMode === "directory",
+ );
+ return {
+ uri: resolvedUri.uri,
+ ...(resolvedUri.legacyUris ? { legacyUris: resolvedUri.legacyUris } : {}),
+ name: resource.name?.trim() || actionName,
+ ...(resource.title ? { title: resource.title } : {}),
+ ...(description ? { description } : {}),
+ html: resource.html,
+ requestOrigin,
+ mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE,
+ ...(resourceMeta ? { _meta: resourceMeta } : {}),
+ };
} catch (error) {
console.warn(
`[mcp] Skipping MCP App resource for action "${actionName}" because its metadata could not be resolved: ${describeThrownForLog(error)}`,
@@ -2350,9 +2306,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"];
@@ -2413,13 +2378,12 @@ function renderMcpAppHtml(
resource: ResolvedMcpAppResource,
actionName: string,
config: MCPConfig,
- requestMeta?: MCPRequestMeta,
): string {
if (typeof resource.html === "function") {
return resource.html({
actionName,
appId: config.appId,
- requestOrigin: requestMeta?.origin,
+ requestOrigin: resource.requestOrigin,
catalogMode: config.catalogMode,
startToolName:
config.catalogMode === "directory" &&
@@ -2900,14 +2864,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,
@@ -3111,6 +3079,9 @@ export async function createMCPServerForRequest(
? { orgScope: "personal" as const }
: {}),
...(requestMeta?.origin ? { requestOrigin: requestMeta.origin } : {}),
+ ...(requestMeta?.resolveEmbedRequestOrigin
+ ? { resolveEmbedRequestOrigin: requestMeta.resolveEmbedRequestOrigin }
+ : {}),
...(mcpRequestId ? { mcpRequestId } : {}),
mcpCredentialIssuedAtMs:
effectiveIdentity?.mcpCredentialIssuedAtMs ?? null,
@@ -3230,24 +3201,28 @@ export async function createMCPServerForRequest(
.sort(([a], [b]) => compareMcpCatalogValues(a, b))
.map(async ([name, entry]) => {
const hasLink = typeof entry.link === "function";
- const mcpAppResource = mcpAppWidgetsEnabledForIdentity(
- config,
- effectiveIdentity,
- )
- ? await resolveMcpAppResourceSafely(
- config,
- name,
- entry,
- requestMeta,
- )
- : null;
+ const mcpAppResource =
+ requestMeta?.inlineMcpApps === true &&
+ mcpAppWidgetsEnabledForIdentity(config, effectiveIdentity)
+ ? await resolveMcpAppResourceSafely(
+ config,
+ name,
+ entry,
+ requestMeta,
+ )
+ : null;
const rawToolMeta =
(entry.tool as any)._meta &&
typeof (entry.tool as any)._meta === "object" &&
!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 +3324,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 +3510,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 +3525,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);
@@ -3726,7 +3720,6 @@ export async function createMCPServerForRequest(
found.resource,
found.actionName,
config,
- requestMeta,
),
...(found.resource._meta
? { _meta: found.resource._meta }
diff --git a/packages/core/src/mcp/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..fbe87029338 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,17 @@ function openAppTool(
await import("../server/request-context.js");
const ctx = getRequestContext();
const ownerEmail = ctx?.userEmail?.trim();
- if (ownerEmail) {
+ const { getEmbedSessionOrigin } =
+ await import("../server/embed-request-origin.js");
+ const embedOrigin = getEmbedSessionOrigin();
+ if (ownerEmail && embedOrigin) {
const { normalizeEmbedTargetPath, createEmbedSessionTicket } =
await import("../server/embed-session.js");
const { buildEmbedStartPath } =
await import("../server/embed-route.js");
const targetPath = normalizeEmbedTargetPath(
withMcpChatBridgeParam(url),
- requestMeta?.origin,
+ embedOrigin,
);
if (targetPath) {
const ticket = await createEmbedSessionTicket({
@@ -906,9 +907,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, embedOrigin).toString();
embedTargetPath = targetPath;
embedExpiresAt = ticket.expiresAt;
}
@@ -953,9 +952,7 @@ function openAppTool(
};
}
-function createEmbedSessionTool(requestMeta?: {
- origin?: string;
-}): ActionEntry {
+function createEmbedSessionTool(): ActionEntry {
return {
tool: {
...tool(
@@ -982,6 +979,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();
@@ -995,21 +999,24 @@ function createEmbedSessionTool(requestMeta?: {
const { normalizeEmbedTargetPath, createEmbedSessionTicket } =
await import("../server/embed-session.js");
const { buildEmbedStartPath } = await import("../server/embed-route.js");
+ const { getEmbedSessionOrigin } =
+ await import("../server/embed-request-origin.js");
+ const embedOrigin = getEmbedSessionOrigin();
const rawTarget =
typeof args.url === "string" && args.url.trim()
? args.url
: typeof args.path === "string"
? args.path
: "";
- const targetPath = normalizeEmbedTargetPath(
- rawTarget,
- requestMeta?.origin,
- );
+ const targetPath = normalizeEmbedTargetPath(rawTarget, embedOrigin);
if (!targetPath) {
throw new Error(
"create_embed_session can only embed same-origin app-relative URLs.",
);
}
+ if (!embedOrigin) {
+ return { url: targetPath, targetPath, embed: false };
+ }
const ticket = await createEmbedSessionTicket({
ownerEmail,
@@ -1018,15 +1025,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, embedOrigin).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 +1520,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..8d0e58c658c 100644
--- a/packages/core/src/mcp/connector-catalog.spec.ts
+++ b/packages/core/src/mcp/connector-catalog.spec.ts
@@ -153,7 +153,7 @@ function makeEvent(opts: {
const webReq = new Request(reqUrl, { method: "POST", headers });
return {
method: "POST",
- url: { pathname: "/" },
+ url: new URL(reqUrl),
path: "/",
req: webReq,
_headers: headers,
@@ -300,7 +300,8 @@ const connectorConfig = {
connectorCatalog: CONNECTOR_CATALOG,
};
-vi.mock("h3", () => ({
+vi.mock("h3", async (importOriginal) => ({
+ ...(await importOriginal()),
defineEventHandler: (fn: any) => fn,
getMethod: (event: any) => event.method ?? "GET",
getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()],
@@ -405,7 +406,6 @@ describe("connector-catalog tier", () => {
"open_app",
"ask_app",
"ask_app_status",
- "create_embed_session",
].sort();
expect(names).toEqual(expected);
@@ -428,7 +428,6 @@ describe("connector-catalog tier", () => {
"open_app",
"ask_app",
"ask_app_status",
- "create_embed_session",
].sort(),
);
@@ -1034,7 +1033,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 +1114,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 +1175,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/directory-template-profiles.spec.ts b/packages/core/src/mcp/directory-template-profiles.spec.ts
index ef54487331b..32196757aa5 100644
--- a/packages/core/src/mcp/directory-template-profiles.spec.ts
+++ b/packages/core/src/mcp/directory-template-profiles.spec.ts
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { CHATGPT_DIRECTORY_PROFILE as contentProfile } from "../../../../templates/content/server/lib/chatgpt-directory-tools.js";
import { CHATGPT_DIRECTORY_PROFILE as designProfile } from "../../../../templates/design/server/lib/chatgpt-directory-tools.js";
+import { CHATGPT_DIRECTORY_PROFILE as slides2Profile } from "../../../../templates/slides-2/server/lib/chatgpt-directory-tools.js";
import { CHATGPT_DIRECTORY_PROFILE as slidesProfile } from "../../../../templates/slides/server/lib/chatgpt-directory-tools.js";
import { isActionHiddenFromEveryAgentSurface } from "../action.js";
import {
@@ -21,6 +22,7 @@ import {
filterMcpOnlyActions,
} from "../server/agent-chat/action-filters-a2a.js";
import { resolveAgentChatMcpOptions } from "../server/agent-chat/mcp-options.js";
+import * as embedSession from "../server/embed-session.js";
import {
canRenewMcpDirectoryWidgetCapabilityScope,
createMcpDirectoryWidgetReadCapability,
@@ -109,12 +111,14 @@ async function loadTemplateActions(appId: string) {
path.join(projectRoot, ".generated/actions-registry.ts"),
"utf8",
);
- const profile = templateProfiles.find(
- (profile) => profile.appId === appId,
- )?.profile;
+ const profile =
+ appId === "slides-2"
+ ? slides2Profile
+ : templateProfiles.find((profile) => profile.appId === appId)?.profile;
if (!profile) throw new Error(`Unknown ChatGPT directory template ${appId}.`);
const toolNames = profile.connectorCatalog;
- const sharedActions = sharedActionsByApp[appId] ?? {};
+ const sharedActions =
+ sharedActionsByApp[appId === "slides-2" ? "slides" : appId] ?? {};
const loadNames = [
...new Set([
...toolNames,
@@ -183,6 +187,130 @@ function mentionsTool(text: string, name: string): boolean {
}
describe("ChatGPT directory template profiles", () => {
+ it.each(
+ [
+ { appId: "slides-2", profile: slides2Profile },
+ { appId: "slides", profile: slidesProfile },
+ ].flatMap((template) =>
+ [
+ { mode: "generic", catalogMode: undefined },
+ { mode: "directory", catalogMode: "directory" as const },
+ ].map((mode) => ({
+ ...template,
+ ...mode,
+ })),
+ ),
+ )(
+ "$appId create/add return full-app links without UI resources or tickets in $mode mode",
+ async ({ appId, profile, catalogMode }) => {
+ const mintTicket = vi
+ .spyOn(embedSession, "createEmbedSessionTicket")
+ .mockRejectedValue(
+ new Error("Plain links must not mint embed tickets."),
+ );
+ const { actions, productionActions } = await loadTemplateActions(appId);
+ const names = ["create-deck", "add-slide"];
+ const deckId = "deck-full-app-link";
+ const payload = { id: deckId, deckId, title: "Test deck", slideCount: 1 };
+ const entries = {
+ ...productionActions,
+ ...Object.fromEntries(
+ names.map((name) => [
+ name,
+ {
+ ...productionActions[name]!,
+ schema: undefined,
+ run: async () => payload,
+ },
+ ]),
+ ),
+ };
+ const server = await createMCPServerForRequest(
+ {
+ name: `agent-native-${appId}`,
+ appId,
+ description: "Slides full-app links",
+ catalogMode,
+ builtinCrossAppTools: false,
+ connectorCatalog: profile.connectorCatalog,
+ widgetDomain: profile.widgetDomain,
+ directoryProfile: profile,
+ actions: entries,
+ productionActions: entries,
+ widgetReadActions: selectMcpDirectoryWidgetReadActions(
+ profile,
+ actions,
+ ),
+ widgetWriteActions: selectMcpDirectoryWidgetWriteActions(
+ profile,
+ actions,
+ ),
+ },
+ {
+ userEmail: "reviewer@example.test",
+ identityAssurance: "user",
+ mcpCredentialIssuedAtMs: Date.now() - 1_000,
+ orgId: null,
+ orgDomain: undefined,
+ },
+ {
+ origin: profile.widgetDomain,
+ transport: "http",
+ inlineMcpApps: true,
+ },
+ );
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: "slides-links-spec", version: "1" });
+ try {
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+ const { tools } = await client.listTools();
+ for (const name of names) {
+ const tool = tools.find((tool) => tool.name === name);
+ expect(tool, name).toBeDefined();
+ expect(tool?._meta?.ui, name).toBeUndefined();
+ expect(tool?._meta?.["openai/outputTemplate"], name).toBeUndefined();
+ const result = await client.callTool({ name, arguments: { deckId } });
+ expect(result.isError, name).not.toBe(true);
+ const openLink = result._meta?.["agent-native/openLink"] as {
+ webUrl: string;
+ label: string;
+ };
+ expect(openLink, name).toMatchObject({
+ label:
+ appId === "slides"
+ ? "Open deck in Slides"
+ : "Open deck in Slides 2",
+ });
+ const url = new URL(openLink.webUrl);
+ expect(url.origin).toBe(profile.widgetDomain);
+ expect(url.pathname).toBe("/_agent-native/open");
+ expect(url.searchParams.get("app")).toBe(appId);
+ expect(url.searchParams.get("view")).toBe("editor");
+ expect(url.searchParams.get("deckId")).toBe(deckId);
+ expect(JSON.stringify(result.content)).toContain(openLink.webUrl);
+ expect(JSON.stringify(result)).not.toMatch(
+ /ui:\/\/|embed\/start|embedTicket|openai\/outputTemplate/,
+ );
+ expect(result._meta?.ui).toBeUndefined();
+ }
+ if (catalogMode === "directory") {
+ expect(client.getServerCapabilities()?.resources).toBeUndefined();
+ } else {
+ expect((await client.listResources()).resources).toEqual([]);
+ }
+ expect(mintTicket).not.toHaveBeenCalled();
+ } finally {
+ await Promise.all([client.close(), server.close()]);
+ mintTicket.mockRestore();
+ }
+ },
+ ACTION_REGISTRY_TEST_TIMEOUT_MS,
+ );
+
it.each(templateProfiles)(
"$appId keeps the full widget enabled at its canonical domain",
({ appId, profile }) => {
@@ -1095,22 +1223,26 @@ describe("ChatGPT directory template profiles", () => {
const sessionTool = tools.find(
(tool) => tool.name === "create_embed_session",
);
- expect(sessionTool?.annotations).toMatchObject({
- readOnlyHint: false,
- destructiveHint: false,
- openWorldHint: false,
- });
- expect(sessionTool?._meta?.ui?.visibility).toEqual(["app"]);
- expect(sessionTool?.inputSchema.required).toEqual(["sourceTicket"]);
- expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
- "sourceTool",
- );
- expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
- "toolInput",
- );
- expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
- "toolOutput",
- );
+ if (widgetTargetNames.length === 0) {
+ expect(sessionTool).toBeUndefined();
+ } else {
+ expect(sessionTool?.annotations).toMatchObject({
+ readOnlyHint: false,
+ destructiveHint: false,
+ openWorldHint: false,
+ });
+ expect(sessionTool?._meta?.ui?.visibility).toEqual(["app"]);
+ expect(sessionTool?.inputSchema.required).toEqual(["sourceTicket"]);
+ expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
+ "sourceTool",
+ );
+ expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
+ "toolInput",
+ );
+ expect(sessionTool?.inputSchema.properties).not.toHaveProperty(
+ "toolOutput",
+ );
+ }
} finally {
await Promise.all([client.close(), server.close()]);
}
@@ -1522,7 +1654,9 @@ describe("ChatGPT directory template profiles", () => {
widgetTargets: { "create-document": target, "get-document": target },
},
}),
- ).toThrow(/widget target "get-document" must name a listed action/);
+ ).toThrow(
+ /widget target "get-document" must name an MCP action with an mcpApp resource/,
+ );
});
it("keeps a denied create_embed_session out of tools/list and tools/call", async () => {
@@ -1715,11 +1849,16 @@ describe("Slides widget share grant", () => {
expiresAtMs = Date.now() + fifteenMinutes,
}: { userEmail?: string; orgId?: string; expiresAtMs?: number } = {},
) {
- const target = slidesProfile.widgetTargets["create-deck"](
- {},
- { id: deckId },
- );
- if (!target) throw new Error("Slides create-deck target is missing.");
+ const target = {
+ targetPath: `/deck/${encodeURIComponent(deckId)}`,
+ resourceIds: { deckId, resourceType: "deck" } as Record,
+ writeActions: [
+ "patch-deck",
+ "share-resource",
+ "unshare-resource",
+ "set-resource-visibility",
+ ],
+ };
const materialize = (rules: ArgumentRules): ArgumentRules =>
Object.fromEntries(
Object.entries(rules).map(([name, rule]) => [
diff --git a/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/legacy-unreviewed-destructive-hint.json b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json
index 1f1580d53d0..8e03ecbdb91 100644
--- a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json
+++ b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json
@@ -25,7 +25,6 @@
"create-vault-secret",
"create-workspace-resource",
"create-workspace-resource-grant",
- "create_embed_session",
"create_workspace_app",
"creative-context-a2a",
"delete-data-program",
diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts
index 9093db8895e..acaec2e1d6d 100644
--- a/packages/core/src/mcp/server.spec.ts
+++ b/packages/core/src/mcp/server.spec.ts
@@ -18,15 +18,22 @@ 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";
import { loadActionsFromStaticRegistry } from "../server/action-discovery.js";
import { registerErrorCaptureProvider } from "../server/capture-error.js";
+import { resolveEmbedRequestOrigin } from "../server/embed-request-origin.js";
import { filesystemActionEntry } from "../server/filesystem-action-entry.js";
+import {
+ EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS,
+ WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS,
+} from "../server/origin-allowlist.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 +41,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(() => ({
@@ -372,14 +381,19 @@ function makeWebEvent(opts: MakeEventOpts): any {
authorization: "Bearer test-access-token",
...(opts.headers ?? {}),
};
- const reqUrl = `https://mail.agent-native.com${opts.path ?? "/"}`;
+ const host = headers.host || "mail.agent-native.com";
+ const protocol = /^(localhost|127\.0\.0\.1)(:|$)/.test(host)
+ ? "http"
+ : "https";
+ const reqUrl = `${protocol}://${host}${opts.path ?? "/"}`;
const webReq = new Request(reqUrl, {
method: opts.method ?? "POST",
headers,
});
+ Object.assign(webReq, { context: { clientAddress: opts.ip } });
const event: any = {
method: opts.method ?? "POST",
- url: { pathname: (opts.path ?? "/").split("?")[0] },
+ url: new URL(reqUrl),
path: opts.path ?? "/",
req: webReq,
_headers: headers,
@@ -424,13 +438,13 @@ function makeWebEvent(opts: MakeEventOpts): any {
return event;
}
-vi.mock("h3", () => ({
+vi.mock("h3", async (importOriginal) => ({
+ ...(await importOriginal()),
defineEventHandler: (fn: any) => fn,
getMethod: (event: any) => event.method ?? "GET",
getHeader: (event: any, name: string) => event._headers?.[name.toLowerCase()],
getRequestHeader: (event: any, name: string) =>
event._headers?.[name.toLowerCase()],
- getRequestIP: (event: any) => event._ip,
getQuery: (event: any) => event._query ?? {},
setResponseStatus: (event: any, code: number) => {
event._status = code;
@@ -528,6 +542,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,
);
@@ -650,7 +732,10 @@ async function callWeb(
opts.routePath,
);
expect(res).toBeInstanceOf(Response);
- const response = res as Response;
+ return readWebResponse(res as Response);
+}
+
+async function readWebResponse(response: Response): Promise {
const ct = response.headers.get("content-type") || "";
const text = await response.text();
if (ct.includes("text/event-stream")) {
@@ -779,12 +864,6 @@ async function mcpAppsAuthHeaders(
}
const directoryWidgetTemplates = [
- {
- appId: "slides",
- profile: slidesDirectoryProfile,
- toolName: "create-deck",
- result: { id: "deck-1" },
- },
{
appId: "design",
profile: designDirectoryProfile,
@@ -1030,6 +1109,41 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
);
});
+ it("validates a widget target on an ordinary MCP action outside the directory catalog", () => {
+ const entry = defineAction({
+ description: "Read one record.",
+ parameters: {},
+ readOnly: true,
+ mcpAnnotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ openWorldHint: false,
+ },
+ mcpApp: {
+ resource: {
+ title: "Record",
+ html: "Record",
+ },
+ },
+ run: async () => ({ id: "record-1" }),
+ });
+ expect(() =>
+ mcpBuildServer.validateMcpDirectoryProfile({
+ ...config,
+ actions: { "directory-record": entry, "ordinary-record": entry },
+ directoryProfile: {
+ connectorCatalog: ["directory-record"],
+ widgetTargets: {
+ "ordinary-record": () => ({
+ targetPath: "/record/record-1",
+ resourceIds: { documentId: "record-1" },
+ }),
+ },
+ },
+ }),
+ ).not.toThrow();
+ });
+
it("keeps a legacy directory catalog discoverable without widgets when widgetTargets is absent", async () => {
const legacyWidget = defineAction({
description: "Read one legacy directory record.",
@@ -1099,7 +1213,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
{ appId: "content", profile: contentDirectoryProfile },
] as const;
const expectedWidgetToolNames = {
- slides: ["add-slide", "create-deck"],
+ slides: [],
design: [
"create-design",
"create-design-from-template",
@@ -1361,7 +1475,9 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
expect(init.body?.result?.protocolVersion).toBe(protocolVersion);
expect(init.body?.result?.instructions).toBe(profile.instructions);
expect(init.body?.result?.capabilities?.prompts).toBeUndefined();
- expect(resourcesSupported).toBe(profile.widgets !== false);
+ expect(resourcesSupported).toBe(
+ expectedWidgetToolNames[appId].length > 0,
+ );
const modelVisibleTools = listedTools.filter((tool: any) => {
const visibility = tool._meta?.ui?.visibility;
return !(
@@ -1424,7 +1540,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
directiveCopyPattern.test(entry.text),
);
expect(directiveCopy).toEqual([]);
- if (profile.widgets === false) {
+ if (expectedWidgetToolNames[appId].length === 0) {
expect(linkedUris).toEqual([]);
expect(JSON.stringify(listedTools)).not.toMatch(
/ui:\/\/|openai\/(?:ui|outputTemplate|widget)/,
@@ -2274,6 +2390,465 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
).rejects.toThrow(/readOnlyHint must match its readOnly action setting/);
});
+ describe("trusted widget request origin", () => {
+ beforeEach(() => {
+ for (const key of [
+ ...EXPLICIT_PUBLIC_ORIGIN_ENV_KEYS,
+ ...WORKSPACE_GATEWAY_ORIGIN_ENV_KEYS,
+ "BETTER_AUTH_TRUSTED_ORIGINS",
+ ])
+ vi.stubEnv(key, "");
+ });
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ async function expectWidgetResourceOrigin(
+ sourceEvent: any,
+ expectedOrigin: string,
+ ) {
+ const cspOrigins: Array = [];
+ const widgetConfig = scopedWidgetConfig({
+ ...config,
+ actions: {
+ "echo-thing": {
+ ...config.actions["echo-thing"],
+ mcpApp: {
+ resource: {
+ ...config.actions["echo-thing"].mcpApp.resource,
+ csp: ({ requestOrigin }) => {
+ cspOrigins.push(requestOrigin);
+ return {
+ connectDomains: [
+ "$requestOrigin",
+ "https://api.example.com",
+ ],
+ resourceDomains: ["$requestOrigin"],
+ frameDomains: ["$requestOrigin"],
+ baseUriDomains: ["$requestOrigin"],
+ };
+ },
+ },
+ },
+ },
+ },
+ });
+ const expectedCsp = {
+ connect_domains: [expectedOrigin, "https://api.example.com"],
+ resource_domains: [expectedOrigin],
+ frame_domains: [expectedOrigin],
+ };
+ for (const method of [
+ "tools/list",
+ "resources/list",
+ "resources/templates/list",
+ "resources/read",
+ "tools/call",
+ ]) {
+ const event = makeWebEvent({
+ node: Boolean(sourceEvent.node),
+ ip: sourceEvent._ip,
+ headers: {
+ ...sourceEvent._headers,
+ "x-agent-native-mcp-full-catalog": "1",
+ },
+ body: {
+ jsonrpc: "2.0",
+ id: 142,
+ method,
+ params:
+ method === "resources/read"
+ ? { uri: "ui://mail/echo-thing/shell-v65" }
+ : method === "tools/call"
+ ? { name: "echo-thing", arguments: { value: "hello" } }
+ : {},
+ },
+ });
+ event.url = new URL(sourceEvent.url);
+ event.req.runtime = sourceEvent.req.runtime;
+ event.req.ip = sourceEvent.req.ip;
+ const response = await handleMcpRequest(event, widgetConfig);
+ expect(response).toBeInstanceOf(Response);
+ const out = await readWebResponse(response as Response);
+ expect(out.error).toBeUndefined();
+ expect(out.result.isError).not.toBe(true);
+ const item =
+ method === "tools/list"
+ ? out.result.tools.find((tool: any) => tool.name === "echo-thing")
+ : method === "resources/list"
+ ? out.result.resources[0]
+ : method === "resources/templates/list"
+ ? out.result.resourceTemplates[0]
+ : method === "resources/read"
+ ? out.result.contents[0]
+ : out.result;
+ expect(item._meta["openai/widgetCSP"]).toEqual(expectedCsp);
+ expect(JSON.stringify(item._meta["openai/widgetCSP"])).not.toContain(
+ "attacker.example",
+ );
+ if (method.startsWith("resources/")) {
+ expect(item._meta.ui.csp).toEqual({
+ connectDomains: [expectedOrigin, "https://api.example.com"],
+ resourceDomains: [expectedOrigin],
+ frameDomains: [expectedOrigin],
+ baseUriDomains: [expectedOrigin],
+ });
+ expect(item._meta.ui.domain).toBeUndefined();
+ expect(item._meta["openai/widgetDomain"]).toBe(expectedOrigin);
+ }
+ if (method === "resources/read") {
+ expect(item.text).toContain(`data-origin="${expectedOrigin}"`);
+ expect(item.text).not.toContain("attacker.example");
+ }
+ }
+ expect(cspOrigins).toEqual(Array(5).fill(expectedOrigin));
+ }
+
+ it.each([false, true])(
+ "uses the raw request origin for widget tickets despite untrusted forwarded headers (Node: %s)",
+ async (node) => {
+ const actual = await vi.importActual<
+ typeof import("../server/embed-session.js")
+ >("../server/embed-session.js");
+ embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce(
+ async (input) => ({
+ ...(await actual.createEmbedSessionTicket(
+ input as Parameters[0],
+ )),
+ targetPath: input.targetPath,
+ }),
+ );
+ const event = makeWebEvent({
+ node,
+ ip: "203.0.113.20",
+ headers: {
+ ...(await mcpAppsAuthHeaders({
+ resource: "http://attacker.example/_agent-native/mcp",
+ issuer: "http://attacker.example",
+ })),
+ "x-forwarded-host": "attacker.example",
+ "x-forwarded-proto": "http",
+ "x-forwarded-for": "127.0.0.1",
+ },
+ body: {
+ jsonrpc: "2.0",
+ id: 138,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ const response = await handleMcpRequest(
+ event,
+ scopedWidgetConfig(config),
+ );
+ expect(response).toBeInstanceOf(Response);
+ const called = await readWebResponse(response as Response);
+ expect(called.result.isError).not.toBe(true);
+ const startUrl = new URL(
+ called.result._meta["agent-native/embedStart"].startUrl,
+ );
+ expect(startUrl.origin).toBe("https://mail.agent-native.com");
+ expect(startUrl.pathname).toBe("/_agent-native/embed/start");
+ expect(startUrl.searchParams.get("__an_mcp_chat_bridge")).toBe("1");
+ const ticket = startUrl.searchParams.get("ticket");
+ expect(ticket).toBeTruthy();
+ expect(
+ embedSessionMocks.createEmbedSessionTicket,
+ ).toHaveBeenCalledOnce();
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(ticket!, {
+ audienceHost: "mail.agent-native.com",
+ }),
+ ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" });
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(ticket!, {
+ audienceHost: "attacker.example",
+ }),
+ ).resolves.toBeNull();
+ expect(startUrl.hostname).not.toBe("attacker.example");
+ await expectWidgetResourceOrigin(event, startUrl.origin);
+ },
+ );
+
+ it("retains the forwarded MCP origin for an ordinary non-embed tool", async () => {
+ const { getRequestContext } =
+ await import("../server/request-context.js");
+ const response = await callWeb(
+ {
+ jsonrpc: "2.0",
+ id: 140,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "origin" } },
+ },
+ {
+ headers: {
+ ...(await mcpAppsAuthHeaders({
+ resource: "https://workspace.example.test/_agent-native/mcp",
+ issuer: "https://workspace.example.test",
+ })),
+ host: "internal.gateway:8100",
+ "x-forwarded-host": "workspace.example.test",
+ "x-forwarded-proto": "https",
+ },
+ config: {
+ ...config,
+ catalogMode: "app",
+ actions: {
+ "echo-thing": {
+ ...config.actions["echo-thing"],
+ mcpApp: undefined,
+ link: undefined,
+ run: async () => ({
+ origin: getRequestContext()?.requestOrigin,
+ }),
+ },
+ },
+ },
+ },
+ );
+ expect(response.result).toMatchObject({
+ structuredContent: { origin: "https://workspace.example.test" },
+ });
+ expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled();
+ });
+
+ it.each([undefined, "203.0.113.20"])(
+ "uses the public Netlify request origin without an allowlist (peer: %s)",
+ async (ip) => {
+ const actual = await vi.importActual<
+ typeof import("../server/embed-session.js")
+ >("../server/embed-session.js");
+ embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce(
+ async (input) => ({
+ ...(await actual.createEmbedSessionTicket(
+ input as Parameters[0],
+ )),
+ targetPath: input.targetPath,
+ }),
+ );
+ const event = makeWebEvent({
+ headers: {
+ ...(await mcpAppsAuthHeaders()),
+ host: "mail.agent-native.com",
+ "x-forwarded-host": "mail.agent-native.com",
+ "x-forwarded-proto": "https",
+ ...(ip ? { "x-nf-client-connection-ip": ip } : {}),
+ },
+ body: {
+ jsonrpc: "2.0",
+ id: 141,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ // Nitro's Netlify adapter preserves Request.url and copies this header to req.ip.
+ event.req.runtime = { name: "netlify" };
+ event.req.ip =
+ event.req.headers.get("x-nf-client-connection-ip") || undefined;
+ const { getRequestIP } = await import("h3");
+ expect(getRequestIP(event)).toBe(ip);
+ const { getConfiguredOriginAllowlist } =
+ await import("../server/origin-allowlist.js");
+ expect(getConfiguredOriginAllowlist().size).toBe(0);
+ const response = await handleMcpRequest(
+ event,
+ scopedWidgetConfig(config),
+ );
+ const called = await readWebResponse(response as Response);
+ expect(called.result.isError).not.toBe(true);
+ const startUrl = new URL(
+ called.result._meta["agent-native/embedStart"].startUrl,
+ );
+ expect(startUrl.origin).toBe("https://mail.agent-native.com");
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(
+ startUrl.searchParams.get("ticket")!,
+ { audienceHost: "mail.agent-native.com" },
+ ),
+ ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" });
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(
+ startUrl.searchParams.get("ticket")!,
+ { audienceHost: "attacker.example" },
+ ),
+ ).resolves.toBeNull();
+ await expectWidgetResourceOrigin(event, startUrl.origin);
+ },
+ );
+
+ it.each(["APP_URL", "WORKSPACE_GATEWAY_URL"])(
+ "uses an allowlisted non-loopback gateway for minting and renewal via %s",
+ async (key) => {
+ vi.stubEnv(key, "https://workspace.example.test");
+ const actual = await vi.importActual<
+ typeof import("../server/embed-session.js")
+ >("../server/embed-session.js");
+ embedSessionMocks.createEmbedSessionTicket.mockImplementationOnce(
+ async (input) => ({
+ ...(await actual.createEmbedSessionTicket(
+ input as Parameters[0],
+ )),
+ targetPath: input.targetPath,
+ }),
+ );
+ const gatewayConfig = scopedWidgetConfig(config);
+ const headers = {
+ ...(await mcpAppsAuthHeaders({
+ resource: "https://workspace.example.test/_agent-native/mcp",
+ issuer: "https://workspace.example.test",
+ })),
+ host: "internal.gateway:8100",
+ "x-forwarded-host": "workspace.example.test",
+ "x-forwarded-proto": "https",
+ };
+ const event = makeWebEvent({
+ ip: "203.0.113.20",
+ headers,
+ body: {
+ jsonrpc: "2.0",
+ id: 138,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ event.url = new URL("http://internal.gateway:8100/");
+ const response = await handleMcpRequest(event, gatewayConfig);
+ const called = await readWebResponse(response as Response);
+ expect(called.result.isError).not.toBe(true);
+ const startUrl = new URL(
+ called.result._meta["agent-native/embedStart"].startUrl,
+ );
+ expect(startUrl.origin).toBe("https://workspace.example.test");
+ const sourceTicket = startUrl.searchParams.get("ticket")!;
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, {
+ audienceHost: "workspace.example.test",
+ }),
+ ).resolves.toMatchObject({ ownerEmail: "oauth@example.com" });
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, {
+ audienceHost: "internal.gateway",
+ }),
+ ).resolves.toBeNull();
+ await expect(
+ actual.readMcpDirectoryWidgetRenewalTicket(sourceTicket, {
+ audienceHost: "attacker.example",
+ }),
+ ).resolves.toBeNull();
+ embedSessionMocks.readMcpDirectoryWidgetRenewalTicket.mockImplementationOnce(
+ actual.readMcpDirectoryWidgetRenewalTicket,
+ );
+ const renewal = await callWeb(
+ {
+ jsonrpc: "2.0",
+ id: 139,
+ method: "tools/call",
+ params: {
+ name: "create_embed_session",
+ arguments: { sourceTicket },
+ },
+ },
+ { headers, config: gatewayConfig },
+ );
+ expect(renewal.result.isError).not.toBe(true);
+ expect(
+ new URL(renewal.result._meta["agent-native/embedStart"].startUrl)
+ .origin,
+ ).toBe("https://workspace.example.test");
+ await expectWidgetResourceOrigin(event, startUrl.origin);
+ },
+ );
+
+ it("uses validated forwarded authority from a loopback proxy for widget tickets", async () => {
+ vi.stubEnv("APP_URL", "https://mail.agent-native.com");
+ const event = makeWebEvent({
+ ip: "::ffff:127.0.0.1",
+ headers: {
+ ...(await mcpAppsAuthHeaders()),
+ host: "127.0.0.1:8100",
+ "x-forwarded-host": "mail.agent-native.com",
+ "x-forwarded-proto": "https",
+ },
+ body: {
+ jsonrpc: "2.0",
+ id: 138,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ const response = await handleMcpRequest(
+ event,
+ scopedWidgetConfig(config),
+ );
+ expect(response).toBeInstanceOf(Response);
+ const called = await readWebResponse(response as Response);
+ expect(called.result._meta["agent-native/embedStart"].startUrl).toBe(
+ "https://mail.agent-native.com/_agent-native/embed/start?ticket=minted-picker-ticket&__an_mcp_chat_bridge=1",
+ );
+ expect(embedSessionMocks.createEmbedSessionTicket).toHaveBeenCalledOnce();
+ });
+
+ it.each([
+ { "x-forwarded-host": "mail.agent-native.com/path" },
+ { "x-forwarded-host": "", "x-forwarded-proto": "https" },
+ { "x-forwarded-proto": "javascript" },
+ ])(
+ "rejects invalid trusted proxy authority before minting: %j",
+ async (headers) => {
+ const event = makeWebEvent({
+ ip: "127.0.0.1",
+ headers: { ...(await mcpAppsAuthHeaders()), ...headers },
+ body: {
+ jsonrpc: "2.0",
+ id: 138,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ const response = await handleMcpRequest(
+ event,
+ scopedWidgetConfig(config),
+ );
+ if (response instanceof Response) {
+ const called = await readWebResponse(response);
+ expect(called.result.isError).toBe(true);
+ } else {
+ expect(event._status).toBe(401);
+ expect(response).toMatchObject({ error: "Unauthorized" });
+ }
+ expect(
+ embedSessionMocks.createEmbedSessionTicket,
+ ).not.toHaveBeenCalled();
+ },
+ );
+
+ it("does not recover missing raw authority from untrusted forwarded headers", async () => {
+ const event = makeWebEvent({
+ ip: "203.0.113.20",
+ headers: {
+ ...(await mcpAppsAuthHeaders()),
+ "x-forwarded-host": "attacker.example",
+ "x-forwarded-proto": "https",
+ },
+ body: {
+ jsonrpc: "2.0",
+ id: 138,
+ method: "tools/call",
+ params: { name: "echo-thing", arguments: { value: "hello" } },
+ },
+ });
+ delete event.url;
+ event.req = { headers: event.req.headers, url: "/" };
+ const response = await handleMcpRequest(
+ event,
+ scopedWidgetConfig(config),
+ );
+ expect(event._status).toBe(401);
+ expect(response).toMatchObject({ error: "Unauthorized" });
+ expect(embedSessionMocks.createEmbedSessionTicket).not.toHaveBeenCalled();
+ });
+ });
+
it("mints a regular MCP widget embed ticket from an action link", async () => {
const createArtifact = defineAction({
description: "Create one editable document.",
@@ -2287,6 +2862,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 +2880,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 +2911,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,24 +2945,161 @@ 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 legacyToolCall = await callWeb(
- {
- jsonrpc: "2.0",
- id: 139,
- method: "tools/call",
- params: { name: "create_embed_session", arguments: { path: "/" } },
- },
- {
- headers,
- config: regularConfig,
- },
- );
- expect(legacyToolCall.result.isError).toBe(true);
- expect(JSON.stringify(legacyToolCall)).not.toContain(
- "create_embed_session completed",
+ 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(
+ {
+ jsonrpc: "2.0",
+ id: 139,
+ method: "tools/call",
+ params: { name: "create_embed_session", arguments: { path: "/" } },
+ },
+ {
+ headers,
+ config: regularConfig,
+ },
+ );
+ expect(legacyToolCall.result.isError).toBe(true);
+ expect(JSON.stringify(legacyToolCall)).not.toContain(
+ "create_embed_session completed",
);
const wrongAudience = await handleMcpRequest(
@@ -2609,7 +3353,13 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
widgetDomain: "https://slides.agent-native.com",
authorizeWidgetWrite,
widgetTargets: {
- "create-deck": slidesDirectoryProfile.widgetTargets["create-deck"],
+ "create-deck": () => ({
+ targetPath: "/deck/deck-a",
+ resourceIds: { deckId: "deck-a", resourceType: "deck" },
+ writeActions: Object.keys(
+ slidesDirectoryProfile.widgetWriteActionArguments,
+ ),
+ }),
},
widgetReadActionArguments:
slidesDirectoryProfile.widgetReadActionArguments,
@@ -3590,6 +4340,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 +9796,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 +9821,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 +9875,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 +9922,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)",
},
{
headers: await mcpAppsFullCatalogHeaders(),
- config: embedConfig,
+ config: scopedWidgetConfig(embedConfig),
},
);
@@ -9184,7 +9935,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 +10001,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 +10010,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 +10052,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 +10061,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 +10105,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 +10120,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 +10209,498 @@ 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 event = makeWebEvent({
+ headers: { "x-forwarded-host": "mail.agent-native.com/path" },
+ });
+ const resolveWidgetOrigin = vi.fn(() =>
+ resolveEmbedRequestOrigin(event),
+ );
+ 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",
+ resolveEmbedRequestOrigin: resolveWidgetOrigin,
+ },
+ );
+ 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();
+ expect(resolveWidgetOrigin).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 +10709,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 +10720,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 +11152,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 +11171,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 +11214,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 +11224,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 +11287,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 +11306,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/mcp/server.ts b/packages/core/src/mcp/server.ts
index dd70032e7c8..5653e9f8948 100644
--- a/packages/core/src/mcp/server.ts
+++ b/packages/core/src/mcp/server.ts
@@ -15,6 +15,7 @@ import {
type BearerCredentialRefusal,
} from "../server/bearer-credential-refusal.js";
import { CREDENTIAL_MEMBERSHIP_UNAVAILABLE_MESSAGE } from "../server/credential-membership-unavailable.js";
+import { resolveEmbedRequestOrigin } from "../server/embed-request-origin.js";
import { getH3App } from "../server/framework-request-handler.js";
import { getOrigin } from "../server/google-oauth.js";
import { readBody } from "../server/h3-helpers.js";
@@ -448,6 +449,7 @@ async function handleMcpRequestInternal(
const serverRequestMeta: MCPRequestMeta = {
...requestMeta,
+ resolveEmbedRequestOrigin: () => resolveEmbedRequestOrigin(event),
...(requestConfig.createLocalAgentTaskClient
? {
localAgentTaskClient: requestConfig.createLocalAgentTaskClient(event),
diff --git a/packages/core/src/org/admin-action-errors.spec.ts b/packages/core/src/org/admin-action-errors.spec.ts
index b3ece180d3b..e412cc3975b 100644
--- a/packages/core/src/org/admin-action-errors.spec.ts
+++ b/packages/core/src/org/admin-action-errors.spec.ts
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
execute: vi.fn(),
insertAuditEvent: vi.fn(),
checkAction: vi.fn(),
+ getBrowserCookieSession: vi.fn(),
}));
vi.mock("../db/client.js", async (importOriginal) => ({
...(await importOriginal()),
@@ -18,6 +19,10 @@ vi.mock("../audit/store.js", () => ({
vi.mock("../authorization/check-action.js", () => ({
checkAction: mocks.checkAction,
}));
+vi.mock("../server/auth.js", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getBrowserCookieSession: mocks.getBrowserCookieSession,
+}));
// The calls come from the signed-in app, which is where approval is given.
vi.mock("../server/ui-action-capability.js", async (importOriginal) => ({
...(await importOriginal<
@@ -73,6 +78,9 @@ async function invoke(
headers: {
"content-type": "application/json",
"x-agent-native-frontend": "1",
+ host: url.host,
+ origin: url.origin,
+ cookie: "an_session=test-browser-session",
},
body: JSON.stringify(args),
}),
@@ -87,6 +95,9 @@ describe("admin-only action errors", () => {
mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
mocks.insertAuditEvent.mockResolvedValue(undefined);
mocks.checkAction.mockResolvedValue({ allowed: true });
+ mocks.getBrowserCookieSession.mockResolvedValue({
+ email: member.userEmail,
+ });
});
it.each(["frontend", "http", "tool", "mcp"] satisfies ActionCaller[])(
diff --git a/packages/core/src/server/action-approval.ts b/packages/core/src/server/action-approval.ts
index 352652be0d6..1e081d2fcea 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,
@@ -52,7 +56,16 @@ export async function directHttpActionApprovalRefusal(
}
const prepared = await actionCallIntent(entry, params);
if (!prepared.valid) return null;
- return (await preparedCallNeedsApproval(entry, prepared, ctx))
- ? actionApprovalRequiredError(ctx.actionName, ctx.caller, false)
+ return (await preparedCallNeedsApproval(entry, prepared, {
+ ...ctx,
+ caller: ctx.caller === "frontend" ? "http" : ctx.caller,
+ }))
+ ? actionApprovalRequiredError(
+ ctx.actionName,
+ ctx.caller === "frontend" && ctx.requestHeaders?.has("authorization")
+ ? "http"
+ : ctx.caller,
+ false,
+ )
: null;
}
diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts
index 30f09ff24a1..0e5a37f1d26 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,27 @@ 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 mockHasEmbedSessionRequestMarker = vi.hoisted(() =>
+ vi.fn((event: any) =>
+ Boolean(
+ event._query?.__an_embed_token ||
+ event._query?.__an_embed_target ||
+ event._headers?.["x-agent-native-embed-target"],
+ ),
+ ),
+);
const mockIsExpiredMcpDirectoryWidgetSessionRequest = vi.hoisted(() =>
vi.fn(async (..._args: unknown[]) => false),
);
@@ -44,12 +70,19 @@ function fakeUnsignedJwt(payload: Record): string {
return `${encode({ alg: "none" })}.${encode(payload)}.not-a-real-signature`;
}
-vi.mock("h3", () => ({
+vi.mock("h3", async (importOriginal) => ({
+ ...(await importOriginal()),
createError: (opts: any) =>
Object.assign(new Error(opts?.statusMessage), opts),
defineEventHandler: (handler: any) => handler,
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 +136,19 @@ 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", () => ({
+ hasEmbedSessionRequestMarker: (event: any) =>
+ mockHasEmbedSessionRequestMarker(event),
+ hasLiveEmbedCapabilityCookie: (event: any) =>
+ event._hasLiveEmbedCapabilityCookie ?? false,
+ hasPresentedEmbedSessionCredential: (event: any) =>
+ mockHasPresentedEmbedSessionCredential(event),
isExpiredMcpDirectoryWidgetSessionRequest: (...args: unknown[]) =>
mockIsExpiredMcpDirectoryWidgetSessionRequest(...args),
hasExplicitEmbedSessionCredential: (event: any) =>
@@ -155,6 +196,8 @@ describe("mountActionRoutes", () => {
});
mockGetSession.mockReset();
mockGetSession.mockResolvedValue(null);
+ mockGetBrowserCookieSession.mockReset();
+ mockGetBrowserCookieSession.mockResolvedValue(null);
mockGetOrgContext.mockReset();
mockGetOrgContext.mockResolvedValue({ orgId: undefined });
mockVerifyA2ATokenWithClaims.mockReset();
@@ -162,6 +205,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();
@@ -438,6 +492,8 @@ describe("mountActionRoutes", () => {
type Caller =
| "proven UI"
| "frontend header without UI proof"
+ | "browser cookie without capability"
+ | "bearer with frontend header"
| "plain HTTP"
| "MCP OAuth bearer"
| "org service token"
@@ -446,6 +502,7 @@ describe("mountActionRoutes", () => {
| "needsApproval true"
| "predicate false"
| "predicate throws"
+ | "predicate outside UI"
| "no needsApproval";
type Outcome = "runs" | "ui-capability" | "approval-required";
@@ -457,12 +514,18 @@ describe("mountActionRoutes", () => {
throw new Error("cannot decide");
},
},
+ "predicate outside UI": {
+ needsApproval: (_args: unknown, ctx: { caller: string }) =>
+ ctx.caller !== "frontend",
+ },
"no needsApproval": {},
};
const refusedBy: Record = {
"proven UI": "runs",
"frontend header without UI proof": "ui-capability",
+ "browser cookie without capability": "ui-capability",
+ "bearer with frontend header": "approval-required",
"plain HTTP": "approval-required",
"MCP OAuth bearer": "approval-required",
"org service token": "approval-required",
@@ -472,6 +535,8 @@ describe("mountActionRoutes", () => {
const labelled: Record = {
"proven UI": "frontend",
"frontend header without UI proof": "frontend",
+ "browser cookie without capability": "frontend",
+ "bearer with frontend header": "frontend",
"plain HTTP": "http",
"MCP OAuth bearer": "mcp",
"org service token": "mcp",
@@ -508,8 +573,15 @@ describe("mountActionRoutes", () => {
),
};
const owner = "owner@example.com";
- if (caller === "proven UI")
+ if (caller === "proven UI") {
mockHasUiActionCapability.mockReturnValue(true);
+ }
+ const browser =
+ caller === "proven UI" ||
+ caller === "browser cookie without capability";
+ if (browser) {
+ mockGetBrowserCookieSession.mockResolvedValue({ email: owner } as any);
+ }
mountActionRoutes(
nitroApp,
@@ -543,6 +615,10 @@ describe("mountActionRoutes", () => {
_headers: {
host: "app.example.com",
"sec-fetch-site": "same-origin",
+ ...(browser ? { cookie: "an_session=test-browser-session" } : {}),
+ ...(caller === "bearer with frontend header"
+ ? { authorization: "Bearer test-token" }
+ : {}),
...(caller === "plain HTTP"
? {}
: { "x-agent-native-frontend": "1" }),
@@ -583,6 +659,274 @@ 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: "browser cookie with navigation fetch metadata",
+ browser: "owner@example.com",
+ headers: { "sec-fetch-site": "none" },
+ runs: false,
+ },
+ {
+ name: "browser cookie with desktop shell origin",
+ browser: "owner@example.com",
+ headers: { origin: "tauri://localhost" },
+ runs: false,
+ },
+ {
+ name: "general custom session and forged same-origin fetch",
+ headers: { "sec-fetch-site": "same-origin" },
+ 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: {
+ url: "https://app.example.com/_agent-native/actions/send-message",
+ 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 || testCase.headers.authorization
+ ? "approval_required"
+ : "ui_capability_required",
+ );
+ if (testCase.embedded) {
+ expect(event._status).toBe(409);
+ expect(result.error).toContain("Open the full app");
+ }
+ });
+
+ it.each(
+ (
+ [
+ "other-target",
+ "expired",
+ "invalid",
+ "marked",
+ "live-capability",
+ "live-capability-malformed",
+ "live-capability-empty",
+ ] as const
+ ).flatMap((credential) =>
+ (credential.startsWith("live-capability") ? [true] : [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";
+ mockResolveOrgScopeForEmail.mockResolvedValue({ status: "unknown" });
+ 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,
+ ...(credential.startsWith("live-capability")
+ ? { scope: "capability:visual-edit:design:doc-1" }
+ : {}),
+ });
+ 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,
+ capabilityScopes: ["visual-edit"],
+ } 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",
+ ...(credential === "live-capability-malformed"
+ ? { "x-forwarded-host": "app.example.com/path" }
+ : credential === "live-capability-empty"
+ ? { "x-forwarded-host": "" }
+ : {}),
+ ...(credential === "marked"
+ ? { "x-agent-native-embed-target": "/page/doc-1" }
+ : {}),
+ 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", designId: "doc-1" }),
+ },
+ };
+ event.req.headers = new Headers(event._headers);
+ event.url = new URL(event.req.url);
+ if (credential === "live-capability") {
+ await expect(mounted[0]!.handler(event)).resolves.toMatchObject({
+ errorCode: "approval_required",
+ });
+ expect(event._status).toBe(409);
+ expect(run).not.toHaveBeenCalled();
+ } else if (
+ !credential.startsWith("live-capability") &&
+ credential !== "marked"
+ ) {
+ 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,62 +1980,204 @@ 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,
+ mountActionRoutes(
+ nitroApp,
+ {
+ "get-design": {
+ http: { method: "GET" },
+ readOnly: true,
+ requiresAuth: false,
+ run,
+ } as any,
+ },
+ {
+ getOwnerFromEvent: async () => {
+ if (browserUser) return browserUser;
+ throw unauthenticated;
+ },
+ },
+ );
+
+ 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=${requestedId}&designId=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.each(
+ [
{
- "get-design": {
- http: { method: "GET" },
- readOnly: true,
- requiresAuth: false,
- run: vi.fn(async (_args, ctx) => ({
- ctxUserEmail: ctx?.userEmail,
- requestUserEmail: getRequestUserEmail(),
- authCapability: getRequestAuthCapability(),
- })),
- } as any,
+ credential: "capability-only",
+ browserUser: undefined,
+ scope: "visual-edit",
},
{
- getOwnerFromEvent: async () => {
- throw unauthenticated;
- },
+ credential: "signed-in plus capability",
+ browserUser: "browser@example.com",
+ scope: "visual-edit",
},
- );
-
- await expect(
- mounted[0]!.handler({
- _method: "GET",
+ {
+ credential: "MCP card",
+ browserUser: "browser@example.com",
+ scope: "card",
+ },
+ {
+ credential: "plain signed-in",
+ browserUser: "browser@example.com",
+ scope: undefined,
+ },
+ ].flatMap((credential) =>
+ [
+ { inside: true, requiresAuth: true, name: "update-screen-source" },
+ { inside: false, requiresAuth: true, name: "future-visual-action" },
+ { inside: false, requiresAuth: false, name: "future-visual-action" },
+ {
+ inside: false,
+ requiresAuth: true,
+ name: "grant-localhost-write-consent",
+ },
+ ].map((action) => ({ ...credential, ...action })),
+ ),
+ )(
+ "keeps $credential authority independent for $name (Visual Edit: $inside, account required: $requiresAuth)",
+ async ({ browserUser, scope, inside, requiresAuth, name }) => {
+ const { createMcpDirectoryWidgetReadCapability } =
+ await import("../shared/embed-auth.js");
+ const { mountActionRoutes } = await import("./action-routes.js");
+ const { getRequestAuthCapability } = await import("./request-context.js");
+ const mounted: Array<{ path: string; handler: any }> = [];
+ const visualScope = "capability:visual-edit:design:design_1";
+ const resolvedScope =
+ scope === "card"
+ ? createMcpDirectoryWidgetReadCapability({
+ appId: "design",
+ resourceUri: "ui://design/card",
+ resourceIds: { designId: "design_1" },
+ actionArguments: { "get-design": { id: "design_1" } },
+ })
+ : scope
+ ? visualScope
+ : undefined;
+ mockResolveEmbedSessionFromRequest.mockResolvedValue(
+ resolvedScope
+ ? {
+ email: "link-owner@example.com",
+ token: "signed-capability",
+ targetPath: "/visual-edit/design_1",
+ scope: resolvedScope,
+ }
+ : null,
+ );
+ const unauthenticated = Object.assign(new Error("Unauthenticated"), {
+ statusCode: 401,
+ });
+ const run = vi.fn(async (_args, ctx) => ({
+ userEmail: ctx?.userEmail,
+ authCapability: getRequestAuthCapability(),
+ }));
+ mountActionRoutes(
+ {
+ use: vi.fn((path: string, handler: any) =>
+ mounted.push({ path, handler }),
+ ),
+ },
+ {
+ [name]: {
+ http: { method: "POST" },
+ requiresAuth,
+ capabilityScopes: ["visual-edit"],
+ run,
+ } as any,
+ },
+ {
+ getOwnerFromEvent: async () => {
+ if (browserUser) return browserUser;
+ throw unauthenticated;
+ },
+ },
+ );
+ const event = {
+ _method: "POST",
+ _headers: {
+ "x-agent-native-frontend": "1",
+ ...(browserUser
+ ? { cookie: "better-auth.session_token=browser-session" }
+ : {}),
+ },
req: {
- url: "http://app.test/_agent-native/actions/get-design?id=design_1",
+ url: `http://app.test/_agent-native/actions/${name}`,
+ json: async () =>
+ inside || name === "grant-localhost-write-consent"
+ ? { designId: "design_1", connectionId: "conn_1" }
+ : {},
},
- }),
- ).resolves.toEqual({
- ctxUserEmail: undefined,
- requestUserEmail: undefined,
- authCapability: "capability:visual-edit:design:design_1",
- });
- });
+ };
+ if (scope === "card") {
+ expect(await mounted[0].handler(event)).toEqual({
+ error: "This widget capability only permits its scoped data routes.",
+ });
+ expect(event).toMatchObject({ _status: 403 });
+ } else if (!inside && !browserUser && requiresAuth) {
+ await expect(mounted[0].handler(event)).rejects.toBe(unauthenticated);
+ } else {
+ expect(await mounted[0].handler(event)).toEqual({
+ userEmail: browserUser,
+ authCapability: inside && scope ? visualScope : undefined,
+ });
+ expect(run).toHaveBeenCalledOnce();
+ return;
+ }
+ expect(run).not.toHaveBeenCalled();
+ },
+ );
- it("does not let a capability satisfy account-backed write action auth", async () => {
+ it("refuses update-file from a capability-only client that ignores its persistence hint", async () => {
const { mountActionRoutes } = await import("./action-routes.js");
const mounted: Array<{ path: string; handler: any }> = [];
const run = vi.fn(async () => ({ ok: true }));
@@ -1712,7 +2198,7 @@ describe("mountActionRoutes", () => {
),
},
{
- "update-design": {
+ "update-file": {
http: { method: "POST" },
requiresAuth: true,
run,
@@ -1728,9 +2214,14 @@ describe("mountActionRoutes", () => {
await expect(
mounted[0]!.handler({
_method: "POST",
+ _headers: { "x-agent-native-frontend": "1" },
req: {
- url: "http://app.test/_agent-native/actions/update-design",
- json: async () => ({ id: "design_1" }),
+ url: "http://app.test/_agent-native/actions/update-file",
+ json: async () => ({
+ id: "file_1",
+ filename: "renamed.html",
+ canPersistSource: true,
+ }),
},
}),
).rejects.toBe(unauthenticated);
@@ -2729,7 +3220,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 +3266,8 @@ describe("mountActionRoutes", () => {
http: { method: "POST" },
readOnly: false,
requiresAuth: true,
+ needsApproval,
+ access,
schema: schemas[name],
tool: { parameters: { type: "object", properties: {} } },
run,
@@ -2781,6 +3277,8 @@ describe("mountActionRoutes", () => {
http: { method: "GET" },
readOnly: true,
requiresAuth: true,
+ needsApproval,
+ access,
run,
}) as any;
mountActionRoutes(
@@ -2831,13 +3329,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 +3393,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 +4137,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 +4145,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 +4157,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
@@ -3570,7 +4179,9 @@ describe("mountActionRoutes", () => {
"unshare-resource",
"set-resource-visibility",
];
- const target = profile.widgetTargets["create-deck"]({}, { id: "deck-a" })!;
+ const target = {
+ resourceIds: { deckId: "deck-a", resourceType: "deck" },
+ };
const materialize = (rules: Record) =>
Object.fromEntries(
Object.entries(rules).map(([argumentName, rule]) => [
@@ -3692,7 +4303,6 @@ describe("mountActionRoutes", () => {
},
}),
);
- expect(needsApproval).not.toHaveBeenCalled();
run.mockClear();
const unshareBody = {
@@ -3713,7 +4323,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 +4345,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 +4380,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 +6256,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 +6288,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",
@@ -7225,206 +7856,361 @@ describe("mountWebMcpActionRoutes", () => {
expect(privateRun).not.toHaveBeenCalled();
});
- it("filters signed-out WebMCP actions to the matching capability scope", async () => {
- const { mountWebMcpActionRoutes } = await import("./action-routes.js");
- const mounted: Array<{ path: string; handler: any }> = [];
- const nitroApp = {
- use: vi.fn((path: string, handler: any) =>
- mounted.push({ path, handler }),
- ),
- };
- mockResolveEmbedSessionFromRequest.mockResolvedValue({
- email: "ticket-owner@example.com",
- token: "signed-capability",
- targetPath: "/visual-edit/design_1",
- scope: "capability:visual-edit:design:design_1",
- });
- const unauthenticated = Object.assign(new Error("Unauthorized"), {
- statusCode: 401,
- });
+ it.each([false, true])(
+ "filters Visual Edit metadata with browser identity: %s",
+ async (signedIn) => {
+ const { mountWebMcpActionRoutes } = await import("./action-routes.js");
+ const mounted: Array<{ path: string; handler: any }> = [];
+ const nitroApp = {
+ use: vi.fn((path: string, handler: any) =>
+ mounted.push({ path, handler }),
+ ),
+ };
+ mockResolveEmbedSessionFromRequest.mockResolvedValue({
+ email: "ticket-owner@example.com",
+ token: "signed-capability",
+ targetPath: "/visual-edit/design_1",
+ scope: "capability:visual-edit:design:design_1",
+ });
+ const unauthenticated = Object.assign(new Error("Unauthorized"), {
+ statusCode: 401,
+ });
- mountWebMcpActionRoutes(
- nitroApp,
- {
- "visual-edit": {
- tool: { description: "Visual edit", parameters: {} },
- run: vi.fn(),
- capabilityScopes: ["visual-edit"],
- } as any,
- "other-capability": {
- tool: { description: "Other capability", parameters: {} },
- run: vi.fn(),
- capabilityScopes: ["other"],
- } as any,
- },
- {
- getOwnerFromEvent: async () => {
- throw unauthenticated;
+ mountWebMcpActionRoutes(
+ nitroApp,
+ {
+ "update-screen-source": {
+ tool: { description: "Visual edit", parameters: {} },
+ run: vi.fn(),
+ capabilityScopes: ["visual-edit"],
+ } as any,
+ "other-capability": {
+ tool: { description: "Other capability", parameters: {} },
+ run: vi.fn(),
+ capabilityScopes: ["other"],
+ } as any,
+ "future-visual-action": {
+ tool: { description: "Unlisted action", parameters: {} },
+ run: vi.fn(),
+ capabilityScopes: ["visual-edit"],
+ } as any,
},
- },
- );
+ {
+ getOwnerFromEvent: async () => {
+ if (signedIn) return "browser@example.com";
+ throw unauthenticated;
+ },
+ },
+ );
- const manifestRoute = mounted.find(
- ({ path }) => path === "/_agent-native/webmcp/manifest",
- );
- await expect(
- manifestRoute?.handler({ _method: "GET", _headers: {} }),
- ).resolves.toEqual([
- {
- name: "visual-edit",
- title: "Visual edit",
- description: "Visual edit",
- inputSchema: {},
- readOnly: false,
- },
- ]);
+ const manifestRoute = mounted.find(
+ ({ path }) => path === "/_agent-native/webmcp/manifest",
+ );
+ await expect(
+ manifestRoute?.handler({ _method: "GET", _headers: {} }),
+ ).resolves.toEqual([
+ {
+ name: "update-screen-source",
+ title: "Update screen source",
+ description: "Visual edit",
+ inputSchema: {},
+ readOnly: false,
+ },
+ ]);
- const otherRoute = mounted.find(
- ({ path }) => path === "/_agent-native/webmcp/actions/other-capability",
- );
- await expect(
- otherRoute?.handler({
- _method: "POST",
- _headers: {},
- req: { json: async () => ({}) },
- }),
- ).rejects.toMatchObject({ statusCode: 401 });
- });
+ const otherRoute = mounted.find(
+ ({ path }) => path === "/_agent-native/webmcp/actions/other-capability",
+ );
+ if (!signedIn)
+ await expect(
+ otherRoute?.handler({
+ _method: "POST",
+ _headers: {},
+ req: { json: async () => ({}) },
+ }),
+ ).rejects.toMatchObject({ statusCode: 401 });
+ },
+ );
- it("keeps directory-widget read manifests and WebMCP calls scoped when a session cookie is present", async () => {
- const { createMcpDirectoryWidgetReadCapability } =
- await import("../shared/embed-auth.js");
- const { mountWebMcpActionRoutes } = await import("./action-routes.js");
- const mounted: Array<{ path: string; handler: any }> = [];
- const run = vi.fn(async (_args, context) => ({
- caller: context.caller,
- userEmail: context.userEmail,
- mcpDirectoryWidgetReadOnly: context.mcpDirectoryWidgetReadOnly,
- }));
- const runUnscopedPublicRead = vi.fn();
- const capability = createMcpDirectoryWidgetReadCapability({
- appId: "design",
- resourceUri: "ui://design/shell-v69",
- resourceIds: { designId: "d1" },
- actionArguments: { "get-design": { id: "d1" } },
- })!;
- mockResolveEmbedSessionFromRequest.mockResolvedValue({
- email: "ticket-owner@example.com",
- token: "signed-directory-capability",
- targetPath: "/design/d1",
- scope: capability,
- });
- const nitroApp = {
- use: vi.fn((path: string, handler: any) =>
- mounted.push({ path, handler }),
- ),
- };
+ it.each(["read", "write"] as const)(
+ "keeps a %s card manifest limited to its explicit read grants",
+ async (kind) => {
+ const {
+ createMcpDirectoryWidgetReadCapability,
+ createMcpDirectoryWidgetWriteCapability,
+ } = await import("../shared/embed-auth.js");
+ const { mountWebMcpActionRoutes } = await import("./action-routes.js");
+ const mounted: Array<{ path: string; handler: any }> = [];
+ const run = vi.fn(async (_args, context) => ({
+ caller: context.caller,
+ userEmail: context.userEmail,
+ mcpDirectoryWidgetReadOnly: context.mcpDirectoryWidgetReadOnly,
+ }));
+ const runUnscopedPublicRead = vi.fn();
+ const resource = {
+ appId: "design",
+ resourceUri: "ui://design/shell-v69",
+ resourceIds: { designId: "d1" },
+ };
+ const readArguments = { "get-design": { id: "d1" } };
+ const capability = (
+ kind === "read"
+ ? createMcpDirectoryWidgetReadCapability({
+ ...resource,
+ actionArguments: readArguments,
+ })
+ : createMcpDirectoryWidgetWriteCapability({
+ ...resource,
+ userEmail: "ticket-owner@example.com",
+ expiresAtMs: Date.now() + 60_000,
+ readActionArguments: readArguments,
+ writeActionArguments: { "update-design": { id: "d1" } },
+ })
+ )!;
+ mockResolveEmbedSessionFromRequest.mockResolvedValue({
+ email: "ticket-owner@example.com",
+ token: "signed-directory-capability",
+ targetPath: "/design/d1",
+ scope: capability,
+ });
+ const nitroApp = {
+ use: vi.fn((path: string, handler: any) =>
+ mounted.push({ path, handler }),
+ ),
+ };
- mountWebMcpActionRoutes(
- nitroApp,
- {
- "get-design": {
- tool: { description: "Read this design", parameters: {} },
- run,
- http: { method: "GET" },
- readOnly: true,
- } as any,
- "update-design": {
- tool: { description: "Update a design", parameters: {} },
- run: vi.fn(),
- http: { method: "POST" },
- readOnly: false,
- } as any,
- "get-other-design": {
- tool: { description: "Read another design", parameters: {} },
- run: vi.fn(),
- http: { method: "GET" },
- readOnly: true,
- } as any,
- "search-public-docs": {
- tool: { description: "Search public docs", parameters: {} },
- run: runUnscopedPublicRead,
- http: { method: "GET" },
- requiresAuth: false,
- readOnly: true,
- publicAgent: {
- expose: true,
+ mountWebMcpActionRoutes(
+ nitroApp,
+ {
+ "get-design": {
+ tool: { description: "Read this design", parameters: {} },
+ run,
+ http: { method: "GET" },
+ readOnly: true,
+ } as any,
+ "update-design": {
+ tool: { description: "Update a design", parameters: {} },
+ run: vi.fn(),
+ http: { method: "POST" },
+ readOnly: false,
+ } as any,
+ "get-other-design": {
+ tool: { description: "Read another design", parameters: {} },
+ run: vi.fn(),
+ http: { method: "GET" },
+ readOnly: true,
+ } as any,
+ "get-visual-edit-snapshot": {
+ tool: { description: "Visual Edit read", parameters: {} },
+ run: vi.fn(),
+ http: { method: "GET" },
readOnly: true,
+ capabilityScopes: ["visual-edit"],
+ } as any,
+ "search-public-docs": {
+ tool: { description: "Search public docs", parameters: {} },
+ run: runUnscopedPublicRead,
+ http: { method: "GET" },
requiresAuth: false,
+ readOnly: true,
+ publicAgent: {
+ expose: true,
+ readOnly: true,
+ requiresAuth: false,
+ },
+ } as any,
+ },
+ {
+ getOwnerContextFromEvent: async () => ({
+ owner: "cookie-owner@example.com",
+ anonymous: false,
+ }),
+ mcpDirectoryWidgetReadActionArguments: {
+ "get-design": ["id"],
+ "get-visual-edit-snapshot": ["designId"],
},
- } as any,
- },
- {
- getOwnerContextFromEvent: async () => ({
- owner: "cookie-owner@example.com",
- anonymous: false,
+ mcpDirectoryWidgetReadOnlyActions: ["get-design"],
+ mcpDirectoryWidgetAppId: "design",
+ mcpDirectoryWidgetResourceUri: "ui://design/shell-v69",
+ },
+ );
+
+ const manifestRoute = mounted.find(
+ ({ path }) => path === "/_agent-native/webmcp/manifest",
+ );
+ const webMcpRoute = mounted.find(
+ ({ path }) => path === "/_agent-native/webmcp/actions/get-design",
+ );
+ const unscopedPublicRoute = mounted.find(
+ ({ path }) =>
+ path === "/_agent-native/webmcp/actions/search-public-docs",
+ );
+ const mcpToolRoute = mounted.find(
+ ({ path }) => path === "/mcp/tool/get-design",
+ );
+ await expect(
+ manifestRoute?.handler({
+ _method: "GET",
+ _headers: {},
+ _hasLiveEmbedCapabilityCookie: true,
}),
- mcpDirectoryWidgetReadActionArguments: { "get-design": ["id"] },
- mcpDirectoryWidgetReadOnlyActions: ["get-design"],
- mcpDirectoryWidgetAppId: "design",
- mcpDirectoryWidgetResourceUri: "ui://design/shell-v69",
- },
- );
+ ).resolves.toEqual([
+ {
+ name: "get-design",
+ title: "Get design",
+ description: "Read this design",
+ inputSchema: {},
+ readOnly: true,
+ },
+ ]);
- const manifestRoute = mounted.find(
- ({ path }) => path === "/_agent-native/webmcp/manifest",
- );
- const webMcpRoute = mounted.find(
- ({ path }) => path === "/_agent-native/webmcp/actions/get-design",
- );
- const unscopedPublicRoute = mounted.find(
- ({ path }) => path === "/_agent-native/webmcp/actions/search-public-docs",
- );
- const mcpToolRoute = mounted.find(
- ({ path }) => path === "/mcp/tool/get-design",
- );
- await expect(
- manifestRoute?.handler({ _method: "GET", _headers: {} }),
- ).resolves.toEqual([
- {
- name: "get-design",
- title: "Get design",
- description: "Read this design",
- inputSchema: {},
- readOnly: true,
- },
- ]);
+ await expect(
+ webMcpRoute?.handler({
+ _method: "POST",
+ _headers: {},
+ req: { json: async () => ({ id: "d1" }) },
+ }),
+ ).resolves.toEqual({
+ caller: "mcp-widget",
+ userEmail: "ticket-owner@example.com",
+ mcpDirectoryWidgetReadOnly: true,
+ });
+ expect(run).toHaveBeenCalledOnce();
- await expect(
- webMcpRoute?.handler({
- _method: "POST",
- _headers: {},
- req: { json: async () => ({ id: "d1" }) },
- }),
- ).resolves.toEqual({
- caller: "mcp-widget",
- userEmail: "ticket-owner@example.com",
- mcpDirectoryWidgetReadOnly: true,
- });
- expect(run).toHaveBeenCalledOnce();
+ await expect(
+ mcpToolRoute?.handler({
+ _method: "POST",
+ _headers: {},
+ req: { json: async () => ({ id: "d1" }) },
+ }),
+ ).resolves.toEqual({
+ error: "This widget capability only permits its scoped data routes.",
+ });
+ expect(run).toHaveBeenCalledOnce();
- await expect(
- mcpToolRoute?.handler({
- _method: "POST",
- _headers: {},
- req: { json: async () => ({ id: "d1" }) },
- }),
- ).resolves.toEqual({
- error: "This widget capability only permits its scoped data routes.",
- });
- expect(run).toHaveBeenCalledOnce();
+ await expect(
+ unscopedPublicRoute?.handler({
+ _method: "POST",
+ _headers: {},
+ req: { json: async () => ({}) },
+ }),
+ ).resolves.toEqual({
+ error: "This widget capability only permits its scoped data routes.",
+ });
+ expect(runUnscopedPublicRead).not.toHaveBeenCalled();
+ },
+ );
- await expect(
- unscopedPublicRoute?.handler({
- _method: "POST",
- _headers: {},
- req: { json: async () => ({}) },
- }),
- ).resolves.toEqual({
- error: "This widget capability only permits its scoped data routes.",
- });
- expect(runUnscopedPublicRead).not.toHaveBeenCalled();
- });
+ it.each(
+ (["card", "visual-edit"] as const).flatMap((scope) =>
+ (["unmarked", "query", "bearer", "signed-out"] as const).map(
+ (context) => ({ scope, context }),
+ ),
+ ),
+ )(
+ "separates an unresolved $scope cookie from $context manifest authority",
+ async ({ scope, context }) => {
+ const embedSessions =
+ await vi.importActual(
+ "./embed-session.js",
+ );
+ const { createMcpDirectoryWidgetReadCapability, EMBED_SESSION_COOKIE } =
+ await import("../shared/embed-auth.js");
+ const { mountWebMcpActionRoutes } = await import("./action-routes.js");
+ const capability =
+ scope === "visual-edit"
+ ? "capability:visual-edit:design:d1"
+ : createMcpDirectoryWidgetReadCapability({
+ appId: "design",
+ resourceUri: "ui://design/shell-v69",
+ resourceIds: { designId: "d1" },
+ actionArguments: { "get-design": { id: "d1" } },
+ })!;
+ const token = embedSessions.signEmbedSessionToken({
+ ownerEmail: "ticket-owner@example.com",
+ audienceHost: "design.example.com",
+ targetPath: "/design/d1",
+ scope: capability,
+ ...(scope === "card" ? { ticketCreatedAtMs: Date.now() } : {}),
+ });
+ expect(embedSessions.verifyEmbedSessionToken(token).ok).toBe(true);
+ mockResolveEmbedSessionFromRequest.mockResolvedValue(null);
+ mockHasPresentedEmbedSessionCredential.mockImplementation(
+ embedSessions.hasPresentedEmbedSessionCredential,
+ );
+ const mounted: Array<{ path: string; handler: any }> = [];
+ const run = vi.fn();
+ mountWebMcpActionRoutes(
+ {
+ use: (path: string, handler: any) => mounted.push({ path, handler }),
+ },
+ {
+ "get-design": {
+ tool: { description: "Read design", parameters: {} },
+ run,
+ readOnly: true,
+ } as any,
+ "update-screen-source": {
+ tool: { description: "Edit screen", parameters: {} },
+ capabilityScopes: ["visual-edit"],
+ run,
+ } as any,
+ "account-action": {
+ tool: { description: "Account action", parameters: {} },
+ run,
+ } as any,
+ },
+ {
+ getOwnerFromEvent: async () => {
+ if (context !== "signed-out") return "browser@example.com";
+ throw Object.assign(new Error("Unauthorized"), {
+ statusCode: 401,
+ });
+ },
+ },
+ );
+ const event = {
+ _method: "GET",
+ url: new URL(
+ "https://design.example.com/_agent-native/webmcp/manifest",
+ ),
+ _headers: {
+ host: "design.example.com",
+ cookie: `${context === "signed-out" ? "" : "browser-session=signed-in; "}${EMBED_SESSION_COOKIE}=${token}`,
+ origin: "https://design.example.com",
+ referer: "https://design.example.com/design/d1",
+ "sec-fetch-site": "same-origin",
+ ...(context === "bearer"
+ ? {
+ authorization: `Bearer ${token}`,
+ "x-agent-native-embed-target": "/design/d1",
+ }
+ : {}),
+ },
+ _query:
+ context === "query"
+ ? { __an_embed_token: token, __an_embed_target: "/design/d1" }
+ : {},
+ _hasLiveEmbedCapabilityCookie: true,
+ };
+ const manifest = mounted.find(
+ ({ path }) => path === "/_agent-native/webmcp/manifest",
+ )!;
+ if (context === "unmarked") {
+ const result = await manifest.handler(event);
+ expect(result.map((entry: { name: string }) => entry.name)).toEqual([
+ "get-design",
+ "update-screen-source",
+ "account-action",
+ ]);
+ } else {
+ await expect(manifest.handler(event)).rejects.toMatchObject({
+ statusCode: 401,
+ });
+ }
+ expect(run).not.toHaveBeenCalled();
+ },
+ );
it("does not let a bootstrap capability match ordinary visual-edit actions", async () => {
const { mountWebMcpActionRoutes } = await import("./action-routes.js");
diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts
index 62f4de27896..aa6e5a30876 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";
@@ -78,7 +78,6 @@ import {
getMcpDirectoryWidgetWriteCapabilityGrant,
isExpiredMcpDirectoryWidgetWriteCapability,
isMcpDirectoryWidgetCapabilityScope,
- isMcpDirectoryWidgetReadCapabilityScope,
isMcpDirectoryWidgetWriteCapabilityScope,
normalizeMcpDirectoryWidgetReadActionArguments,
normalizeMcpDirectoryWidgetWriteActionArguments,
@@ -112,7 +111,8 @@ import {
readCorsAllowedOrigins,
} from "./cors-origins.js";
import {
- hasExplicitEmbedSessionCredential,
+ hasEmbedSessionRequestMarker,
+ hasPresentedEmbedSessionCredential,
isExpiredMcpDirectoryWidgetSessionRequest,
resolveEmbedSessionFromRequest,
resolvedEmbedCapabilityScope,
@@ -127,7 +127,7 @@ import {
import { consumeOneTimeJti } from "./identity-sso-store.js";
import {
getForwardedRequestOrigin,
- isSameOriginRequest,
+ hasSameOriginBrowserEvidence,
} from "./request-origin.js";
import { hasUiActionCapability } from "./ui-action-capability.js";
@@ -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") ||
+ !hasSameOriginBrowserEvidence(event) ||
+ !hasUiActionCapability(event, userEmail)
+ ) {
+ return false;
+ }
+ const session = await getBrowserCookieSession(event);
+ return session?.email.trim().toLowerCase() === userEmail.trim().toLowerCase();
}
type CorsOrigin = {
@@ -566,10 +577,40 @@ function isPublicWebMcpAction(entry: ActionEntry): boolean {
);
}
+const VISUAL_EDIT_CAPABILITY_ACTIONS = new Set([
+ "acknowledge-visual-edit-pending",
+ "add-breakpoint",
+ "add-localhost-screens",
+ "clear-localhost-write-consent-request",
+ "get-design",
+ "get-design-snapshot",
+ "get-localhost-write-consent-request",
+ "get-visual-edit-pending",
+ "get-visual-edit-snapshot",
+ "list-design-source-capabilities",
+ "list-design-states",
+ "list-files",
+ "list-local-files",
+ "list-localhost-connections",
+ "migrate-board-objects-to-file",
+ "publish-visual-edit-pending",
+ "read-local-file",
+ "refresh-localhost-preview-token",
+ "remove-breakpoint",
+ "request-localhost-write-consent",
+ "update-breakpoint",
+ "update-screen-source",
+]);
+
function allowsWebMcpCapability(
+ name: string,
entry: ActionEntry,
authCapability: string | undefined,
): boolean {
+ if (authCapability?.startsWith("capability:visual-edit:")) {
+ if (!VISUAL_EDIT_CAPABILITY_ACTIONS.has(name)) return false;
+ if (name === "get-design") return true;
+ }
if (!authCapability || !Array.isArray(entry.capabilityScopes)) return false;
return entry.capabilityScopes.some(
(scope) =>
@@ -582,7 +623,7 @@ function allowsWebMcpCapability(
function allowsMcpDirectoryWidgetReadManifestAction(
name: string,
entry: ActionEntry,
- authCapability: string | undefined,
+ session: ResolvedEmbedSession,
options: MountWebMcpActionRoutesOptions | undefined,
): boolean {
const allowedArgumentNames =
@@ -595,10 +636,12 @@ function allowsMcpDirectoryWidgetReadManifestAction(
(entry.requiresAuth !== false ||
options?.mcpDirectoryWidgetReadPublicActions?.includes(name) === true) &&
allowedArgumentNames !== undefined &&
- allowsMcpDirectoryWidgetReadAction(authCapability, {
+ allowsMcpDirectoryWidgetReadAction(resolvedEmbedCapabilityScope(session), {
actionName: name,
appId: options?.mcpDirectoryWidgetAppId ?? options?.appId,
resourceUri: options?.mcpDirectoryWidgetResourceUri,
+ userEmail: session.email,
+ orgId: session.orgId,
allowedArgumentNames,
requireArgumentMatch: false,
})
@@ -606,26 +649,34 @@ 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;
}
}
-async function resolveRequestAuthCapability(
+async function resolveRequestEmbedSession(
event: any,
-): Promise {
+ options?: { ignoreUnresolvedCookie?: boolean },
+): Promise {
const session = await resolveEmbedSessionFromRequest(event);
- if (!session && hasExplicitEmbedSessionCredential(event)) {
+ if (
+ !session &&
+ (options?.ignoreUnresolvedCookie
+ ? hasEmbedSessionRequestMarker(event)
+ : hasPresentedEmbedSessionCredential(event))
+ ) {
if (await isExpiredMcpDirectoryWidgetSessionRequest(event)) {
setResponseHeader(
event,
@@ -635,13 +686,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 +877,19 @@ 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 presentedCapability = resolvedEmbedCapabilityScope(embedSession);
+ const authCapability =
+ presentedCapability?.startsWith("capability:visual-edit:") &&
+ !allowsWebMcpCapability(name, entry, presentedCapability)
+ ? undefined
+ : presentedCapability;
+ 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, {
@@ -937,7 +987,7 @@ function mountActionRoutesInternal(
let directoryWidgetReadAuthenticatedFallback = false;
const capabilityAllowed =
(options?.caller === "webmcp" || isFrontendActionRequest(event)) &&
- (allowsWebMcpCapability(entry, authCapability) ||
+ (allowsWebMcpCapability(name, entry, authCapability) ||
directoryWidgetReadAllowed ||
directoryWidgetWriteAllowed);
if (
@@ -1225,16 +1275,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 +1520,7 @@ function mountActionRoutesInternal(
params = normalizedArgs;
}
if (
- capabilityAllowed &&
- !userEmail &&
- !allowsWebMcpCapabilityResource(authCapability, params)
+ !allowsWebMcpCapabilityResource(name, authCapability, params)
) {
throw createError({
statusCode: 401,
@@ -1584,27 +1635,33 @@ 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 === "frontend"
+ ? "http"
+ : caller,
}))
) {
+ if (embedded && entry.access) {
+ await assertActionAccess(
+ entry.access,
+ preparedCallArguments(prepared),
+ runContext,
+ );
+ }
throw actionApprovalRequiredError(
name,
- caller,
+ caller === "frontend" && getHeader(event, "authorization")
+ ? "http"
+ : caller,
Boolean(verifiedServiceIdentity),
+ embedded,
);
}
const result = await runPreparedActionCall(
@@ -1879,12 +1936,6 @@ export function mountWebMcpActionRoutes(
const publicEligible = Object.fromEntries(
Object.entries(eligible).filter(([, entry]) => isPublicWebMcpAction(entry)),
);
- const capabilityEligible = Object.fromEntries(
- Object.entries(eligible).filter(([, entry]) =>
- Array.isArray(entry.capabilityScopes),
- ),
- );
-
const app = getH3App(nitroApp);
const actionRoutePrefixes = [WEBMCP_ACTION_ROUTE_PREFIX, "/mcp/tool"];
const actionRoutePaths = actionRoutePrefixes.flatMap((routePrefix) =>
@@ -1938,23 +1989,28 @@ export function mountWebMcpActionRoutes(
if (!isAuthResolutionFailure(error)) throw error;
}
}
- const authCapability = await resolveRequestAuthCapability(event);
- const directoryWidgetReadCapability =
- isMcpDirectoryWidgetReadCapabilityScope(authCapability);
+ const embedSession = await resolveRequestEmbedSession(event, {
+ ignoreUnresolvedCookie: true,
+ });
+ const authCapability = resolvedEmbedCapabilityScope(embedSession);
+ const directoryWidgetSession =
+ embedSession && isMcpDirectoryWidgetCapabilityScope(authCapability)
+ ? embedSession
+ : null;
const visibleCapabilityActions = authCapability
? Object.fromEntries(
- Object.entries(capabilityEligible).filter(([, entry]) =>
- allowsWebMcpCapability(entry, authCapability),
+ Object.entries(eligible).filter(([name, entry]) =>
+ allowsWebMcpCapability(name, entry, authCapability),
),
)
: {};
- const visibleDirectoryWidgetReadActions = directoryWidgetReadCapability
+ const visibleDirectoryWidgetReadActions = directoryWidgetSession
? Object.fromEntries(
Object.entries(eligible).filter(([name, entry]) =>
allowsMcpDirectoryWidgetReadManifestAction(
name,
entry,
- authCapability,
+ directoryWidgetSession,
options,
),
),
@@ -1969,11 +2025,13 @@ export function mountWebMcpActionRoutes(
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
setResponseHeader(event, "Cache-Control", "no-store");
- const visible = directoryWidgetReadCapability
+ const visible = directoryWidgetSession
? visibleDirectoryWidgetReadActions
- : authenticated
- ? eligible
- : { ...publicEligible, ...visibleCapabilityActions };
+ : authCapability?.startsWith("capability:visual-edit:")
+ ? visibleCapabilityActions
+ : authenticated
+ ? eligible
+ : { ...publicEligible, ...visibleCapabilityActions };
return Object.entries(visible).map(([name, entry]) => ({
name,
title: agentNativeToolTitle(name, entry.tool.title),
diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts
index 3ea19bf9e5f..ec9bc5633eb 100644
--- a/packages/core/src/server/auth.spec.ts
+++ b/packages/core/src/server/auth.spec.ts
@@ -113,6 +113,11 @@ describe("server/auth", () => {
vi.doUnmock("../org/auth-policy.js");
vi.doUnmock("../org/workspace-app-access.js");
vi.doUnmock("./embed-session.js");
+ vi.doUnmock("./framework-request-handler.js");
+ vi.doUnmock("@agent-native/core/action");
+ vi.doUnmock("@agent-native/core/server");
+ vi.doUnmock("@agent-native/core/labs/server");
+ vi.doUnmock("@agent-native/core/sharing");
vi.doUnmock("./email.js");
vi.doUnmock("./request-origin.js");
vi.doUnmock("./sentry.js");
@@ -8940,7 +8945,475 @@ 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}
-
+
+
+ );
+}
+
+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 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);
+ },
+ [],
+ );
+
+ // Closing is not cancelling: a granted request closes through here too, so
+ // only the dialog's own Cancel calls onCancel.
+ const onOpenChange = useCallback((next: boolean) => {
+ if (!next) 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/commands/save-file-content.ts b/templates/design/app/pages/design-editor/commands/save-file-content.ts
index 94e776f9528..9ac22b099b3 100644
--- a/templates/design/app/pages/design-editor/commands/save-file-content.ts
+++ b/templates/design/app/pages/design-editor/commands/save-file-content.ts
@@ -86,6 +86,7 @@ export interface SaveFileContentArgs {
baseUpdatedAt?: string | null,
identityMigrationSourceContent?: string,
deferCollabReconcile?: boolean,
+ identityMigrationRefused?: boolean,
) => void;
/** The server content the file's first unsaved local edit was made from. */
getPendingBaseContent?: (fileId: string) => string | undefined;
@@ -322,6 +323,14 @@ export function runFileContentSaveKeepalive(
.catch(() => {});
}
+function isAccessRefusal(error: unknown): boolean {
+ const status =
+ error && typeof error === "object"
+ ? (error as { status?: unknown }).status
+ : undefined;
+ return status === 401 || status === 403;
+}
+
const SAVE_FAILURE_REASONS: Record =
{
conflict: "conflict",
@@ -648,6 +657,31 @@ export function runSaveFileContent(
op.skip("superseded");
return "failed";
}
+ // An identity migration carries no user edit; a refused one is dropped
+ // quietly, and its marked pending entry stops the same source re-queuing.
+ if (
+ pending.identityMigrationSourceContent !== undefined &&
+ isAccessRefusal(error)
+ ) {
+ markPendingLocalFileContent(
+ pending.id,
+ pending.content,
+ undefined,
+ pending.identityMigrationSourceContent,
+ pending.deferCollabReconcile,
+ true,
+ );
+ if (queuedOutboxEntry)
+ void acknowledgeOutboxEntry(queuedOutboxEntry).catch(() => {});
+ if (latestFileSaveForUnloadRef.current[pending.id] === pending) {
+ delete latestFileSaveForUnloadRef.current[pending.id];
+ }
+ void queryClient.invalidateQueries({
+ queryKey: ["action", "get-design"],
+ });
+ op.skip("no_edit_rights");
+ return "failed";
+ }
const failureKind = classifyDesignSaveFailure(error, navigator.onLine);
if (
failureKind === "conflict" &&
diff --git a/templates/design/app/pages/design-editor/domains/use-editor-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-canvas-and-screens.ts b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts
index 2fd19f92d94..3d6c50b03d4 100644
--- a/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts
+++ b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts
@@ -178,6 +178,7 @@ export function useEditorCanvasAndScreens({
designEditorCommandVersion,
designAccessRole,
canEditDesign,
+ canPersistDesignSource,
rawServerFilesByIdRef,
historyFilesRef,
pendingLocalFileContentsRef,
@@ -702,6 +703,7 @@ export function useEditorCanvasAndScreens({
serverFiles,
publishCanonicalContent,
canEditDesign,
+ canPersistDesignSource,
isSignedIn,
activeFileId,
viewMode,
diff --git a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx
new file mode 100644
index 00000000000..8afeb5e23ba
--- /dev/null
+++ b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.source-persistence.test.tsx
@@ -0,0 +1,578 @@
+// @vitest-environment happy-dom
+
+import { sourceContentHash } from "@shared/source-workspace";
+import { QueryClient } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import * as Y from "yjs";
+
+import type { DesignSaveOutboxEntry } from "@/lib/design-save-outbox";
+import type { FileContentSaveRequest } from "@/pages/design-editor/editor-state";
+
+import { runPublishCanonicalContent } from "../commands/publish-canonical-content";
+import {
+ runQueueFileContentSave,
+ runSaveFileContent,
+ type QueueFileContentSaveArgs,
+ type SaveFileContentArgs,
+} from "../commands/save-file-content";
+import { usePublishActiveSourceContent } from "../effects/publish-active-source-content";
+import { prepareCanonicalSourceContent } from "../source-publication";
+import type { DesignData, DesignFile } from "../types";
+import type { EditorCore } from "./use-editor-core";
+import { useEditorGenerationAndAccess } from "./use-editor-generation-and-access";
+import type { EditorHistory } from "./use-editor-history";
+
+const mocks = vi.hoisted(() => ({
+ design: { current: undefined as unknown },
+ toast: {
+ error: vi.fn(),
+ warning: vi.fn(),
+ success: vi.fn(),
+ info: vi.fn(),
+ message: vi.fn(),
+ },
+}));
+
+vi.mock("sonner", () => ({ toast: mocks.toast }));
+
+vi.mock("@agent-native/core/client/hooks", () => {
+ const refetch = async () => ({ isSuccess: true });
+ const mutations = new Map();
+ return {
+ useActionQuery: (name: string) => ({
+ data: name === "get-design" ? mocks.design.current : undefined,
+ error: null,
+ isError: false,
+ isLoading: false,
+ isFetching: false,
+ dataUpdatedAt: 1,
+ status: "success",
+ refetch,
+ }),
+ useActionMutation: (name: string) => {
+ if (!mutations.has(name)) {
+ mutations.set(name, {
+ mutate: () => {},
+ mutateAsync: async () => ({}),
+ isPending: false,
+ });
+ }
+ return mutations.get(name);
+ },
+ actionErrorMessage: () => undefined,
+ callAction: async () => ({}),
+ getBrowserTabId: () => "tab-1",
+ readClientAppState: async () => null,
+ setClientAppState: async () => {},
+ useChangeVersion: () => 0,
+ useChangeVersions: () => 0,
+ useAvatarUrl: () => undefined,
+ };
+});
+
+vi.mock("@agent-native/core/client/mcp-app-host", () => ({
+ useIsMcpDirectoryWidgetWriteEmbed: () => false,
+}));
+
+vi.mock("@agent-native/core/client/review", () => {
+ const sendReviewThreadToAgent = () => {};
+ return {
+ useReviewComments: () => ({ data: undefined }),
+ useSendReviewThreadToAgent: () => sendReviewThreadToAgent,
+ };
+});
+
+vi.mock("@agent-native/creative-context/client", () => {
+ const lab = {};
+ return { useCreativeContextLabState: () => lab };
+});
+
+vi.mock("@/hooks/use-agent-generating", () => {
+ const generation = {
+ generating: false,
+ submit: () => {},
+ reset: () => {},
+ track: () => {},
+ };
+ return { useAgentGenerating: () => generation };
+});
+
+vi.mock("@/hooks/use-annotate-lab", () => {
+ const annotateLab = {};
+ return { useAnnotateLab: () => annotateLab };
+});
+
+vi.mock("@/hooks/use-question-flow", () => {
+ const questionFlow = {
+ questions: null,
+ handleSubmit: () => {},
+ handleSkip: () => {},
+ retryProviderStatus: () => {},
+ };
+ return { useQuestionFlow: () => questionFlow };
+});
+
+vi.mock("@/lib/design-funnel-tracking", () => ({
+ useDesignReadyViewTracking: () => {},
+}));
+
+type Access = ReturnType;
+
+const DESIGN_ID = "design-1";
+const FILE_ID = "screen-1";
+const RAW_CONTENT =
+ 'Initial';
+const FILES: DesignFile[] = [
+ {
+ id: FILE_ID,
+ filename: "index.html",
+ fileType: "html",
+ content: RAW_CONTENT,
+ createdAt: null,
+ updatedAt: "2026-10-24T00:00:00.000Z",
+ },
+];
+
+function designData(overrides: Partial): DesignData {
+ return {
+ id: DESIGN_ID,
+ title: "Design",
+ updatedAt: "2026-10-24T00:00:00.000Z",
+ projectType: "prototype",
+ accessRole: "editor",
+ files: FILES,
+ ...overrides,
+ };
+}
+
+type Surface = { isSignedIn: boolean; isVisualEditSurface: boolean };
+
+const unmounts: Array<() => Promise> = [];
+
+async function mountAccess(
+ surface: Surface,
+ design: DesignData,
+ useWithAccess?: (access: () => Access) => void,
+) {
+ const queryClient = new QueryClient();
+ const editorHistory = {
+ shellInput: null,
+ setSelectedLayerIdsState: () => {},
+ codeLayerOwnerByNodeIdRef: { current: new Map() },
+ overviewSelectedScreenIds: [],
+ setOverviewSelectedScreenIds: () => {},
+ pendingOverviewScreenSelectionRef: { current: null },
+ pendingOverviewLayerSelectionRef: { current: null },
+ clearPendingOverviewLayerSelectionTimer: () => {},
+ schedulePendingOverviewLayerSelectionClear: () => {},
+ latestClipboardMutationContentRef: { current: null },
+ explicitOverviewScreenSelectionRef: { current: [] },
+ captureCurrentSelection: () => null,
+ } as unknown as EditorHistory;
+ const viewModeRef = { current: "single" };
+ const selectedElementRef = { current: null };
+ const editorCore = (next: Surface) =>
+ ({
+ t: (key: string) => key,
+ id: DESIGN_ID,
+ session: next.isSignedIn ? { email: "person@example.com" } : null,
+ isSignedIn: next.isSignedIn,
+ sessionResolved: true,
+ reviewPreview: false,
+ queryClient,
+ shellMode: false,
+ isVisualEditSurface: next.isVisualEditSurface,
+ readOnlyWidget: false,
+ viewMode: "single",
+ viewModeRef,
+ setSelectedElement: () => {},
+ selectedElementRef,
+ setPendingVisualEditPublicationFailed: () => {},
+ activeFileId: FILE_ID,
+ setActiveFileId: () => {},
+ }) as unknown as EditorCore;
+ const latest: { current: Access | null } = { current: null };
+ function Harness({ surface }: { surface: Surface }) {
+ latest.current = useEditorGenerationAndAccess({
+ editorCore: editorCore(surface),
+ editorHistory,
+ });
+ useWithAccess?.(() => latest.current!);
+ return null;
+ }
+ mocks.design.current = design;
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ await act(async () => root.render());
+ unmounts.push(() =>
+ act(async () => root.unmount()).then(() => host.remove()),
+ );
+ return {
+ access: () => latest.current!,
+ queryClient,
+ rerender: (next: Surface, nextDesign: DesignData) => {
+ mocks.design.current = nextDesign;
+ return act(async () => root.render());
+ },
+ };
+}
+
+function accessRefusal() {
+ return Object.assign(new Error("Forbidden"), { status: 403 });
+}
+
+function stored(input: Record) {
+ return {
+ updated: true,
+ versionHash: sourceContentHash(String(input.content)),
+ content: input.content,
+ };
+}
+
+// Wires the hook's own refs and pending-content bookkeeping into the real
+// publish, queue and save commands, the way useEditorFilesAndSaving does.
+function sourceSync(
+ access: () => Access,
+ queryClient: QueryClient,
+ updateFile: (input: Record) => Promise,
+) {
+ const latestFileSaveForUnloadRef: SaveFileContentArgs["latestFileSaveForUnloadRef"] =
+ { current: {} };
+ const acknowledgeOutboxEntry = vi.fn(async () => {});
+ const journalOutboxEntry = vi.fn(async () => true);
+ const createFileSaveOutboxEntry = (
+ pending: FileContentSaveRequest,
+ ): DesignSaveOutboxEntry => ({
+ key: `${DESIGN_ID}:${pending.id}:update-file`,
+ designId: DESIGN_ID,
+ actorScope: "user-1",
+ actionName: "update-file",
+ resourceId: pending.id,
+ operationSource: pending.operationSource,
+ operationRevision: pending.operationRevision,
+ payload: { id: pending.id, content: pending.content },
+ updatedAt: 1,
+ });
+ const updateFileMutation = { mutateAsync: vi.fn(updateFile) };
+ const markPendingLocalFileContent: SaveFileContentArgs["markPendingLocalFileContent"] =
+ (...args) => access().markPendingLocalFileContent(...args);
+ const saveArgs: SaveFileContentArgs = {
+ acknowledgeOutboxEntry,
+ canEditDesignRef: access().canEditDesignRef,
+ createFileSaveOutboxEntry,
+ designId: DESIGN_ID,
+ fileSaveChainsRef: { current: {} },
+ journalOutboxEntry,
+ latestFileSaveForUnloadRef,
+ rollbackPendingLocalFileContent: (...args) =>
+ access().rollbackPendingLocalFileContent(...args),
+ markPendingLocalFileContent,
+ queryClient,
+ setPatchProof: () => {},
+ t: (key) => key,
+ updateFileMutation:
+ updateFileMutation as unknown as SaveFileContentArgs["updateFileMutation"],
+ warnChangesWillRetry: () => {},
+ };
+ const completions: Array | void> = [];
+ const queueArgs: QueueFileContentSaveArgs = {
+ canEditDesignRef: access().canEditDesignRef,
+ createFileSaveOutboxEntry,
+ fileSaveOperationRevisionRef: { current: {} },
+ fileSaveOutboxJournalPromisesRef: { current: new WeakMap() },
+ fileSaveTimersRef: { current: {} },
+ journalOutboxEntry,
+ latestFileSaveForUnloadRef,
+ markPendingLocalFileContent,
+ operationSource: "tab-1",
+ pendingFileSavesRef: { current: {} },
+ saveFileContent: (pending, journalPromise, onPersistedContent) =>
+ runSaveFileContent(saveArgs, pending, journalPromise, onPersistedContent),
+ setTimer: (callback, delayMs) => window.setTimeout(callback, delayMs),
+ clearTimer: (timerId) => window.clearTimeout(timerId),
+ };
+ const settle = () =>
+ act(async () => {
+ await Promise.all(completions);
+ });
+ const publishCanonicalContent = (
+ fileId: string,
+ sourceContent: string,
+ fileType?: string,
+ ) =>
+ runPublishCanonicalContent(
+ {
+ canEditDesignRef: access().canEditDesignRef,
+ canPersistDesignSourceRef: access().canPersistDesignSourceRef,
+ pendingLocalFileContentsRef: access().pendingLocalFileContentsRef,
+ cancelIdentityMigration: () => {},
+ queueFileContentSave: (fileId, content, options) => {
+ completions.push(
+ runQueueFileContentSave(queueArgs, fileId, content, options),
+ );
+ },
+ },
+ fileId,
+ sourceContent,
+ fileType,
+ );
+ return {
+ acknowledgeOutboxEntry,
+ journalOutboxEntry,
+ latestFileSaveForUnloadRef,
+ updateFileMutation,
+ settle,
+ publishCanonicalContent,
+ publish: async () => {
+ let canonical = "";
+ await act(async () => {
+ canonical = publishCanonicalContent(FILE_ID, RAW_CONTENT, "html");
+ });
+ await settle();
+ return canonical;
+ },
+ saveUserEdit: async (content: string) => {
+ await act(async () => {
+ completions.push(
+ runQueueFileContentSave(queueArgs, FILE_ID, content, {
+ expectedVersionHash: "user-edit-base",
+ immediate: true,
+ }),
+ );
+ });
+ await settle();
+ },
+ };
+}
+
+const VISUAL_EDIT_SIGNED_IN: Surface = {
+ isSignedIn: true,
+ isVisualEditSurface: true,
+};
+
+describe("automatic canonical source persistence", () => {
+ beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ for (const fn of Object.values(mocks.toast)) fn.mockClear();
+ });
+
+ afterEach(async () => {
+ while (unmounts.length) await unmounts.pop()!();
+ });
+
+ it("syncs on a Visual Edit link when the account itself can edit", async () => {
+ const { access, queryClient } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+ const sync = sourceSync(access, queryClient, async (input) => ({
+ updated: true,
+ versionHash: "persisted",
+ content: input.content,
+ }));
+
+ await sync.publish();
+
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: FILE_ID,
+ identityOnly: true,
+ content: expect.stringContaining("data-agent-native-node-id"),
+ }),
+ );
+ });
+
+ it("keeps canonical content in memory without syncing when only the link can edit", async () => {
+ const { access, queryClient } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: false }),
+ );
+ const sync = sourceSync(access, queryClient, async () => ({}));
+
+ const canonical = await sync.publish();
+
+ expect(access().canEditDesign).toBe(true);
+ expect(access().canPersistDesignSourceRef.current).toBe(false);
+ expect(canonical).toContain("data-agent-native-node-id");
+ expect(sync.updateFileMutation.mutateAsync).not.toHaveBeenCalled();
+ expect(sync.journalOutboxEntry).not.toHaveBeenCalled();
+ expect(access().pendingLocalFileContentsRef.current.size).toBe(0);
+ expect(mocks.toast.error).not.toHaveBeenCalled();
+ });
+
+ it("starts syncing when a refetch grants account access with the same files", async () => {
+ const { access, queryClient, rerender } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: false }),
+ );
+ expect(access().canPersistDesignSourceRef.current).toBe(false);
+
+ await rerender(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+
+ expect(access().design?.files).toBe(FILES);
+ expect(access().canPersistDesignSourceRef.current).toBe(true);
+ const sync = sourceSync(access, queryClient, async (input) => ({
+ updated: true,
+ versionHash: "persisted",
+ content: input.content,
+ }));
+ await sync.publish();
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ { surface: VISUAL_EDIT_SIGNED_IN, persists: false },
+ {
+ surface: { isSignedIn: true, isVisualEditSurface: false },
+ persists: true,
+ },
+ ])(
+ "treats a missing flag as $persists on visualEdit=$surface.isVisualEditSurface",
+ async ({ surface, persists }) => {
+ const { access } = await mountAccess(surface, designData({}));
+
+ expect(access().canEditDesign).toBe(true);
+ expect(access().canPersistDesignSourceRef.current).toBe(persists);
+ },
+ );
+
+ it("drops a refused automatic sync quietly and does not queue it again", async () => {
+ const { access, queryClient } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+ const sync = sourceSync(access, queryClient, async () => {
+ throw accessRefusal();
+ });
+
+ const canonical = await sync.publish();
+
+ expect(canonical).toContain("data-agent-native-node-id");
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ expect(sync.acknowledgeOutboxEntry).toHaveBeenCalledWith(
+ expect.objectContaining({ resourceId: FILE_ID }),
+ );
+ expect(sync.latestFileSaveForUnloadRef.current[FILE_ID]).toBeUndefined();
+ expect(mocks.toast.error).not.toHaveBeenCalled();
+
+ await sync.publish();
+
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ expect(mocks.toast.error).not.toHaveBeenCalled();
+ });
+
+ it("syncs a refused automatic migration once account access returns", async () => {
+ const { access, queryClient, rerender } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+ let refuse = true;
+ const sync = sourceSync(access, queryClient, async (input) => {
+ if (refuse) throw accessRefusal();
+ return stored(input);
+ });
+ const userEdit = "Other screen edit";
+ await act(async () => {
+ access().markPendingLocalFileContent("screen-2", userEdit);
+ });
+
+ await sync.publish();
+ await rerender(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: false }),
+ );
+ await sync.publish();
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+
+ refuse = false;
+ await rerender(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+ await sync.publish();
+ await sync.publish();
+
+ expect(access().design?.files).toBe(FILES);
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(2);
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenLastCalledWith(
+ expect.objectContaining({ id: FILE_ID, identityOnly: true }),
+ );
+ expect(
+ access().pendingLocalFileContentsRef.current.get("screen-2")?.content,
+ ).toBe(userEdit);
+ expect(mocks.toast.error).not.toHaveBeenCalled();
+ });
+
+ it("publishes the active synced screen's raw text when account access arrives", async () => {
+ const doc = new Y.Doc();
+ doc.getText("content").insert(0, RAW_CONTENT);
+ const canonical = prepareCanonicalSourceContent(RAW_CONTENT, {
+ fileId: FILE_ID,
+ fileType: "html",
+ }).content;
+ let sync: ReturnType | undefined;
+ function useActiveScreen(access: () => Access) {
+ sync ??= sourceSync(access, new QueryClient(), async (input) =>
+ stored(input),
+ );
+ usePublishActiveSourceContent({
+ activeFile: FILES[0],
+ activeContent: canonical,
+ rawActiveContent: canonical,
+ canEditDesign: access().canEditDesign,
+ canPersistDesignSource: access().canPersistDesignSource,
+ isSynced: true,
+ publishCanonicalContent: sync.publishCanonicalContent,
+ ydoc: doc,
+ });
+ }
+ const { rerender } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: false }),
+ useActiveScreen,
+ );
+ await sync!.settle();
+ expect(sync!.updateFileMutation.mutateAsync).not.toHaveBeenCalled();
+
+ await rerender(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: true }),
+ );
+ await sync!.settle();
+
+ expect(sync!.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ expect(sync!.updateFileMutation.mutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: FILE_ID,
+ identityOnly: true,
+ content: canonical,
+ expectedVersionHash: sourceContentHash(RAW_CONTENT),
+ }),
+ );
+ });
+
+ it("still reports a refused user edit", async () => {
+ const { access, queryClient } = await mountAccess(
+ VISUAL_EDIT_SIGNED_IN,
+ designData({ canPersistSource: false }),
+ );
+ const sync = sourceSync(access, queryClient, async () => {
+ throw accessRefusal();
+ });
+
+ await sync.saveUserEdit("Edited");
+
+ expect(sync.updateFileMutation.mutateAsync).toHaveBeenCalledTimes(1);
+ expect(sync.acknowledgeOutboxEntry).not.toHaveBeenCalled();
+ expect(mocks.toast.error).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts b/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts
index 8228216b3c4..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) {
@@ -1062,8 +1038,14 @@ export function useEditorGenerationAndAccess({
setReviewSendingThreadId(null);
}
}, [reviewAgentQueueThreadIds, reviewSendingThreadId]);
+ // accessRole includes a Visual Edit link's role, which update-file does not
+ // honor; automatic source syncs need the account's own canPersistSource.
+ const canPersistDesignSource =
+ canEditDesign &&
+ isSignedIn &&
+ (design?.canPersistSource ?? !isVisualEditSurface);
const canEditDesignRef = useRef(canEditDesign);
- const canPersistDesignSourceRef = useRef(canEditDesign && isSignedIn);
+ const canPersistDesignSourceRef = useRef(canPersistDesignSource);
const rawServerFilesByIdRef = useRef(new Map());
const historyFilesRef = useRef([]);
const restoreSelectionSnapshot = useCallback(
@@ -1191,13 +1173,22 @@ export function useEditorGenerationAndAccess({
baseUpdatedAt?: string | null,
identityMigrationSourceContent?: string,
deferCollabReconcile?: boolean,
+ identityMigrationRefused?: boolean,
) => {
const current = pendingLocalFileContentsRef.current.get(fileId);
+ const sameMigration =
+ current?.content === content &&
+ current.identityMigrationSourceContent ===
+ identityMigrationSourceContent;
+ // A refusal annotates only the migration it answered, never a newer edit.
+ if (identityMigrationRefused && !sameMigration) return;
if (
current?.content === content &&
current.identityMigrationSourceContent ===
identityMigrationSourceContent &&
current.deferCollabReconcile === deferCollabReconcile &&
+ current.identityMigrationRefused ===
+ (identityMigrationRefused || undefined) &&
(baseUpdatedAt === undefined || current.baseUpdatedAt !== undefined)
)
return;
@@ -1210,6 +1201,7 @@ export function useEditorGenerationAndAccess({
baseUpdatedAt,
identityMigrationSourceContent,
deferCollabReconcile,
+ identityMigrationRefused,
}),
);
setPendingLocalFileContentsRevision((revision) => revision + 1);
@@ -1252,8 +1244,25 @@ export function useEditorGenerationAndAccess({
useLayoutEffect(() => {
canEditDesignRef.current = canEditDesign;
- canPersistDesignSourceRef.current = canEditDesign && isSignedIn;
- }, [canEditDesign, isSignedIn]);
+ canPersistDesignSourceRef.current = canPersistDesignSource;
+ }, [canEditDesign, canPersistDesignSource]);
+ const sourcePersistenceDeniedRef = useRef(!canPersistDesignSource);
+ useLayoutEffect(() => {
+ const regained =
+ canPersistDesignSource && sourcePersistenceDeniedRef.current;
+ sourcePersistenceDeniedRef.current = !canPersistDesignSource;
+ if (!regained) return;
+ // Refused migrations stay pending so they are not re-sent; regained source
+ // access is the only signal that one may now succeed.
+ let cleared = false;
+ for (const [fileId, pending] of pendingLocalFileContentsRef.current) {
+ if (!pending.identityMigrationRefused) continue;
+ pendingLocalFileContentsRef.current.delete(fileId);
+ cleared = true;
+ }
+ if (cleared)
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
+ }, [canPersistDesignSource]);
useEffect(() => {
if (!id || !hasPendingGeneration || !designDataReady) return;
@@ -1648,10 +1657,6 @@ export function useEditorGenerationAndAccess({
publicVisualEdit,
canCommentDesign,
canRenderAuthenticatedShare,
- failedLocalhostConsentClear,
- setFailedLocalhostConsentClear,
- localhostConsentRequestQuery,
- clearLocalhostConsentRequestMutation,
visualEditSnapshotPublicationState,
liveCollaborationSaving,
liveCollaborationEnabled,
@@ -1666,6 +1671,7 @@ export function useEditorGenerationAndAccess({
reviewSendingThreadId,
setReviewSendingThreadId,
canEditDesignRef,
+ canPersistDesignSource,
canPersistDesignSourceRef,
rawServerFilesByIdRef,
historyFilesRef,
diff --git a/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts b/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts
index 6e4b0b425e8..ea9183d7433 100644
--- a/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts
+++ b/templates/design/app/pages/design-editor/domains/use-editor-live-edits-and-presence.ts
@@ -44,14 +44,7 @@ import {
import { isRunningAppSourceType } from "@shared/source-mode";
import { normalizeDesignSourceType } from "@shared/source-mode";
import { useTheme } from "next-themes";
-import {
- useState,
- useEffect,
- useLayoutEffect,
- useCallback,
- useRef,
- useMemo,
-} from "react";
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { toast } from "sonner";
import * as Y from "yjs";
@@ -84,6 +77,7 @@ import {
} from "../editor-state";
import { runAdoptDbFileContent } from "../effects/adopt-db-file-content";
import { runObserveCollabText } from "../effects/observe-collab-text";
+import { usePublishActiveSourceContent } from "../effects/publish-active-source-content";
import { runSeedCollabContent } from "../effects/seed-collab-content";
import { useSyncLatestActiveContent } from "../effects/sync-latest-active-content";
import { forwardYjsUndoStackItemMeta, MAX_DESIGN_UNDO_STACK } from "../history";
@@ -159,6 +153,7 @@ export function useEditorLiveEditsAndPresence({
generating,
pendingGenerationActive,
canEditDesign,
+ canPersistDesignSource,
canEditLiveScreens,
publicVisualEdit,
rawServerFilesByIdRef,
@@ -668,21 +663,16 @@ export function useEditorLiveEditsAndPresence({
}
},
});
- useLayoutEffect(() => {
- if (activeFile?.id && rawActiveContent !== activeContent)
- publishCanonicalContent(
- activeFile.id,
- rawActiveContent,
- activeFile.fileType,
- );
- }, [
- activeFile?.id,
- activeFile?.fileType,
- rawActiveContent,
+ usePublishActiveSourceContent({
+ activeFile,
activeContent,
- publishCanonicalContent,
+ rawActiveContent,
canEditDesign,
- ]);
+ canPersistDesignSource,
+ isSynced,
+ publishCanonicalContent,
+ ydoc,
+ });
useSyncLatestActiveContent({
activeContent,
activeFile,
diff --git a/templates/design/app/pages/design-editor/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/editor-state.ts b/templates/design/app/pages/design-editor/editor-state.ts
index aaa8192d610..88ac882d066 100644
--- a/templates/design/app/pages/design-editor/editor-state.ts
+++ b/templates/design/app/pages/design-editor/editor-state.ts
@@ -428,6 +428,7 @@ export interface PendingLocalFileContent {
identityMigrationSourceContent?: string;
identityMigrationStoredContent?: string;
identityMigrationStoredUpdatedAt?: string | null;
+ identityMigrationRefused?: true;
}
export function createPendingLocalFileContent(args: {
@@ -437,6 +438,7 @@ export function createPendingLocalFileContent(args: {
baseUpdatedAt?: string | null;
identityMigrationSourceContent?: string;
deferCollabReconcile?: boolean;
+ identityMigrationRefused?: boolean;
}): PendingLocalFileContent {
const {
current,
@@ -445,6 +447,7 @@ export function createPendingLocalFileContent(args: {
baseUpdatedAt,
identityMigrationSourceContent,
deferCollabReconcile,
+ identityMigrationRefused,
} = args;
const sameContent = current?.content === content;
const sameMigration =
@@ -477,6 +480,9 @@ export function createPendingLocalFileContent(args: {
: sameContent && sameMigration
? current.identityMigrationStoredUpdatedAt
: (file?.updatedAt ?? null),
+ ...(identityMigrationRefused && identityMigrationSourceContent !== undefined
+ ? { identityMigrationRefused: true }
+ : {}),
};
}
diff --git a/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts b/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts
new file mode 100644
index 00000000000..cd4db35dfe0
--- /dev/null
+++ b/templates/design/app/pages/design-editor/effects/publish-active-source-content.ts
@@ -0,0 +1,55 @@
+import { useLayoutEffect, useRef } from "react";
+import type * as Y from "yjs";
+
+export function usePublishActiveSourceContent({
+ activeFile,
+ activeContent,
+ rawActiveContent,
+ canEditDesign,
+ canPersistDesignSource,
+ isSynced,
+ publishCanonicalContent,
+ ydoc,
+}: {
+ activeFile: { id: string; fileType?: string } | null | undefined;
+ activeContent: string;
+ rawActiveContent: string;
+ canEditDesign: boolean;
+ canPersistDesignSource: boolean;
+ isSynced: boolean;
+ publishCanonicalContent: (
+ fileId: string,
+ sourceContent: string,
+ fileType?: string,
+ ) => string;
+ ydoc: Y.Doc | null;
+}): void {
+ const sourcePersistenceDeniedRef = useRef(!canPersistDesignSource);
+ useLayoutEffect(() => {
+ const regained =
+ canPersistDesignSource && sourcePersistenceDeniedRef.current;
+ sourcePersistenceDeniedRef.current = !canPersistDesignSource;
+ if (!activeFile?.id) return;
+ // A synced screen's collab content is already canonical, so publishing it
+ // finds nothing to persist; regained access must publish the doc's raw text.
+ const syncedText =
+ regained && ydoc && isSynced ? ydoc.getText("content").toJSON() : "";
+ const sourceContent = syncedText || rawActiveContent;
+ if (sourceContent !== activeContent)
+ publishCanonicalContent(
+ activeFile.id,
+ sourceContent,
+ activeFile.fileType,
+ );
+ }, [
+ activeFile?.id,
+ activeFile?.fileType,
+ rawActiveContent,
+ activeContent,
+ publishCanonicalContent,
+ canEditDesign,
+ canPersistDesignSource,
+ isSynced,
+ ydoc,
+ ]);
+}
diff --git a/templates/design/app/pages/design-editor/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..06de9c2641d
--- /dev/null
+++ b/templates/design/app/pages/design-editor/localhost-consent-resume.test.tsx
@@ -0,0 +1,809 @@
+// @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 grantHold: Promise | null;
+ 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;
+ grantHold = null;
+ 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 (grantHold) await grantHold;
+ 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();
+ }
+
+ function openLocalRequest(files: string[]) {
+ const events: string[] = [];
+ act(() =>
+ latest!.openLocalConsent({
+ connectionId: CONNECTION_ID,
+ rootPath: "/repo",
+ files,
+ onGranted: () => events.push("granted"),
+ onCancel: () => events.push("cancelled"),
+ onError: () => events.push("error"),
+ }),
+ );
+ return events;
+ }
+
+ function holdGrant(): () => Promise {
+ let release!: () => void;
+ grantHold = new Promise((resolve) => {
+ release = resolve;
+ });
+ return async () => {
+ grantHold = null;
+ release();
+ await settle();
+ };
+ }
+
+ // Every way a person can dismiss the dialog.
+ const dismissals: Array<[string, () => Promise]> = [
+ ["Cancel", () => click("Cancel")],
+ [
+ "Escape",
+ async () => {
+ await act(async () => {
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ });
+ await settle();
+ },
+ ],
+ [
+ "a press outside the dialog",
+ async () => {
+ await act(async () => {
+ for (const type of [
+ "pointerdown",
+ "mousedown",
+ "pointerup",
+ "mouseup",
+ "click",
+ ]) {
+ document.body.dispatchEvent(
+ new MouseEvent(type, { bubbles: true, button: 0 }),
+ );
+ }
+ });
+ await settle();
+ },
+ ],
+ ];
+
+ // Sign-in is a full-page navigation: capture it, then end this mount the way
+ // a page unload would, without closing the dialog.
+ async function signInAndLeave(): Promise {
+ 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 and never cancels it", async () => {
+ await mountAt(`/design/${DESIGN_ID}`);
+ const onGranted = vi.fn();
+ const onCancel = vi.fn();
+ act(() =>
+ latest!.openLocalConsent({
+ connectionId: CONNECTION_ID,
+ rootPath: "/repo",
+ files: ["src/Card.tsx"],
+ onGranted,
+ onCancel,
+ }),
+ );
+ await settle();
+ await click("Allow writes");
+
+ expect(onGranted).toHaveBeenCalledTimes(1);
+ expect(onGranted).toHaveBeenCalledWith(GRANT);
+ expect(onCancel).not.toHaveBeenCalled();
+ expect(dialogTitled("Allow file writes")).toBeUndefined();
+ expect(dialogTitled(copy().approvedTitle)).toBeUndefined();
+ expect(callsTo("request-localhost-write-consent")).toHaveLength(0);
+ });
+
+ it.each(dismissals)(
+ "cancels a local edit exactly once when %s dismisses it",
+ async (_dismissal, dismiss) => {
+ await mountAt(`/design/${DESIGN_ID}`);
+ const events = openLocalRequest(["src/Card.tsx"]);
+ await settle();
+ expect(dialogTitled("Allow file writes")).toBeDefined();
+
+ await dismiss();
+ expect(events).toEqual(["cancelled"]);
+ expect(dialogTitled("Allow file writes")).toBeUndefined();
+ expect(callsTo("grant-localhost-write-consent")).toHaveLength(0);
+ },
+ );
+
+ it.each(dismissals)(
+ "keeps a granting dialog open through %s until its grant answers",
+ async (_dismissal, dismiss) => {
+ await mountAt(`/design/${DESIGN_ID}`);
+ const events = openLocalRequest(["src/Card.tsx"]);
+ await settle();
+ const release = holdGrant();
+ await click("Allow writes");
+ expect(button("Cancel")?.disabled).toBe(true);
+
+ await dismiss();
+ expect(events).toEqual([]);
+ expect(dialogTitled("Allow file writes")?.textContent).toContain(
+ "Granting…",
+ );
+
+ await release();
+ expect(events).toEqual(["granted"]);
+ expect(dialogTitled("Allow file writes")).toBeUndefined();
+ expect(callsTo("grant-localhost-write-consent")).toHaveLength(1);
+ },
+ );
+
+ const endings: Array<[string, () => void]> = [
+ ["the editor unmounts", () => unmount()],
+ [
+ "the editor closes the request",
+ () => act(() => latest!.onOpenChange(false)),
+ ],
+ ];
+
+ it.each(
+ endings.flatMap(([ending, end]) =>
+ ([200, 500] as const).map((status) => [status, ending, end] as const),
+ ),
+ )("drops a grant answering %i after %s", async (status, _ending, end) => {
+ await mountAt(`/design/${DESIGN_ID}`);
+ const events = openLocalRequest(["src/Card.tsx"]);
+ await settle();
+ const release = holdGrant();
+ await click("Allow writes");
+
+ end();
+ grantStatus = status;
+ await release();
+ expect(events).toEqual([]);
+ expect(dialogTitled("Allow file writes")).toBeUndefined();
+ });
+
+ it.each([200, 500] as const)(
+ "drops an older grant answering %i after a newer request for the same connection replaced it",
+ async (status) => {
+ await mountAt(`/design/${DESIGN_ID}`);
+ const older = openLocalRequest(["src/Card.tsx"]);
+ await settle();
+ const release = holdGrant();
+ await click("Allow writes");
+
+ const newer = openLocalRequest(["src/Next.tsx"]);
+ await settle();
+ expect(button("Allow writes")?.disabled).toBe(false);
+ grantStatus = status;
+ await release();
+ expect(older).toEqual([]);
+ expect(newer).toEqual([]);
+ expect(dialogTitled("Allow file writes")?.textContent).toContain(
+ "src/Next.tsx",
+ );
+
+ grantStatus = 200;
+ await click("Allow writes");
+ expect(newer).toEqual(["granted"]);
+ expect(older).toEqual([]);
+ expect(dialogTitled("Allow file writes")).toBeUndefined();
+ },
+ );
+
+ it("says a returned request is no longer pending when it was cleared, expired, or replaced", async () => {
+ // Edit access gates the handoff, not a browser sign-in.
+ mocks.sessionStatus = "unauthenticated";
+ 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(callsTo("clear-localhost-write-consent-request")).toEqual([
+ {
+ name: "clear-localhost-write-consent-request",
+ body: { designId: DESIGN_ID, requestedAt, requestId: "shown-request" },
+ },
+ ]);
+ expect(serverRecord()).toEqual(newer);
+ expect(dialogTitled("Allow file writes")?.textContent).toContain(
+ "src/Next.tsx",
+ );
+ });
+
+ 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/types.ts b/templates/design/app/pages/design-editor/types.ts
index bd0abc3716a..293f416b348 100644
--- a/templates/design/app/pages/design-editor/types.ts
+++ b/templates/design/app/pages/design-editor/types.ts
@@ -96,5 +96,6 @@ export interface DesignData {
visibility?: "private" | "org" | "public";
data?: string | null;
accessRole?: DesignAccessRole;
+ canPersistSource?: boolean;
files: DesignFile[];
}
diff --git a/templates/design/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..41dfc0d3595 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,
});
@@ -987,7 +988,8 @@ test.describe.serial("HTML Motion source browser writeback", () => {
}
if (!screenId) throw new Error("HTML Motion screen is not registered");
- await postAction(request, "grant-localhost-write-consent", {
+ await page.goto(appPath("/"), { waitUntil: "domcontentloaded" });
+ await grantLocalhostWriteConsent(page, {
designId,
connectionId,
});
diff --git a/templates/design/e2e/motion-react-source.browser.spec.ts b/templates/design/e2e/motion-react-source.browser.spec.ts
index b793ec14719..03e760aad1d 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,
});
@@ -1138,7 +1139,8 @@ test.describe.serial("React Motion source browser writeback", () => {
if (!screenId) throw new Error("React Motion screen is not registered");
const nativeNodeId = "motion-native-target";
- await postAction(request, "grant-localhost-write-consent", {
+ await page.goto(appPath("/"), { waitUntil: "domcontentloaded" });
+ await grantLocalhostWriteConsent(page, {
designId,
connectionId,
});
@@ -1345,7 +1347,8 @@ test.describe.serial("React Motion source browser writeback", () => {
}
if (!screenId) throw new Error("React Motion screen is not registered");
- await postAction(request, "grant-localhost-write-consent", {
+ await page.goto(appPath("/"), { waitUntil: "domcontentloaded" });
+ await grantLocalhostWriteConsent(page, {
designId,
connectionId,
});
diff --git a/templates/design/e2e/public-visual-edit.spec.ts b/templates/design/e2e/public-visual-edit.spec.ts
index 0c9351082a1..1af9d213d5d 100644
--- a/templates/design/e2e/public-visual-edit.spec.ts
+++ b/templates/design/e2e/public-visual-edit.spec.ts
@@ -1940,7 +1940,10 @@ async function setDesignVisibility(
try {
// Making a resource public needs approval, which over HTTP only the
// signed-in app UI gives: a frontend call holding the UI capability.
- await context.request.get(`${BASE_URL}/_agent-native/ui-capability`);
+ const origin = new URL(BASE_URL).origin;
+ await context.request.get(`${BASE_URL}/_agent-native/ui-capability`, {
+ headers: { Origin: origin },
+ });
const response = await context.request.post(
`${BASE_URL}/_agent-native/actions/set-resource-visibility`,
{
@@ -1949,7 +1952,7 @@ async function setDesignVisibility(
resourceId: id,
visibility,
},
- headers: { "X-Agent-Native-Frontend": "1" },
+ headers: { "X-Agent-Native-Frontend": "1", Origin: origin },
},
);
if (!response.ok()) {
diff --git a/templates/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,
});
diff --git a/templates/design/server/plugins/auth.spec.ts b/templates/design/server/plugins/auth.spec.ts
index 0d832afff16..d99bedd2fcc 100644
--- a/templates/design/server/plugins/auth.spec.ts
+++ b/templates/design/server/plugins/auth.spec.ts
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@agent-native/core/server", () => ({
createAuthPlugin: mocks.createAuthPlugin,
registerAuthPublicPaths: mocks.registerAuthPublicPaths,
+ registerDelegatedBuilderCredentials: vi.fn(),
}));
import authPlugin from "./auth.js";
@@ -68,4 +69,23 @@ describe("design auth plugin", () => {
]),
);
});
+
+ it("declares Builder identity as an explicit cookie-only browser provider", async () => {
+ vi.stubEnv("BUILDER_IDENTITY", "1");
+ vi.resetModules();
+ try {
+ const { getBuilderIdentitySession } =
+ await import("../lib/builder-identity.js");
+ await import("./auth.js");
+ expect(mocks.createAuthPlugin).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ getSession: getBuilderIdentitySession,
+ getBrowserSession: getBuilderIdentitySession,
+ }),
+ );
+ } finally {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ }
+ });
});
diff --git a/templates/design/server/plugins/auth.ts b/templates/design/server/plugins/auth.ts
index bcc19858e84..2130ea0edbd 100644
--- a/templates/design/server/plugins/auth.ts
+++ b/templates/design/server/plugins/auth.ts
@@ -15,6 +15,7 @@ export default createToolkitAuthPlugin({
...(isBuilderIdentityEnabled()
? {
getSession: getBuilderIdentitySession,
+ getBrowserSession: getBuilderIdentitySession,
trustCustomEmailVerification: true,
}
: {}),
diff --git a/templates/plan/e2e/concurrent-edit-durability.spec.ts b/templates/plan/e2e/concurrent-edit-durability.spec.ts
index 01d177377d7..608fd727253 100644
--- a/templates/plan/e2e/concurrent-edit-durability.spec.ts
+++ b/templates/plan/e2e/concurrent-edit-durability.spec.ts
@@ -165,7 +165,10 @@ async function registerUser(
async function shareAsEditor(page: Page, planId: string, email: string) {
// Sharing outside the organization needs approval, which over HTTP only the
// signed-in app UI gives: a frontend call holding the UI capability.
- await page.request.get("/_agent-native/ui-capability");
+ const origin = new URL(test.info().project.use.baseURL ?? page.url()).origin;
+ await page.request.get("/_agent-native/ui-capability", {
+ headers: { Origin: origin },
+ });
const res = await page.request.post("/_agent-native/actions/share-resource", {
data: {
resourceType: "plan",
@@ -174,7 +177,7 @@ async function shareAsEditor(page: Page, planId: string, email: string) {
principalId: email,
role: "editor",
},
- headers: { "X-Agent-Native-Frontend": "1" },
+ headers: { "X-Agent-Native-Frontend": "1", Origin: origin },
});
expect(res.ok(), `share-resource: ${await res.text()}`).toBeTruthy();
}
diff --git a/templates/plan/e2e/guest-mode.guest.spec.ts b/templates/plan/e2e/guest-mode.guest.spec.ts
index 0f57795dad8..418b0c7c143 100644
--- a/templates/plan/e2e/guest-mode.guest.spec.ts
+++ b/templates/plan/e2e/guest-mode.guest.spec.ts
@@ -74,12 +74,15 @@ async function createPlanAs(
async function makePublic(request: APIRequestContext, planId: string) {
// Making a plan public needs approval, which over HTTP only the signed-in
// app UI gives: a frontend call holding the UI capability.
- await request.get("/_agent-native/ui-capability");
+ const origin = new URL(test.info().project.use.baseURL ?? APP_ORIGIN).origin;
+ await request.get("/_agent-native/ui-capability", {
+ headers: { Origin: origin },
+ });
const res = await request.post(
"/_agent-native/actions/set-resource-visibility",
{
data: { resourceType: "plan", resourceId: planId, visibility: "public" },
- headers: { "X-Agent-Native-Frontend": "1" },
+ headers: { "X-Agent-Native-Frontend": "1", Origin: origin },
},
);
expect(
diff --git a/templates/slides-2/actions/add-slide.ts b/templates/slides-2/actions/add-slide.ts
index 5255b7fd325..0e1754c66d6 100644
--- a/templates/slides-2/actions/add-slide.ts
+++ b/templates/slides-2/actions/add-slide.ts
@@ -1,9 +1,4 @@
-import {
- ActionContractError,
- defineAction,
- embedApp,
- fail,
-} from "@agent-native/core";
+import { ActionContractError, defineAction, fail } from "@agent-native/core";
import type { WriteReceipt } from "@agent-native/core/action";
import { buildDeepLink, getRequestContext } from "@agent-native/core/server";
import { assertAccess } from "@agent-native/core/sharing";
@@ -247,13 +242,6 @@ export default defineAction({
},
mcpApp: {
compactCatalog: true,
- resource: embedApp({
- title: "Deck editor",
- description: "Open the updated deck in the Slides 2 editor.",
- iframeTitle: "Agent-Native Slides 2",
- openLabel: "Open deck",
- height: 680,
- }),
},
// Not retrySafe: a replay with the same slideId is rejected and must be
// reconciled by reading the deck before another add is attempted.
diff --git a/templates/slides-2/actions/create-deck.ts b/templates/slides-2/actions/create-deck.ts
index ace9d09a250..0bda752d652 100644
--- a/templates/slides-2/actions/create-deck.ts
+++ b/templates/slides-2/actions/create-deck.ts
@@ -1,4 +1,4 @@
-import { defineAction, embedApp, fail } from "@agent-native/core";
+import { defineAction, fail } from "@agent-native/core";
import { writeAppState } from "@agent-native/core/application-state";
import { buildDeepLink } from "@agent-native/core/server";
import {
@@ -474,13 +474,6 @@ export default defineAction({
},
mcpApp: {
compactCatalog: true,
- resource: embedApp({
- title: "Deck preview",
- description: "Open the generated deck in the Slides 2 editor.",
- iframeTitle: "Agent-Native Slides 2",
- openLabel: "Open deck",
- height: 680,
- }),
},
retrySafe: (input) => input.promptDraft !== undefined,
http: { method: "POST" },
diff --git a/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md
new file mode 100644
index 00000000000..3f4006013c5
--- /dev/null
+++ b/templates/slides-2/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-10-22
+---
+
+New decks and added slides open in the full Slides 2 app from their chat links.
diff --git a/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts b/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts
index 01d5bdf379f..f8d39c8aba5 100644
--- a/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts
+++ b/templates/slides-2/server/lib/chatgpt-directory-tools.spec.ts
@@ -200,25 +200,6 @@ describe("Slides ChatGPT directory widget share grant", () => {
mocks.getRequestUserEmail.mockReturnValue("reviewer@example.com");
});
- it("binds the literal deck type and id on every widget target", () => {
- const { widgetTargets } = CHATGPT_DIRECTORY_PROFILE;
-
- expect(widgetTargets["create-deck"]({}, { id: "deck-a" })).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["create-deck"]({ deckId: "deck-a" }, {})).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["add-slide"]({ deckId: "deck-a" }, {})).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["add-slide"]({}, { deckId: "deck-a" })).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["create-deck"]({}, {})).toBeNull();
- expect(widgetTargets["add-slide"]({}, {})).toBeNull();
- });
-
it("limits the Share popover grant to its four actions and their arguments", () => {
const {
widgetReadActionArguments,
@@ -270,43 +251,28 @@ describe("Slides ChatGPT directory widget share grant", () => {
});
});
- it("resolves every resource rule from a target key and every target action from the allowlist", () => {
- const { widgetTargets, widgetWriteActionArguments } =
- CHATGPT_DIRECTORY_PROFILE;
- const targets = [
- widgetTargets["create-deck"]({}, { id: "deck-a" }),
- widgetTargets["add-slide"]({ deckId: "deck-a" }, {}),
- ];
+ it("resolves stored widget rules against a bounded deck fixture", () => {
+ const { widgetWriteActionArguments } = CHATGPT_DIRECTORY_PROFILE;
+ const widgetTarget = target("deck-a");
const rules = [
...Object.values(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments),
...Object.values(widgetWriteActionArguments),
].flatMap((argumentMap) => Object.values(argumentMap));
- for (const widgetTarget of targets) {
- expect(widgetTarget).not.toBeNull();
- for (const rule of rules) {
- if (typeof rule === "string") {
- expect(widgetTarget!.resourceIds).toHaveProperty(rule);
- }
- }
- for (const action of widgetTarget!.writeActions) {
- expect(widgetWriteActionArguments).toHaveProperty(action);
+ for (const rule of rules) {
+ if (typeof rule === "string") {
+ expect(widgetTarget.resourceIds).toHaveProperty(rule);
}
}
+ for (const action of widgetTarget.writeActions) {
+ expect(widgetWriteActionArguments).toHaveProperty(action);
+ }
});
it("does not grant access requests, groups, agent links, or deck deletion", () => {
const granted = new Set([
...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments),
...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetWriteActionArguments),
- ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["create-deck"](
- {},
- { id: "deck-a" },
- )!.writeActions,
- ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["add-slide"](
- { deckId: "deck-a" },
- {},
- )!.writeActions,
]);
for (const action of [
diff --git a/templates/slides-2/server/lib/chatgpt-directory-tools.ts b/templates/slides-2/server/lib/chatgpt-directory-tools.ts
index 5986ca8990b..58d23930cb0 100644
--- a/templates/slides-2/server/lib/chatgpt-directory-tools.ts
+++ b/templates/slides-2/server/lib/chatgpt-directory-tools.ts
@@ -22,39 +22,6 @@ export const CHATGPT_DIRECTORY_TOOL_NAMES = [
"duplicate-deck",
];
-function record(value: unknown): Record {
- return value && typeof value === "object" && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
-
-function id(...values: unknown[]): string | null {
- return (
- values.find(
- (value): value is string =>
- typeof value === "string" && Boolean(value.trim()),
- ) ?? null
- );
-}
-
-// share-resource, unshare-resource, and set-resource-visibility assert admin
-// access to the deck themselves, so an editor holds this grant without being
-// able to change sharing.
-const DECK_WIDGET_WRITE_ACTIONS = [
- "patch-deck",
- "share-resource",
- "unshare-resource",
- "set-resource-visibility",
-] as const;
-
-function deckWidgetTarget(deckId: string) {
- return {
- targetPath: `/deck/${encodeURIComponent(deckId)}`,
- resourceIds: { deckId, resourceType: "deck" },
- writeActions: DECK_WIDGET_WRITE_ACTIONS,
- };
-}
-
type WidgetWriteAuthorizationInput = {
toolName: string;
args: Record;
@@ -91,16 +58,7 @@ export const CHATGPT_DIRECTORY_PROFILE = {
throw error;
}
},
- widgetTargets: {
- "create-deck": (args: Record, result: unknown) => {
- const deckId = id(record(result).id, args.deckId);
- return deckId ? deckWidgetTarget(deckId) : null;
- },
- "add-slide": (args: Record, result: unknown) => {
- const deckId = id(args.deckId, record(result).deckId);
- return deckId ? deckWidgetTarget(deckId) : null;
- },
- },
+ widgetTargets: {},
widgetReadActionArguments: {
// The ticketed get-deck path normalizes duplicate IDs in memory only.
"get-deck": { id: "deckId", deckId: "deckId" },
diff --git a/templates/slides/actions/add-slide.ts b/templates/slides/actions/add-slide.ts
index ca70104b38c..72fb6b199f9 100644
--- a/templates/slides/actions/add-slide.ts
+++ b/templates/slides/actions/add-slide.ts
@@ -1,9 +1,4 @@
-import {
- ActionContractError,
- defineAction,
- embedApp,
- fail,
-} from "@agent-native/core";
+import { ActionContractError, defineAction, fail } from "@agent-native/core";
import { buildDeepLink, getRequestContext } from "@agent-native/core/server";
import { assertAccess } from "@agent-native/core/sharing";
import { track } from "@agent-native/core/tracking";
@@ -199,13 +194,6 @@ export default defineAction({
}),
mcpApp: {
compactCatalog: true,
- resource: embedApp({
- title: "Deck editor",
- description: "Open the updated deck in the real Slides editor.",
- iframeTitle: "Agent-Native Slides",
- openLabel: "Open deck",
- height: 680,
- }),
},
// Not retrySafe, even with a caller slideId: run inserts without checking for
// an existing id, so a replay appends a second copy of the slide.
diff --git a/templates/slides/actions/create-deck.ts b/templates/slides/actions/create-deck.ts
index b80e6a3a760..2a33f9a5f48 100644
--- a/templates/slides/actions/create-deck.ts
+++ b/templates/slides/actions/create-deck.ts
@@ -1,4 +1,4 @@
-import { defineAction, embedApp } from "@agent-native/core";
+import { defineAction } from "@agent-native/core";
import { writeAppState } from "@agent-native/core/application-state";
import { buildDeepLink } from "@agent-native/core/server";
import {
@@ -177,7 +177,7 @@ export default defineAction({
title: "Create Slides deck",
description:
"Create the real editable Agent-Native Slides deck, optionally already populated with slides, or atomically replace all slides in an existing deck. This is the primary Slides MCP write action: use it instead of creating or publishing a standalone HTML artifact with the host's file tools. Put slide markup in `slides[].content`; this action persists it and returns an Open in Slides link. " +
- "For short AI-generated decks in MCP app hosts, pass all generated slides in this call so the real deck editor opens inline already populated. " +
+ "For short AI-generated decks in MCP app hosts, pass all generated slides in this call so the saved deck is ready to open in the full Slides app. " +
"For longer decks or live in-app generation, create the deck with slides: [], then add every generated slide with add-slide sequentially so each write preserves per-slide Creative Context provenance; pass generationComplete=false on intermediate writes and true on the final write; use patch-deck for edits to existing slides or deck structure, and never issue parallel writes to the same deck. The new deck is also opened in the connected Slides UI. " +
"Pass presenter-only speaker notes in each slide's `notes` field; keep them out of slide HTML. " +
"Pass deckId to replace an existing deck. " +
@@ -242,13 +242,6 @@ export default defineAction({
},
mcpApp: {
compactCatalog: true,
- resource: embedApp({
- title: "Deck preview",
- description: "Open the generated deck in the real Slides editor.",
- iframeTitle: "Agent-Native Slides",
- openLabel: "Open deck",
- height: 680,
- }),
},
http: { method: "POST" },
mcpAnnotations: {
diff --git a/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md
new file mode 100644
index 00000000000..84e27a8fe80
--- /dev/null
+++ b/templates/slides/changelog/2026-10-22-open-decks-in-the-full-app-from-chat.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-10-22
+---
+
+New decks and added slides open in the full Slides app from their chat links.
diff --git a/templates/slides/server/lib/chatgpt-directory-tools.spec.ts b/templates/slides/server/lib/chatgpt-directory-tools.spec.ts
index a3559cfbe97..09d619f3db5 100644
--- a/templates/slides/server/lib/chatgpt-directory-tools.spec.ts
+++ b/templates/slides/server/lib/chatgpt-directory-tools.spec.ts
@@ -128,25 +128,6 @@ describe("Slides ChatGPT directory widget share grant", () => {
mocks.getRequestUserEmail.mockReturnValue("reviewer@example.com");
});
- it("binds the literal deck type and id on every widget target", () => {
- const { widgetTargets } = CHATGPT_DIRECTORY_PROFILE;
-
- expect(widgetTargets["create-deck"]({}, { id: "deck-a" })).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["create-deck"]({ deckId: "deck-a" }, {})).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["add-slide"]({ deckId: "deck-a" }, {})).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["add-slide"]({}, { deckId: "deck-a" })).toEqual(
- target("deck-a"),
- );
- expect(widgetTargets["create-deck"]({}, {})).toBeNull();
- expect(widgetTargets["add-slide"]({}, {})).toBeNull();
- });
-
it("limits the Share popover grant to its four actions and their arguments", () => {
const {
widgetReadActionArguments,
@@ -198,43 +179,28 @@ describe("Slides ChatGPT directory widget share grant", () => {
});
});
- it("resolves every resource rule from a target key and every target action from the allowlist", () => {
- const { widgetTargets, widgetWriteActionArguments } =
- CHATGPT_DIRECTORY_PROFILE;
- const targets = [
- widgetTargets["create-deck"]({}, { id: "deck-a" }),
- widgetTargets["add-slide"]({ deckId: "deck-a" }, {}),
- ];
+ it("resolves stored widget rules against a bounded deck fixture", () => {
+ const { widgetWriteActionArguments } = CHATGPT_DIRECTORY_PROFILE;
+ const widgetTarget = target("deck-a");
const rules = [
...Object.values(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments),
...Object.values(widgetWriteActionArguments),
].flatMap((argumentMap) => Object.values(argumentMap));
- for (const widgetTarget of targets) {
- expect(widgetTarget).not.toBeNull();
- for (const rule of rules) {
- if (typeof rule === "string") {
- expect(widgetTarget!.resourceIds).toHaveProperty(rule);
- }
- }
- for (const action of widgetTarget!.writeActions) {
- expect(widgetWriteActionArguments).toHaveProperty(action);
+ for (const rule of rules) {
+ if (typeof rule === "string") {
+ expect(widgetTarget.resourceIds).toHaveProperty(rule);
}
}
+ for (const action of widgetTarget.writeActions) {
+ expect(widgetWriteActionArguments).toHaveProperty(action);
+ }
});
it("does not grant access requests, groups, agent links, or deck deletion", () => {
const granted = new Set([
...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetReadActionArguments),
...Object.keys(CHATGPT_DIRECTORY_PROFILE.widgetWriteActionArguments),
- ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["create-deck"](
- {},
- { id: "deck-a" },
- )!.writeActions,
- ...CHATGPT_DIRECTORY_PROFILE.widgetTargets["add-slide"](
- { deckId: "deck-a" },
- {},
- )!.writeActions,
]);
for (const action of [
diff --git a/templates/slides/server/lib/chatgpt-directory-tools.ts b/templates/slides/server/lib/chatgpt-directory-tools.ts
index 3e4ea48fea3..fd565080e89 100644
--- a/templates/slides/server/lib/chatgpt-directory-tools.ts
+++ b/templates/slides/server/lib/chatgpt-directory-tools.ts
@@ -17,39 +17,6 @@ export const CHATGPT_DIRECTORY_TOOL_NAMES = [
"duplicate-deck",
];
-function record(value: unknown): Record {
- return value && typeof value === "object" && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
-
-function id(...values: unknown[]): string | null {
- return (
- values.find(
- (value): value is string =>
- typeof value === "string" && Boolean(value.trim()),
- ) ?? null
- );
-}
-
-// share-resource, unshare-resource, and set-resource-visibility assert admin
-// access to the deck themselves, so an editor holds this grant without being
-// able to change sharing.
-const DECK_WIDGET_WRITE_ACTIONS = [
- "patch-deck",
- "share-resource",
- "unshare-resource",
- "set-resource-visibility",
-] as const;
-
-function deckWidgetTarget(deckId: string) {
- return {
- targetPath: `/deck/${encodeURIComponent(deckId)}`,
- resourceIds: { deckId, resourceType: "deck" },
- writeActions: DECK_WIDGET_WRITE_ACTIONS,
- };
-}
-
type WidgetWriteAuthorizationInput = {
toolName: string;
args: Record;
@@ -86,16 +53,7 @@ export const CHATGPT_DIRECTORY_PROFILE = {
throw error;
}
},
- widgetTargets: {
- "create-deck": (args: Record, result: unknown) => {
- const deckId = id(record(result).id, args.deckId);
- return deckId ? deckWidgetTarget(deckId) : null;
- },
- "add-slide": (args: Record, result: unknown) => {
- const deckId = id(args.deckId, record(result).deckId);
- return deckId ? deckWidgetTarget(deckId) : null;
- },
- },
+ widgetTargets: {},
widgetReadActionArguments: {
// The ticketed get-deck path normalizes duplicate IDs in memory only.
"get-deck": { id: "deckId", deckId: "deckId" },