Skip to content

Commit 6a801f2

Browse files
authored
Send the org selector header from the artifact shell host (#1501)
The console artifact page's HTTP host sent only content-type and authorization, so every artifact tool call on cloud failed with no_organization once org-scoped session reads began failing closed. Head all three of its requests (executions, resume, preview upload) the same way the typed API client does, resolving the scope per request.
1 parent 5eb2ca3 commit 6a801f2

2 files changed

Lines changed: 122 additions & 10 deletions

File tree

packages/react/src/api/shell-host.test.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// shell's two tool calls over the executions HTTP API — which is exactly the
33
// path that used to surface a raw `ExecutionNotFoundError` to the user when an
44
// approval arrived after the pause was gone.
5-
import { describe, expect, it } from "@effect/vitest";
5+
import { afterEach, describe, expect, it } from "@effect/vitest";
66
import { Schema } from "effect";
77

8+
import { EXECUTOR_ORG_HEADER, setActiveOrgSlugOverride } from "./server-connection";
89
import { APPROVAL_EXPIRED_MESSAGE, createHttpShellHost } from "./shell-host";
910

1011
/** The adapter's request body, read back off the wire. */
@@ -21,19 +22,29 @@ const jsonResponse = (body: unknown, status = 200): Response =>
2122
/** Records what the adapter put on the wire, so the assertions are about the
2223
* request the server actually receives rather than the adapter's internals. */
2324
const recordingFetch = (respond: (url: string) => Response) => {
24-
const calls: Array<{ url: string; body: unknown }> = [];
25+
const calls: Array<{ url: string; body: unknown; headers: Record<string, string> }> = [];
2526
const fetch: typeof globalThis.fetch = async (input, init) => {
2627
const url = String(input);
2728
calls.push({
2829
url,
2930
body: typeof init?.body === "string" ? decodeRequestBody(init.body) : undefined,
31+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
3032
});
3133
return respond(url);
3234
};
3335
return { calls, fetch };
3436
};
3537

38+
const completed = () =>
39+
jsonResponse({ status: "completed", text: "ok", structured: {}, isError: false });
40+
3641
describe("createHttpShellHost", () => {
42+
// Module state on the org scope: leaking a slug would silently head the
43+
// "no org" cases below.
44+
afterEach(() => {
45+
setActiveOrgSlugOverride(null);
46+
});
47+
3748
it("maps execute-action onto POST /executions, carrying the artifact id", async () => {
3849
const { calls, fetch } = recordingFetch(() =>
3950
jsonResponse({ status: "completed", text: "ok", structured: { result: 1 }, isError: false }),
@@ -108,4 +119,81 @@ describe("createHttpShellHost", () => {
108119
// the decision still travels.
109120
expect(calls[0]?.body).toStrictEqual({ action: "decline" });
110121
});
122+
123+
// The prod outage this file's fix addresses. An org-scoped host (cloud) fails
124+
// CLOSED on a session request with no org selector — it 403s rather than
125+
// falling back to the session's org — so a header-less request from here made
126+
// every artifact tool call return `no_organization`. Asserted on all three
127+
// request shapes, since each built its own headers before.
128+
describe("the org selector header", () => {
129+
it("scopes execute-action to the console's org", async () => {
130+
setActiveOrgSlugOverride("acme");
131+
const { calls, fetch } = recordingFetch(completed);
132+
133+
await createHttpShellHost({ fetch }).callServerTool({
134+
name: "execute-action",
135+
arguments: { code: "return 1", artifactId: "art_1" },
136+
});
137+
138+
expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme");
139+
});
140+
141+
// The approval leg: a resume that lost the scope would strand the user's
142+
// decision on a paused execution.
143+
it("scopes execute-action-resume to the console's org", async () => {
144+
setActiveOrgSlugOverride("acme");
145+
const { calls, fetch } = recordingFetch(completed);
146+
147+
await createHttpShellHost({ fetch }).callServerTool({
148+
name: "execute-action-resume",
149+
arguments: { executionId: "exec_1", action: "accept", content: "{}" },
150+
});
151+
152+
expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme");
153+
});
154+
155+
it("scopes the preview upload to the console's org", async () => {
156+
setActiveOrgSlugOverride("acme");
157+
const { calls, fetch } = recordingFetch(() => jsonResponse({}));
158+
159+
createHttpShellHost({ fetch }).savePreview("art_1", "data:image/png;base64,AA==");
160+
// savePreview is deliberately fire-and-forget, so wait for the microtask
161+
// that issues the request rather than a returned promise.
162+
await Promise.resolve();
163+
164+
expect(calls[0]?.url).toContain("/artifacts/art_1/preview");
165+
expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme");
166+
});
167+
168+
// Hosts without org scoping (local, desktop, self-host) have no slug to
169+
// send, and their servers would have nothing to do with one.
170+
it("sends no selector when the host has no org scope", async () => {
171+
setActiveOrgSlugOverride(null);
172+
const { calls, fetch } = recordingFetch(completed);
173+
174+
await createHttpShellHost({ fetch }).callServerTool({
175+
name: "execute-action",
176+
arguments: { code: "return 1" },
177+
});
178+
179+
expect(calls[0]?.headers).not.toHaveProperty(EXECUTOR_ORG_HEADER);
180+
});
181+
182+
// The host outlives any one org: it is memoized for the page's lifetime,
183+
// so the scope has to be read per request, not captured at construction.
184+
it("follows the scope when the console switches org", async () => {
185+
setActiveOrgSlugOverride("acme");
186+
const { calls, fetch } = recordingFetch(completed);
187+
const host = createHttpShellHost({ fetch });
188+
189+
await host.callServerTool({ name: "execute-action", arguments: { code: "return 1" } });
190+
setActiveOrgSlugOverride("globex");
191+
await host.callServerTool({ name: "execute-action", arguments: { code: "return 1" } });
192+
193+
expect(calls.map((call) => call.headers[EXECUTOR_ORG_HEADER])).toStrictEqual([
194+
"acme",
195+
"globex",
196+
]);
197+
});
198+
});
111199
});

packages/react/src/api/shell-host.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
* structural for exactly this reason — see its declaration.
2121
*/
2222

23-
import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";
23+
import {
24+
getExecutorApiBaseUrl,
25+
getExecutorOrganizationHeaders,
26+
getExecutorServerAuthorizationHeader,
27+
} from "./server-connection";
2428

2529
/** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */
2630
type ExecutionResponse =
@@ -114,14 +118,37 @@ export const createHttpShellHost = (options?: {
114118
}): HttpShellHost => {
115119
const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis);
116120

117-
const post = async (path: string, payload: Record<string, unknown>): Promise<unknown> => {
118-
const headers: Record<string, string> = { "content-type": "application/json" };
121+
/**
122+
* Every request this host makes, headed the same way as the typed API client
123+
* (`api/client.tsx`'s `transformClient`): bearer when the connection carries
124+
* one, plus the active org selector.
125+
*
126+
* The selector is what an org-scoped host (cloud) scopes the request by, and
127+
* it fails CLOSED — a session request that omits it is rejected outright
128+
* rather than falling back to the session's stored org. Without it here every
129+
* artifact tool call 403'd with `no_organization`. Resolved per request, not
130+
* captured at construction: the host is memoized for the page's lifetime,
131+
* while the scope authority is set during render, so a captured value could
132+
* outlive the org it named.
133+
*
134+
* Hosts without org scoping (local, desktop, self-host) produce no slug and
135+
* so send no header, which is the same convention the neighboring clients
136+
* follow.
137+
*/
138+
const requestHeaders = (): Record<string, string> => {
139+
const headers: Record<string, string> = {
140+
"content-type": "application/json",
141+
...getExecutorOrganizationHeaders(),
142+
};
119143
const authorization = getExecutorServerAuthorizationHeader();
120144
if (authorization) headers.authorization = authorization;
145+
return headers;
146+
};
121147

148+
const post = async (path: string, payload: Record<string, unknown>): Promise<unknown> => {
122149
const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, {
123150
method: "POST",
124-
headers,
151+
headers: requestHeaders(),
125152
body: JSON.stringify(payload),
126153
});
127154
if (!response.ok) {
@@ -153,12 +180,9 @@ export const createHttpShellHost = (options?: {
153180
* `McpAppsShellHost`.
154181
*/
155182
savePreview: (artifactId: string, preview: string): void => {
156-
const headers: Record<string, string> = { "content-type": "application/json" };
157-
const authorization = getExecutorServerAuthorizationHeader();
158-
if (authorization) headers.authorization = authorization;
159183
void doFetch(
160184
`${getExecutorApiBaseUrl()}/artifacts/${encodeURIComponent(artifactId)}/preview`,
161-
{ method: "PUT", headers, body: JSON.stringify({ preview }) },
185+
{ method: "PUT", headers: requestHeaders(), body: JSON.stringify({ preview }) },
162186
).then(
163187
() => undefined,
164188
// Deliberately silent, and not an Effect: there is no caller to report

0 commit comments

Comments
 (0)