diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index cf1952f114..3c6507d0c0 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -209,6 +209,53 @@ describe("internalPrincipal", () => { }); }); +describe("principal origin", () => { + const internal: CloudflareConfig = { ...delegating, trustedInternal: true }; + + it("marks a person who reached Access with their own client as direct", () => { + const p = principalFromAccessClaims({ sub: "u", email: "person@example.com" }, config); + expect(p.origin).toBe("direct"); + }); + + it("marks a service token as service", () => { + const p = principalFromAccessClaims({ common_name: "agent-token.access" }, config); + expect(p.origin).toBe("service"); + }); + + it("marks a delegated run as service even though it acts as a person", () => { + const p = applyDelegatedSubject(delegatorPrincipal(), delegating, { + subject: "alice@example.com", + email: "alice@example.com", + }); + expect(p?.origin).toBe("service"); + }); + + it("marks the internal door as service, with or without a named person", () => { + const own = internalPrincipal(internal, { subject: null, email: null }); + const named = internalPrincipal(internal, { + subject: "alice@example.com", + email: "alice@example.com", + }); + expect([own?.origin, named?.origin]).toEqual(["service", "service"]); + }); + + it.effect("ignores an x-executor-origin header on the request", () => + Effect.gen(function* () { + const { verify } = makeAccessVerifier(internal); + const p = yield* verify( + new Request("https://executor.test/mcp", { + headers: { + "X-Executor-Subject": "alice@example.com", + "X-Executor-Subject-Email": "alice@example.com", + "X-Executor-Origin": "direct", + }, + }), + ); + expect(p?.origin).toBe("service"); + }), + ); +}); + describe("makeAccessVerifier doors", () => { const delegatedRequest = () => new Request("https://executor.test/mcp", { diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 6ace3c650a..9b403ca41c 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -63,6 +63,7 @@ export const principalFromAccessClaims = ( name: typeof nameClaim === "string" ? nameClaim : commonName || null, avatarUrl: null, roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + origin: email.length > 0 ? "direct" : "service", orgRoleModel: "organization", orgRole: isAdmin ? "admin" : "member", }; @@ -144,6 +145,7 @@ const bindSubject = ( accountId: identityKey(subject), email, name: email.length > 0 ? email : subject, + origin: "service", roles: isAdmin ? ["admin"] : ["member"], // Restated rather than left to the spread: `Principal` discriminates `orgRole` // on `orgRoleModel`, so the spread alone leaves the union open and `orgRole` @@ -171,10 +173,13 @@ export const internalPrincipal = ( config: CloudflareConfig, delegated: DelegatedIdentity, ): Principal | null => { - const service = principalFromAccessClaims( - { common_name: config.accessDelegationCommonName ?? INTERNAL_BINDING_COMMON_NAME }, - config, - ); + const service: Principal = { + ...principalFromAccessClaims( + { common_name: config.accessDelegationCommonName ?? INTERNAL_BINDING_COMMON_NAME }, + config, + ), + origin: "service", + }; const subject = delegated.subject?.trim() ?? ""; const email = delegated.email?.trim() ?? ""; if (subject.length === 0 && email.length === 0) return service; @@ -210,6 +215,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { name: "Dev", avatarUrl: null, roles: ["admin"], + origin: "direct", orgRoleModel: "organization", orgRole: "admin", }; diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index afd469b0c0..06bcf00d35 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -92,6 +92,7 @@ const propsForPrincipal = ( toolMode: readToolMode(request), resource, webOrigin: new URL(request.url).origin, + origin: principal.origin ?? "service", }, propagation, }; diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 92f8f263c6..ceaeb3f552 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -135,6 +135,7 @@ export class McpSessionDO extends McpAgentSessionDOBase["orgWrites"]; + /** How the acting account reached the executor (see `makeScopedExecutor`). */ + readonly origin?: "direct" | "service"; }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, @@ -132,6 +134,7 @@ export const makeExecutionStack = < { plugins: { mcpResource: options?.mcpResource }, ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + ...(options?.origin === undefined ? {} : { origin: options.origin }), }, ).pipe(Effect.withSpan("executor.stack.scoped_executor")); const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 4c88040a96..4f7f924b97 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -48,6 +48,10 @@ interface PrincipalBase { readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + /** How this identity reached the executor, as the host decided from + * server-side facts: `direct` is a person's own client, `service` is + * everything else. Absent is treated as `service`. */ + readonly origin?: "direct" | "service"; } /** diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 0da4a36133..9c0caf8ebb 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -50,6 +50,7 @@ import { makeHostedHttpClientLayer, touchSubject, type HostedInternalHosts, + type InternalOrigin, } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; @@ -285,6 +286,9 @@ export const makeScopedExecutor = < * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's * role; omitted -> allowed (hosts with no role model). */ readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** How the acting account reached the executor, stamped on internal-host + * calls. Omitted -> `service`. */ + readonly origin?: InternalOrigin; }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { @@ -340,6 +344,7 @@ export const makeScopedExecutor = < requireTls: config.requireTls, internalHosts: config.internalHosts, internalCaller: accountId, + internalOrigin: options?.origin ?? "service", }; const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); const hostedFetch = makeHostedFetch(hostedHttpOptions); diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index 824b13cc14..0c119e2e0b 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -39,6 +39,7 @@ export { type HostedHttpClientOptions, type HostedInternalFetcher, type HostedInternalHosts, + type InternalOrigin, } from "./hosted-http-client"; export { diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index f21b440fab..723a0969c8 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -532,6 +532,32 @@ describe("internal host routing", () => { expect(external.urls).toEqual(["https://api.example/openapi.json"]); }); + it("stamps the origin on internal hosts, replacing any value the request carried", async () => { + const seen: Array = []; + const binding = { + fetch: async (request: Request) => { + seen.push(request.headers.get("x-executor-origin")); + return new Response("ok"); + }, + }; + const external = externalStub(); + const make = (internalOrigin?: "direct" | "service") => + makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + ...(internalOrigin === undefined ? {} : { internalOrigin }), + }); + const forged = { headers: { "x-executor-origin": "direct" } }; + + await make("direct")("https://tools.internal/mcp/websearch"); + await make("service")("https://tools.internal/mcp/websearch"); + await make("service")("https://tools.internal/mcp/websearch", forged); + await make()("https://tools.internal/mcp/websearch", forged); + + expect(seen).toEqual(["direct", "service", "service", null]); + }); + 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 04346f5086..7de00f6244 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -26,6 +26,9 @@ export interface HostedInternalFetcher { /** Exact lowercase hostname to the fetcher that serves it, e.g. `tools.internal`. */ export type HostedInternalHosts = Readonly>; +/** `direct`: a person's own client. `service`: everything else, delegated runs included. */ +export type InternalOrigin = "direct" | "service"; + export interface HostedHttpClientOptions { /** * Private hosts served by a service binding instead of the network. A request @@ -41,6 +44,13 @@ export interface HostedHttpClientOptions { * means the header is removed. */ readonly internalCaller?: string; + /** + * How the acting account reached this executor, sent to internal hosts only as + * `x-executor-origin`. Decided by the host from server-side facts; any value + * the request already carried is replaced, and absent means the header is + * removed. + */ + readonly internalOrigin?: InternalOrigin; readonly allowLocalNetwork?: boolean; /** Require HTTPS, except private addresses explicitly allowed for local development. */ readonly requireTls?: boolean; @@ -307,6 +317,9 @@ const guardFetch = ( /** Carries `HostedHttpClientOptions.internalCaller` to an internal host. */ export const INTERNAL_CALLER_HEADER = "x-executor-account-id"; +/** Carries `HostedHttpClientOptions.internalOrigin` to an internal host. */ +export const INTERNAL_ORIGIN_HEADER = "x-executor-origin"; + // The router sits OUTSIDE the guard: a configured internal host goes straight to // its binding, everything else takes the guarded path unchanged. const routeInternalHosts = ( @@ -329,6 +342,10 @@ const routeInternalHosts = ( if (options.internalCaller) { request.headers.set(INTERNAL_CALLER_HEADER, options.internalCaller); } + request.headers.delete(INTERNAL_ORIGIN_HEADER); + if (options.internalOrigin) { + request.headers.set(INTERNAL_ORIGIN_HEADER, options.internalOrigin); + } return await target.fetch(request); } if (isInternalHostname(hostname)) { diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 805dd14d16..6c8f6db6d3 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -88,6 +88,9 @@ interface McpSessionInitBase { * `/mcp/toolkits/` toolkit), so the tool catalog is scoped to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** How the principal reached the executor, decided server-side at connect + * time (see `Principal.origin`). Absent means `service`. */ + readonly origin?: "direct" | "service"; } /** Live session initialization metadata from an authenticated principal. */ @@ -173,6 +176,9 @@ interface SessionMetaBase { * `buildMcpServer` scopes the tool catalog to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** The origin carried from {@link McpSessionInit}. Absent, including for + * sessions persisted before the field existed, means `service`. */ + readonly origin?: "direct" | "service"; /** * Whether this session's client advertised MCP-Apps support at `initialize`. * diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 01d0994db0..3b6cd28e6a 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -50,6 +50,9 @@ const PrincipalFields = { name: Schema.NullOr(Schema.String), avatarUrl: Schema.NullOr(Schema.String), roles: Schema.Array(Schema.String), + /** How the principal reached the executor, decided by the host from + * server-side facts. Absent is treated as `service`. */ + origin: Schema.optional(Schema.Literals(["direct", "service"])), } as const; export const Principal = Schema.Union([