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" ;
66import { Schema } from "effect" ;
77
8+ import { EXECUTOR_ORG_HEADER , setActiveOrgSlugOverride } from "./server-connection" ;
89import { 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. */
2324const 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+
3641describe ( "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} ) ;
0 commit comments