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
47 changes: 47 additions & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
14 changes: 10 additions & 4 deletions apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -210,6 +215,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
name: "Dev",
avatarUrl: null,
roles: ["admin"],
origin: "direct",
orgRoleModel: "organization",
orgRole: "admin",
};
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const propsForPrincipal = (
toolMode: readToolMode(request),
resource,
webOrigin: new URL(request.url).origin,
origin: principal.origin ?? "service",
},
propagation,
};
Expand Down
3 changes: 2 additions & 1 deletion apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
artifactsEnabled: token.artifactsEnabled,
searchToolsEnabled: token.searchToolsEnabled,
toolMode: token.toolMode,
origin: token.origin,
} satisfies SessionMeta);
}

Expand All @@ -152,7 +153,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
{ mcpResource: sessionMeta.resource, orgWrites: "request" },
{ mcpResource: sessionMeta.resource, orgWrites: "request", origin: sessionMeta.origin },
).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
// Browser elicitation mode (the base owns the approval store + the HTTP
// approval RPCs): a gated execution pauses and returns an approvalUrl into
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/server/execution-stack-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export const makeExecutionStackMiddleware = <
resolved.orgRoleModel === "none" || resolved.orgRole === "admin"
? "allowed"
: "denied",
origin: resolved.origin,
},
).pipe(
Effect.provide(options.stackLayer, { local: true }),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/api/src/server/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export const makeExecutionStack = <
/** Workspace-settings permission for this binding (see
* `ExecutorConfig.orgWrites`), derived from the acting member's role. */
readonly orgWrites?: ExecutorConfig<TPlugins>["orgWrites"];
/** How the acting account reached the executor (see `makeScopedExecutor`). */
readonly origin?: "direct" | "service";
},
): Effect.Effect<
{ readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/core/api/src/server/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
makeHostedHttpClientLayer,
touchSubject,
type HostedInternalHosts,
type InternalOrigin,
} from "@executor-js/sdk/host-internal";

import { DbProvider } from "./executor-fuma-db";
Expand Down Expand Up @@ -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<TPlugins>["orgWrites"];
/** How the acting account reached the executor, stamped on internal-host
* calls. Omitted -> `service`. */
readonly origin?: InternalOrigin;
},
): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/host-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
type HostedHttpClientOptions,
type HostedInternalFetcher,
type HostedInternalHosts,
type InternalOrigin,
} from "./hosted-http-client";

export {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/sdk/src/hosted-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> = [];
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 = {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/sdk/src/hosted-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface HostedInternalFetcher {
/** Exact lowercase hostname to the fetcher that serves it, e.g. `tools.internal`. */
export type HostedInternalHosts = Readonly<Record<string, HostedInternalFetcher>>;

/** `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
Expand All @@ -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;
Expand Down Expand Up @@ -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 = (
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ interface McpSessionInitBase {
* `/mcp/toolkits/<slug>` 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. */
Expand Down Expand Up @@ -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`.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/hosts/mcp/src/seams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading