Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions packages/react/src/api/shell-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<string, string> }> = [];
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 }),
Expand Down Expand Up @@ -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",
]);
});
});
});
40 changes: 32 additions & 8 deletions packages/react/src/api/shell-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -114,14 +118,37 @@ export const createHttpShellHost = (options?: {
}): HttpShellHost => {
const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis);

const post = async (path: string, payload: Record<string, unknown>): Promise<unknown> => {
const headers: Record<string, string> = { "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<string, string> => {
const headers: Record<string, string> = {
"content-type": "application/json",
...getExecutorOrganizationHeaders(),
};
const authorization = getExecutorServerAuthorizationHeader();
if (authorization) headers.authorization = authorization;
return headers;
};

const post = async (path: string, payload: Record<string, unknown>): Promise<unknown> => {
const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, {
method: "POST",
headers,
headers: requestHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
Expand Down Expand Up @@ -153,12 +180,9 @@ export const createHttpShellHost = (options?: {
* `McpAppsShellHost`.
*/
savePreview: (artifactId: string, preview: string): void => {
const headers: Record<string, string> = { "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
Expand Down
Loading