diff --git a/doc/public-mcp.md b/doc/public-mcp.md index 3aa183b6353..1f5817e0f1d 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -55,8 +55,8 @@ claude mcp add --transport http paperclip https://YOUR-PAPERCLIP-HOST/mcp/paperc ``` Open Claude Code's `/mcp` menu and authenticate the Paperclip server. Both flows -open browser sign-in and consent: review the selected organization (or choose one for a direct instance connection) and explicitly enable task and -comment writes when wanted. Read-only consent cannot delegate work. +open browser sign-in and consent: review the selected organization (or choose one for a direct instance connection) and review the write-access checkbox. It starts checked when the assistant requests +writes and your organization role allows them; uncheck it for read-only access. Read-only consent cannot delegate work. Ask the assistant to identify the connected organization and list its agents, then ask it to delegate a small task to an available agent. The returned task link is the diff --git a/packages/shared/src/public-mcp.ts b/packages/shared/src/public-mcp.ts index d2d03c7cf1c..2ceb89f1237 100644 --- a/packages/shared/src/public-mcp.ts +++ b/packages/shared/src/public-mcp.ts @@ -17,7 +17,7 @@ export interface McpConnectionRequest { requiresSignIn: boolean; /** Fixed by the authorization request; null permits direct-instance selection. */ requestedCompanyId: string | null; - companies: Array<{ id: string; name: string; canWrite: boolean }>; + companies: Array<{ id: string; name: string; logoUrl: string | null; canWrite: boolean }>; setupUrl: string | null; } diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index b30d757fce5..c756dc22dac 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -3,7 +3,7 @@ import express, { type Request } from "express"; import request from "supertest"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db"; +import { activityLog, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyLogos, assets, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db"; import { instanceSettingsService } from "../services/instance-settings.js"; import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret } from "../services/public-mcp/oauth.js"; import { McpApiError, createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js"; @@ -105,13 +105,15 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => const id = (await oauth.authorize(input)).split("/").at(-1)!; // The binding survives a new service instance, and the description reveals only this membership. const resumed = createPublicMcpOAuth(db, config); - expect(await resumed.describeRequest(id, f.actor, null)).toMatchObject({ requestedCompanyId: f.company.id, companies: [{ id: f.company.id }] }); + const [logo] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: randomUUID(), contentType: "image/png", byteSize: 1, sha256: "fixture" }).returning(); + await db.insert(companyLogos).values({ companyId: f.company.id, assetId: logo!.id }); + expect(await resumed.describeRequest(id, f.actor, null)).toMatchObject({ requestedCompanyId: f.company.id, companies: [{ id: f.company.id, logoUrl: `/api/assets/${logo!.id}/content` }] }); expect((await resumed.describeRequest(id, f.actor, null)).companies).toHaveLength(1); expect((await resumed.describeRequest(id, { type: "none" }, null)).companies).toEqual([]); await expect(resumed.consent(id, f.actor, { decision: "approve", companyId: other!.id, allowWrites: true })).rejects.toMatchObject({ status: 403 }); // A concurrent conversation has an independent binding; rejection did not consume either request. const otherId = (await oauth.authorize({ ...input, company_id: other!.id })).split("/").at(-1)!; - expect((await resumed.describeRequest(otherId, f.actor, null)).companies.map(c => c.id)).toEqual([other!.id]); + expect((await resumed.describeRequest(otherId, f.actor, null)).companies).toEqual([{ id: other!.id, name: other!.name, logoUrl: null, canWrite: true }]); const consent = await resumed.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: true }); const tokens = await resumed.token({ ...f.exchange, code: new URL(consent.redirectUrl).searchParams.get("code")! }); expect((await resumed.authenticate(tokens.access_token)).grant.companyId).toBe(f.company.id); diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index dc29c691aac..b002996a924 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -4,7 +4,7 @@ import { and, eq, gt, inArray, isNull, lt, notExists, sql } from "drizzle-orm"; import { z } from "zod"; import type { Request } from "express"; import { - type Db, activityLog, companies, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, + type Db, activityLog, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, } from "@paperclipai/db"; import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared"; import { boardAuthService } from "../board-auth.js"; @@ -176,8 +176,8 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) { if (!row) throw new McpOAuthError("invalid_request", "Connection request is expired or already decided.", 404); const signedIn = actor.type === "board" && !!actor.userId && ["session", "cloud_tenant"].includes(actor.source ?? ""); const access = signedIn ? await boardAuth.resolveBoardAccess(actor.userId!) : null; - const available = access?.user && access.companyIds.length ? await db.select({ id: companies.id, name: companies.name, status: companies.status }) - .from(companies).where(inArray(companies.id, access.companyIds)) : []; + const available = access?.user && access.companyIds.length ? await db.select({ id: companies.id, name: companies.name, status: companies.status, logoAssetId: companyLogos.assetId }) + .from(companies).leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)).where(inArray(companies.id, access.companyIds)) : []; return { id, clientName: row.client.name, redirectOrigin: new URL(row.request.redirectUri).origin, requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"), requiresSignIn: !access?.user, @@ -186,7 +186,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) { if (row.request.requestedCompanyId && company.id !== row.request.requestedCompanyId) return []; const membership = access?.memberships.find((m) => m.companyId === company.id); return membership?.status === "active" && company.status !== "archived" - ? [{ id: company.id, name: company.name, canWrite: membership.membershipRole !== "viewer" }] : []; + ? [{ id: company.id, name: company.name, logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, canWrite: membership.membershipRole !== "viewer" }] : []; }), setupUrl, }; }, diff --git a/ui/src/pages/McpConnect.test.tsx b/ui/src/pages/McpConnect.test.tsx index 41747cf603d..7c09cd4e951 100644 --- a/ui/src/pages/McpConnect.test.tsx +++ b/ui/src/pages/McpConnect.test.tsx @@ -4,80 +4,113 @@ import type { ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; +import { api } from "../api/client"; import { McpConnectPage } from "./McpConnect"; -const route = vi.hoisted(() => ({ id: "request-one", companyId: null as string | null, unavailable: false })); +const route = vi.hoisted(() => ({ id: "request-one", companyId: null as string | null, unavailable: false, canWrite: true, requestedWrite: true })); vi.mock("@/lib/router", () => ({ useParams: () => ({ id: route.id }), Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, })); -vi.mock("../api/client", () => ({ api: { get: vi.fn(async () => ({ - id: route.id, clientName: "Assistant", redirectOrigin: "https://assistant.example.test", - requestedWrite: true, offlineAccess: true, requiresSignIn: false, requestedCompanyId: route.companyId, - companies: route.unavailable ? [] : [{ id: route.companyId ?? "company-one", name: "Acme Research", canWrite: true }], setupUrl: null, -})) } })); +vi.mock("@/components/CompanyPatternIcon", () => ({ + CompanyPatternIcon: ({ companyName, logoUrl }: { companyName: string; logoUrl?: string | null }) => {`${companyName}, +})); +vi.mock("../api/client", () => ({ api: { + get: vi.fn(async () => ({ + id: route.id, clientName: "Assistant", redirectOrigin: "https://assistant.example.test", + requestedWrite: route.requestedWrite, offlineAccess: true, requiresSignIn: false, requestedCompanyId: route.companyId, + companies: route.unavailable ? [] : [{ id: route.companyId ?? "company-one", name: "Acme Research", logoUrl: "/api/assets/acme-logo/content", canWrite: route.canWrite }], setupUrl: null, + })), + post: vi.fn(() => new Promise(() => {})), +} })); -it("requires a new organization selection and write consent when the request changes", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const render = () => flushSync(() => root.render()); - const button = () => Array.from(container.querySelectorAll("button")).find((item) => item.textContent === "Connect organization")!; - try { - render(); - await vi.waitFor(() => expect(container.querySelector('input[type="radio"]')).not.toBeNull()); - expect(button().disabled).toBe(true); - flushSync(() => (container.querySelector('input[type="radio"]') as HTMLInputElement).click()); - flushSync(() => (container.querySelector('input[type="checkbox"]') as HTMLInputElement).click()); - expect(button().disabled).toBe(false); - route.id = "request-two"; - render(); - await vi.waitFor(() => expect(container.querySelector('input[type="radio"]')).not.toBeNull()); - expect((container.querySelector('input[type="radio"]') as HTMLInputElement).checked).toBe(false); - expect((container.querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false); - expect(button().disabled).toBe(true); - } finally { - flushSync(() => root.unmount()); - container.remove(); - client.clear(); - } +beforeEach(() => { + Object.assign(route, { id: "request-one", companyId: null, unavailable: false, canWrite: true, requestedWrite: true }); + vi.clearAllMocks(); }); -it("keeps a hosted organization fixed and resets write consent for another request", async () => { - route.id = "hosted-one"; - route.companyId = "company-one"; +function setup() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); const render = () => flushSync(() => root.render()); - const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Connect organization")!; + render(); + return { + client, container, render, + connect: () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Connect organization")!, + checkbox: () => container.querySelector('[role="checkbox"]') as HTMLButtonElement, + cleanup: () => { flushSync(() => root.unmount()); container.remove(); client.clear(); }, + }; +} + +it("defaults eligible writes on after selection, preserves opt-out on refetch, and submits read-only consent", async () => { + const page = setup(); try { - render(); - await vi.waitFor(() => expect(container.textContent).toContain("Acme Research")); - expect(container.querySelector('input[type="radio"]')).toBeNull(); - expect(button().disabled).toBe(false); - flushSync(() => (container.querySelector('input[type="checkbox"]') as HTMLInputElement).click()); + await vi.waitFor(() => expect(page.container.querySelector('input[type="radio"]')).not.toBeNull()); + expect(page.connect().disabled).toBe(true); + expect(page.checkbox().getAttribute("aria-checked")).toBe("false"); + flushSync(() => (page.container.querySelector('input[type="radio"]') as HTMLInputElement).click()); + expect(page.checkbox().getAttribute("aria-checked")).toBe("true"); + flushSync(() => page.checkbox().click()); + await page.client.invalidateQueries({ queryKey: ["mcp-request", route.id] }); + expect(page.checkbox().getAttribute("aria-checked")).toBe("false"); + flushSync(() => page.connect().click()); + await vi.waitFor(() => expect(api.post).toHaveBeenCalledWith("/mcp/requests/request-one/consent", { decision: "approve", companyId: "company-one", allowWrites: false })); + route.id = "request-two"; + page.render(); + await vi.waitFor(() => expect(page.container.querySelector('input[type="radio"]')).not.toBeNull()); + expect((page.container.querySelector('input[type="radio"]') as HTMLInputElement).checked).toBe(false); + expect(page.connect().disabled).toBe(true); + flushSync(() => (page.container.querySelector('input[type="radio"]') as HTMLInputElement).click()); + expect(page.checkbox().getAttribute("aria-checked")).toBe("true"); + } finally { page.cleanup(); } +}); + +it("keeps hosted organization identity fixed and defaults each new request to eligible write access", async () => { + route.companyId = "company-one"; + const page = setup(); + try { + await vi.waitFor(() => expect(page.container.textContent).toContain("Acme Research")); + expect(page.container.querySelector('input[type="radio"]')).toBeNull(); + expect(page.container.querySelector('img[alt="Acme Research logo"]')?.getAttribute("src")).toBe("/api/assets/acme-logo/content"); + expect(page.checkbox().getAttribute("aria-checked")).toBe("true"); + flushSync(() => page.checkbox().click()); + expect(page.checkbox().getAttribute("aria-checked")).toBe("false"); route.id = "hosted-two"; route.companyId = "company-two"; - render(); - await vi.waitFor(() => expect(container.textContent).toContain("Acme Research")); - expect(container.querySelector('input[type="radio"]')).toBeNull(); - expect((container.querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false); - // If current membership disappears, the fixed organization cannot become a picker. + page.render(); + await vi.waitFor(() => expect(page.container.textContent).toContain("Acme Research")); + expect(page.checkbox().getAttribute("aria-checked")).toBe("true"); + route.canWrite = false; + await page.client.invalidateQueries({ queryKey: ["mcp-request", route.id] }); + await vi.waitFor(() => expect(page.checkbox().disabled).toBe(true)); + expect(page.checkbox().getAttribute("aria-checked")).toBe("false"); + // Losing membership never turns a fixed hosted organization into a picker. route.unavailable = true; - await client.invalidateQueries({ queryKey: ["mcp-request", route.id] }); - await vi.waitFor(() => expect(container.textContent).toContain("selected organization is no longer available")); - expect(container.querySelector('input[type="radio"]')).toBeNull(); - expect(button().disabled).toBe(true); - expect(Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Cancel")!.disabled).toBe(false); - } finally { - route.companyId = null; - route.unavailable = false; - flushSync(() => root.unmount()); - container.remove(); - client.clear(); - } + await page.client.invalidateQueries({ queryKey: ["mcp-request", route.id] }); + await vi.waitFor(() => expect(page.container.textContent).toContain("selected organization is no longer available")); + expect(page.container.querySelector('input[type="radio"]')).toBeNull(); + expect(page.connect().disabled).toBe(true); + expect(Array.from(page.container.querySelectorAll("button")).find(item => item.textContent === "Cancel")!.disabled).toBe(false); + } finally { page.cleanup(); } +}); + +it.each([ + { requestedWrite: true, canWrite: true, allowWrites: true }, + { requestedWrite: true, canWrite: false, allowWrites: false }, + { requestedWrite: false, canWrite: true, allowWrites: false }, +])("submits only allowed requested access: %j", async ({ requestedWrite, canWrite, allowWrites }) => { + Object.assign(route, { companyId: "company-one", requestedWrite, canWrite }); + const page = setup(); + try { + await vi.waitFor(() => expect(page.container.textContent).toContain("Acme Research")); + if (requestedWrite) { + expect(page.checkbox().getAttribute("aria-checked")).toBe(String(allowWrites)); + expect(page.checkbox().disabled).toBe(!canWrite); + } else expect(page.checkbox()).toBeNull(); + flushSync(() => page.connect().click()); + await vi.waitFor(() => expect(api.post).toHaveBeenCalledWith("/mcp/requests/request-one/consent", { decision: "approve", companyId: "company-one", allowWrites })); + } finally { page.cleanup(); } }); diff --git a/ui/src/pages/McpConnect.tsx b/ui/src/pages/McpConnect.tsx index c0d262e711c..4f4d90ad31e 100644 --- a/ui/src/pages/McpConnect.tsx +++ b/ui/src/pages/McpConnect.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { Paperclip } from "lucide-react"; import { Link, useParams } from "@/lib/router"; import type { McpConnection, McpConnectionRequest } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; import { api } from "../api/client"; export function McpConnectPage() { @@ -13,42 +16,48 @@ export function McpConnectPage() { function McpConnectRequest({ id }: { id: string }) { const [companyId, setCompanyId] = useState(""); - const [allowWrites, setAllowWrites] = useState(false); + const [writeEnabled, setWriteEnabled] = useState(true); const request = useQuery({ queryKey: ["mcp-request", id], queryFn: () => api.get(`/mcp/requests/${encodeURIComponent(id)}`), retry: false }); const data = request.data; const selectedCompanyId = data?.requestedCompanyId ?? companyId; + const company = data?.companies.find((item) => item.id === selectedCompanyId); + const allowWrites = Boolean(data?.requestedWrite && company?.canWrite && writeEnabled); const consent = useMutation({ mutationFn: (decision: "approve" | "deny") => api.post<{ redirectUrl: string }>(`/mcp/requests/${encodeURIComponent(id)}/consent`, { decision, companyId: selectedCompanyId || undefined, allowWrites }), onSuccess: ({ redirectUrl }) => { window.location.assign(redirectUrl); }, }); - const company = data?.companies.find((item) => item.id === selectedCompanyId); return
+

