diff --git a/packages/react/src/api/shell-host.test.ts b/packages/react/src/api/shell-host.test.ts index 886db1054..70601bb60 100644 --- a/packages/react/src/api/shell-host.test.ts +++ b/packages/react/src/api/shell-host.test.ts @@ -2,9 +2,10 @@ // shell's two tool calls over the executions HTTP API — which is exactly the // path that used to surface a raw `ExecutionNotFoundError` to the user when an // approval arrived after the pause was gone. -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; +import { EXECUTOR_ORG_HEADER, setActiveOrgSlugOverride } from "./server-connection"; import { APPROVAL_EXPIRED_MESSAGE, createHttpShellHost } from "./shell-host"; /** The adapter's request body, read back off the wire. */ @@ -21,19 +22,29 @@ const jsonResponse = (body: unknown, status = 200): Response => /** Records what the adapter put on the wire, so the assertions are about the * request the server actually receives rather than the adapter's internals. */ const recordingFetch = (respond: (url: string) => Response) => { - const calls: Array<{ url: string; body: unknown }> = []; + const calls: Array<{ url: string; body: unknown; headers: Record }> = []; const fetch: typeof globalThis.fetch = async (input, init) => { const url = String(input); calls.push({ url, body: typeof init?.body === "string" ? decodeRequestBody(init.body) : undefined, + headers: Object.fromEntries(new Headers(init?.headers).entries()), }); return respond(url); }; return { calls, fetch }; }; +const completed = () => + jsonResponse({ status: "completed", text: "ok", structured: {}, isError: false }); + describe("createHttpShellHost", () => { + // Module state on the org scope: leaking a slug would silently head the + // "no org" cases below. + afterEach(() => { + setActiveOrgSlugOverride(null); + }); + it("maps execute-action onto POST /executions, carrying the artifact id", async () => { const { calls, fetch } = recordingFetch(() => jsonResponse({ status: "completed", text: "ok", structured: { result: 1 }, isError: false }), @@ -108,4 +119,81 @@ describe("createHttpShellHost", () => { // the decision still travels. expect(calls[0]?.body).toStrictEqual({ action: "decline" }); }); + + // The prod outage this file's fix addresses. An org-scoped host (cloud) fails + // CLOSED on a session request with no org selector — it 403s rather than + // falling back to the session's org — so a header-less request from here made + // every artifact tool call return `no_organization`. Asserted on all three + // request shapes, since each built its own headers before. + describe("the org selector header", () => { + it("scopes execute-action to the console's org", async () => { + setActiveOrgSlugOverride("acme"); + const { calls, fetch } = recordingFetch(completed); + + await createHttpShellHost({ fetch }).callServerTool({ + name: "execute-action", + arguments: { code: "return 1", artifactId: "art_1" }, + }); + + expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme"); + }); + + // The approval leg: a resume that lost the scope would strand the user's + // decision on a paused execution. + it("scopes execute-action-resume to the console's org", async () => { + setActiveOrgSlugOverride("acme"); + const { calls, fetch } = recordingFetch(completed); + + await createHttpShellHost({ fetch }).callServerTool({ + name: "execute-action-resume", + arguments: { executionId: "exec_1", action: "accept", content: "{}" }, + }); + + expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme"); + }); + + it("scopes the preview upload to the console's org", async () => { + setActiveOrgSlugOverride("acme"); + const { calls, fetch } = recordingFetch(() => jsonResponse({})); + + createHttpShellHost({ fetch }).savePreview("art_1", "data:image/png;base64,AA=="); + // savePreview is deliberately fire-and-forget, so wait for the microtask + // that issues the request rather than a returned promise. + await Promise.resolve(); + + expect(calls[0]?.url).toContain("/artifacts/art_1/preview"); + expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme"); + }); + + // Hosts without org scoping (local, desktop, self-host) have no slug to + // send, and their servers would have nothing to do with one. + it("sends no selector when the host has no org scope", async () => { + setActiveOrgSlugOverride(null); + const { calls, fetch } = recordingFetch(completed); + + await createHttpShellHost({ fetch }).callServerTool({ + name: "execute-action", + arguments: { code: "return 1" }, + }); + + expect(calls[0]?.headers).not.toHaveProperty(EXECUTOR_ORG_HEADER); + }); + + // The host outlives any one org: it is memoized for the page's lifetime, + // so the scope has to be read per request, not captured at construction. + it("follows the scope when the console switches org", async () => { + setActiveOrgSlugOverride("acme"); + const { calls, fetch } = recordingFetch(completed); + const host = createHttpShellHost({ fetch }); + + await host.callServerTool({ name: "execute-action", arguments: { code: "return 1" } }); + setActiveOrgSlugOverride("globex"); + await host.callServerTool({ name: "execute-action", arguments: { code: "return 1" } }); + + expect(calls.map((call) => call.headers[EXECUTOR_ORG_HEADER])).toStrictEqual([ + "acme", + "globex", + ]); + }); + }); }); diff --git a/packages/react/src/api/shell-host.ts b/packages/react/src/api/shell-host.ts index c90a1e9c3..1605e994f 100644 --- a/packages/react/src/api/shell-host.ts +++ b/packages/react/src/api/shell-host.ts @@ -20,7 +20,11 @@ * structural for exactly this reason — see its declaration. */ -import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; +import { + getExecutorApiBaseUrl, + getExecutorOrganizationHeaders, + getExecutorServerAuthorizationHeader, +} from "./server-connection"; /** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */ type ExecutionResponse = @@ -114,14 +118,37 @@ export const createHttpShellHost = (options?: { }): HttpShellHost => { const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis); - const post = async (path: string, payload: Record): Promise => { - const headers: Record = { "content-type": "application/json" }; + /** + * Every request this host makes, headed the same way as the typed API client + * (`api/client.tsx`'s `transformClient`): bearer when the connection carries + * one, plus the active org selector. + * + * The selector is what an org-scoped host (cloud) scopes the request by, and + * it fails CLOSED — a session request that omits it is rejected outright + * rather than falling back to the session's stored org. Without it here every + * artifact tool call 403'd with `no_organization`. Resolved per request, not + * captured at construction: the host is memoized for the page's lifetime, + * while the scope authority is set during render, so a captured value could + * outlive the org it named. + * + * Hosts without org scoping (local, desktop, self-host) produce no slug and + * so send no header, which is the same convention the neighboring clients + * follow. + */ + const requestHeaders = (): Record => { + const headers: Record = { + "content-type": "application/json", + ...getExecutorOrganizationHeaders(), + }; const authorization = getExecutorServerAuthorizationHeader(); if (authorization) headers.authorization = authorization; + return headers; + }; + const post = async (path: string, payload: Record): Promise => { const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, { method: "POST", - headers, + headers: requestHeaders(), body: JSON.stringify(payload), }); if (!response.ok) { @@ -153,12 +180,9 @@ export const createHttpShellHost = (options?: { * `McpAppsShellHost`. */ savePreview: (artifactId: string, preview: string): void => { - const headers: Record = { "content-type": "application/json" }; - const authorization = getExecutorServerAuthorizationHeader(); - if (authorization) headers.authorization = authorization; void doFetch( `${getExecutorApiBaseUrl()}/artifacts/${encodeURIComponent(artifactId)}/preview`, - { method: "PUT", headers, body: JSON.stringify({ preview }) }, + { method: "PUT", headers: requestHeaders(), body: JSON.stringify({ preview }) }, ).then( () => undefined, // Deliberately silent, and not an Effect: there is no caller to report