From 11d62c0ea2af5c8c510b90150ecc0ee24bb38670 Mon Sep 17 00:00:00 2001 From: fforres Date: Fri, 9 Oct 2026 08:28:36 -0700 Subject: [PATCH] feat(sdk): tell internal hosts which account is calling --- .changeset/internal-host-caller.md | 6 ++++ .../core/api/src/server/scoped-executor.ts | 1 + .../core/sdk/src/hosted-http-client.test.ts | 30 +++++++++++++++++++ packages/core/sdk/src/hosted-http-client.ts | 18 ++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .changeset/internal-host-caller.md diff --git a/.changeset/internal-host-caller.md b/.changeset/internal-host-caller.md new file mode 100644 index 0000000000..a9be0edd94 --- /dev/null +++ b/.changeset/internal-host-caller.md @@ -0,0 +1,6 @@ +--- +"@executor-js/sdk": patch +"@executor-js/api": patch +--- + +Requests to an internal host (a service binding) now carry the acting account in `x-executor-account-id`, so the bound Worker can authorize per caller. Any value the request already carried is replaced, and requests to other hosts never get it. diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 950e3b1e5c..0da4a36133 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -339,6 +339,7 @@ export const makeScopedExecutor = < allowLocalNetwork: config.allowLocalNetwork, requireTls: config.requireTls, internalHosts: config.internalHosts, + internalCaller: accountId, }; const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); const hostedFetch = makeHostedFetch(hostedHttpOptions); diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index e689c5de6e..f21b440fab 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -502,6 +502,36 @@ describe("internal host routing", () => { expect(external.urls).toEqual([]); }); + it("tells the binding who the caller is, replacing any value the request carried", async () => { + const seen: Array = []; + const binding = { + fetch: async (request: Request) => { + seen.push(request.headers.get("x-executor-account-id")); + return new Response("ok"); + }, + }; + const external = externalStub(); + const withCaller = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + internalCaller: "ana@example.com", + }); + const withoutCaller = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + const spoofed = { headers: { "x-executor-account-id": "root@example.com" } }; + + await withCaller("https://tools.internal/mcp/websearch", spoofed); + await withoutCaller("https://tools.internal/mcp/websearch", spoofed); + await withCaller("https://api.example/openapi.json", spoofed); + + expect(seen).toEqual(["ana@example.com", null]); + expect(external.urls).toEqual(["https://api.example/openapi.json"]); + }); + it("hands the binding a manual-redirect request and returns its redirect untouched", async () => { const seen: string[] = []; const binding = { diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index d48460ceed..3df2b46566 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -34,6 +34,13 @@ export interface HostedHttpClientOptions { * one. Empty or absent leaves routing off. */ readonly internalHosts?: HostedInternalHosts; + /** + * The acting account, sent to internal hosts only as `x-executor-account-id` + * so the bound Worker can authorize per caller. The binding is the trust + * boundary: any value the request already carried is replaced, and absent + * means the header is removed. + */ + readonly internalCaller?: string; readonly allowLocalNetwork?: boolean; /** Require HTTPS, except private addresses explicitly allowed for local development. */ readonly requireTls?: boolean; @@ -297,6 +304,9 @@ const guardFetch = ( return await underlying(current, { ...currentInit, redirect: "manual" }); }) as typeof globalThis.fetch; +/** Carries `HostedHttpClientOptions.internalCaller` to an internal host. */ +export const INTERNAL_CALLER_HEADER = "x-executor-account-id"; + // The router sits OUTSIDE the guard: a configured internal host goes straight to // its binding, everything else takes the guarded path unchanged. const routeInternalHosts = ( @@ -311,7 +321,13 @@ const routeInternalHosts = ( const request = new Request(input, { ...init, redirect: "manual" }); const hostname = normalizeHostname(new URL(request.url).hostname); const target = Object.hasOwn(hosts, hostname) ? hosts[hostname] : undefined; - if (target) return await target.fetch(request); + if (target) { + request.headers.delete(INTERNAL_CALLER_HEADER); + if (options.internalCaller) { + request.headers.set(INTERNAL_CALLER_HEADER, options.internalCaller); + } + return await target.fetch(request); + } if (isInternalHostname(hostname)) { return await Effect.runPromise( Effect.fail(