Connect your assistant to Paperclip

{request.isPending &&

Loading connection request…

} {request.error &&

{request.error.message} Start a new connection from your assistant.

} {data && <>

{data.clientName} is requesting access. The connection returns to {data.redirectOrigin}.

{data.requiresSignIn ? : <> -

{data.requestedCompanyId ? "Review access to the organization you selected." : "Choose the organization this assistant may use as you."} Your permissions and attribution apply to every action.

- {data.requestedCompanyId ?
-

Organization

- {company ?

{company.name}

:

The selected organization is no longer available to this account. Cancel and reconnect from your assistant to choose an organization you can access.

} + {data.requestedCompanyId ?
+ {company && } +
+

Organization

+ {company ?

{company.name}

:

The selected organization is no longer available to this account. Cancel and reconnect from your assistant to choose an organization you can access.

} +
:
Organization - {data.companies.map((item) =>
}

Read agents, projects, tasks, comments, documents, deliverables and pending approvals.

- {data.requestedWrite &&
; diff --git a/ui/storybook/fixtures/publicMcp.ts b/ui/storybook/fixtures/publicMcp.ts index 3ab19430539..914580b8c5f 100644 --- a/ui/storybook/fixtures/publicMcp.ts +++ b/ui/storybook/fixtures/publicMcp.ts @@ -6,8 +6,8 @@ export const request: McpConnectionRequest = { id: "storybook-request", clientName: "Codex", redirectOrigin: "https://chatgpt.com", requestedWrite: true, offlineAccess: true, requiresSignIn: false, requestedCompanyId: null, companies: [ - { id: "00000000-0000-4000-8000-000000000001", name: "Acme Research", canWrite: true }, - { id: "00000000-0000-4000-8000-000000000002", name: "Design Partners", canWrite: false }, + { id: "00000000-0000-4000-8000-000000000001", name: "Acme Research", logoUrl: null, canWrite: true }, + { id: "00000000-0000-4000-8000-000000000002", name: "Design Partners", logoUrl: null, canWrite: false }, ], setupUrl: null, }; const connections: McpConnection[] = [ diff --git a/ui/storybook/stories/public-mcp-consent.stories.tsx b/ui/storybook/stories/public-mcp-consent.stories.tsx index c100ddfacea..a10967708e7 100644 --- a/ui/storybook/stories/public-mcp-consent.stories.tsx +++ b/ui/storybook/stories/public-mcp-consent.stories.tsx @@ -21,7 +21,6 @@ export const ChooseOrganization: Story = {}; export const AllowDelegation: Story = { play: async context => { await chooseOrganization(context); const c = within(context.canvasElement); - await userEvent.click(c.getByRole("checkbox")); await expect(c.getByRole("checkbox")).toBeChecked(); await expect(c.getByRole("button", { name: "Connect organization" })).toBeEnabled(); } }; @@ -54,7 +53,10 @@ export const SaveFailed: Story = { parameters: { fixture: { mutationError: true } }; export const SubmitReadOnlyConsent: Story = { play: async context => { await chooseOrganization(context); - await userEvent.click(within(context.canvasElement).getByRole("button", { name: "Connect organization" })); + const c = within(context.canvasElement); + await userEvent.click(c.getByRole("checkbox")); + await expect(c.getByRole("checkbox")).not.toBeChecked(); + await userEvent.click(c.getByRole("button", { name: "Connect organization" })); await expect(consentSubmission).toHaveBeenCalledWith({ decision: "approve", companyId: request.companies[0].id, allowWrites: false }); } }; export const Cancel: Story = { play: async ({ canvasElement }) => { @@ -70,8 +72,7 @@ export const HostedConsentCheck: Story = { ...HostedOrganization, play: async ({ await expect(await c.findByText("Acme Research")).toBeVisible(); await expect(c.queryByRole("radio")).not.toBeInTheDocument(); await expect(c.queryByText("Design Partners")).not.toBeInTheDocument(); - await expect(c.getByRole("checkbox")).not.toBeChecked(); - await userEvent.click(c.getByRole("checkbox")); + await expect(c.getByRole("checkbox")).toBeChecked(); await userEvent.click(c.getByRole("button", { name: "Connect organization" })); await expect(consentSubmission).toHaveBeenCalledWith({ decision: "approve", companyId: request.companies[0].id, allowWrites: true }); } }; @@ -80,6 +81,7 @@ export const HostedReadOnly: Story = { parameters: { fixture: { request: { reque await expect(await c.findByText("Design Partners")).toBeVisible(); await expect(c.queryByRole("radio")).not.toBeInTheDocument(); await expect(c.getByRole("checkbox")).toBeDisabled(); + await expect(c.getByRole("checkbox")).not.toBeChecked(); await expect(c.getByRole("button", { name: "Connect organization" })).toBeEnabled(); } }; export const HostedOrganizationUnavailable: Story = { parameters: { fixture: { request: { requestedCompanyId: request.companies[0].id, companies: [], setupUrl: "https://my.paperclip.app/orgs/new" } } }, play: async ({ canvasElement }) => { diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/public-mcp-journey.stories.tsx index 37824ba85f1..fc128dbfc8c 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/public-mcp-journey.stories.tsx @@ -73,7 +73,7 @@ const steps: JourneyStep[] = [ "title": "Review organization permissions", "place": "Paperclip · Acme Research consent", "why": "Alex is connecting as a person, not becoming the researcher. The selected organization and Alex’s granted permissions bound every subsequent tool call.", - "action": "Acme Research is already selected from Cloud. Review the organization name, check the write-permission box to allow delegation and feedback, then click Connect organization. Preview redirects stay in this frame; then use Next step.", + "action": "Acme Research is already selected from Cloud. Review the organization icon and name. Write access starts enabled for delegation and feedback; uncheck it for read-only access, then click Connect organization. Preview redirects stay in this frame; then use Next step.", "outcome": "Read access permits summaries and results. Write access also permits task creation and comments, which may wake or queue an agent. The organization stays fixed for this request. To connect a different organization, start a new connection from the assistant.", "question": "Would Alex understand that “delegate to researcher” does not let the assistant impersonate that agent?", "story": "assistant-connections-consent--hosted-organization"