diff --git a/apps/cli/src/device-login.test.ts b/apps/cli/src/device-login.test.ts index 805eb4610..94cda1a8f 100644 --- a/apps/cli/src/device-login.test.ts +++ b/apps/cli/src/device-login.test.ts @@ -1,6 +1,44 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; -import { browserOpenCommand } from "./device-login"; +import { + browserOpenCommand, + discoverCliLogin, + refreshDeviceTokens, + requestDeviceCode, + type CliLoginDiscovery, +} from "./device-login"; + +const originalFetch = globalThis.fetch; + +interface FetchCall { + readonly url: string; + readonly headers: Record; +} + +const responseJson = (body: Record, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return Promise.resolve(handler(url, init)); + }) as typeof fetch; +}; + +const recordCall = (calls: Array, url: string, init: RequestInit | undefined): void => { + calls.push({ + url, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + }); +}; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); describe("browserOpenCommand", () => { it("opens Windows browser URLs without cmd.exe", () => { @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); }); }); + +describe("device login headers", () => { + it("sends configured headers when discovering CLI login", async () => { + const calls: Array = []; + installFetch((url, init) => { + recordCall(calls, url, init); + return responseJson({ + provider: "better-auth", + deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", + tokenEndpoint: "https://executor.example/api/auth/device/token", + clientId: "executor-cli", + requestFormat: "json", + }); + }); + + const discovery = await discoverCliLogin("https://executor.example", { + headers: { "CF-Access-Client-Id": "client-id" }, + }); + + expect(discovery.clientId).toBe("executor-cli"); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login"); + expect(calls[0]?.headers).toMatchObject({ + accept: "application/json", + "cf-access-client-id": "client-id", + }); + }); + + it("sends configured headers only to same-origin device endpoints", async () => { + const calls: Array = []; + installFetch((url, init) => { + recordCall(calls, url, init); + if (url.endsWith("/api/auth/device/code")) { + return responseJson({ + device_code: "device-code", + user_code: "USER-CODE", + verification_uri: "https://executor.example/device", + expires_in: 300, + interval: 5, + }); + } + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token-2", + expires_in: 600, + }); + }); + const discovery: CliLoginDiscovery = { + provider: "better-auth", + deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", + tokenEndpoint: "https://accounts.example/oauth/token", + clientId: "executor-cli", + requestFormat: "form", + }; + const headers = { "CF-Access-Client-Id": "client-id" }; + + await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers }); + await refreshDeviceTokens({ + tokenEndpoint: discovery.tokenEndpoint, + clientId: discovery.clientId, + refreshToken: "refresh-token", + serverOrigin: "https://executor.example", + headers, + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.headers).toMatchObject({ + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + "cf-access-client-id": "client-id", + }); + expect(calls[1]?.headers).toMatchObject({ + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }); + expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/device-login.ts b/apps/cli/src/device-login.ts index 09682444c..feb735961 100644 --- a/apps/cli/src/device-login.ts +++ b/apps/cli/src/device-login.ts @@ -50,6 +50,15 @@ export interface DeviceTokens { readonly organizationId?: string; } +export interface DeviceLoginHttpOptions { + readonly headers?: Readonly>; + readonly serverOrigin?: string; +} + +export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions { + readonly now?: () => number; +} + const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const DEFAULT_INTERVAL_SECONDS = 5; @@ -126,17 +135,43 @@ const definedFields = (fields: Record): Record; +const isSameOrigin = (url: string, origin: string): boolean => { + try { + return new URL(url).origin === new URL(origin).origin; + } catch { + return false; + } +}; + +const headersForUrl = ( + url: string, + baseHeaders: Record, + options: DeviceLoginHttpOptions = {}, +): Record => { + const configured = + options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin)) + ? options.headers + : {}; + return { ...configured, ...baseHeaders }; +}; + const post = async ( url: string, fields: Record, format: "form" | "json", + options: DeviceLoginHttpOptions = {}, ) => fetch(url, { method: "POST", - headers: { - "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", - accept: "application/json", - }, + headers: headersForUrl( + url, + { + "content-type": + format === "json" ? "application/json" : "application/x-www-form-urlencoded", + accept: "application/json", + }, + options, + ), body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), }); @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise> => } }; -export const discoverCliLogin = async (origin: string): Promise => { +export const discoverCliLogin = async ( + origin: string, + options: DeviceLoginHttpOptions = {}, +): Promise => { let response: Response; + const url = cliLoginUrl(origin); try { - response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); + response = await fetch(url, { + headers: headersForUrl(url, { accept: "application/json" }, options), + }); } catch (cause) { throw new DeviceLoginError( `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise => { +export const requestDeviceCode = async ( + discovery: CliLoginDiscovery, + options: DeviceLoginHttpOptions = {}, +): Promise => { const response = await post( discovery.deviceAuthorizationEndpoint, { client_id: discovery.clientId, scope: discovery.scope }, discovery.requestFormat, + options, ); const body = await readJson(response); if (!response.ok) { @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout export const pollForDeviceTokens = async ( discovery: CliLoginDiscovery, grant: DeviceCodeGrant, - options: { readonly now?: () => number } = {}, + options: PollForDeviceTokensOptions = {}, ): Promise => { const now = options.now ?? (() => Date.now()); const deadline = now() + grant.expiresInSeconds * 1000; @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( client_id: discovery.clientId, }, discovery.requestFormat, + options, ); const body = await readJson(response); @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { readonly tokenEndpoint: string; readonly clientId: string; readonly refreshToken: string; + readonly headers?: Readonly>; + readonly serverOrigin?: string; }): Promise => { const response = await post( input.tokenEndpoint, @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { client_id: input.clientId, }, "form", + input, ); const body = await readJson(response); if (!response.ok) { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index d537a17e1..a21490f0c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -79,10 +79,13 @@ import { getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, + resolveExecutorServerConfiguredHeaders, + resolveExecutorServerRequestHeaders, type ExecutorLocalServerKind, type ExecutorLocalServerManifest, type ExecutorServerConnection, type ExecutorServerConnectionInput, + type ExecutorServerHeaders, } from "@executor-js/sdk/shared"; import { decodeAccessTokenClaims, @@ -748,7 +751,7 @@ const profileNameFromKey = (key: string): string | null => const refreshOAuthConnection = ( connection: ExecutorServerConnection, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const auth = connection.auth; if (!auth || auth.kind !== "oauth") return connection; @@ -759,8 +762,19 @@ const refreshOAuthConnection = ( const { refreshToken, tokenEndpoint, clientId } = auth; if (!refreshToken || !tokenEndpoint || !clientId) return connection; + const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( + Effect.mapError(toError), + ); + const refreshed = yield* Effect.tryPromise({ - try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), + try: () => + refreshDeviceTokens({ + tokenEndpoint, + clientId, + refreshToken, + serverOrigin: connection.origin, + headers, + }), catch: toError, // On a failed refresh, keep the existing token and let the eventual 401 // surface, better than blocking the command on a transient hiccup. @@ -1050,19 +1064,22 @@ const printExecutionOutcome = (input: { // Typed API client // --------------------------------------------------------------------------- -const makeApiClient = (connection: ExecutorServerConnection) => { - const authorization = getExecutorServerAuthorizationHeader(connection); - return HttpApiClient.make(ExecutorApi, { - baseUrl: connection.apiBaseUrl, - ...(authorization - ? { - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.setHeader(request, "authorization", authorization), - ), - } - : {}), +const makeApiClient = (connection: ExecutorServerConnection) => + Effect.gen(function* () { + const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( + Effect.mapError(toError), + ); + return yield* HttpApiClient.make(ExecutorApi, { + baseUrl: connection.apiBaseUrl, + ...(Object.keys(headers).length > 0 + ? { + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeaders(request, headers), + ), + } + : {}), + }); }).pipe(Effect.provide(FetchHttpClient.layer)); -}; // --------------------------------------------------------------------------- // Foreground session @@ -2106,17 +2123,47 @@ const toolsCommand = Command.make("tools").pipe( Command.withDescription("Discover available tools and sources"), ); +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +const parseHeaderEnvOption = ( + headerEnv: Option.Option>, +): ExecutorServerHeaders | undefined => { + const values = Option.getOrUndefined(headerEnv); + if (!values) return undefined; + const headers: Record = {}; + for (const [rawHeaderName, rawEnvName] of Object.entries(values)) { + const headerName = rawHeaderName.trim(); + const envName = rawEnvName.trim(); + if (!HEADER_NAME_PATTERN.test(headerName)) { + throw new Error( + `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`, + ); + } + if (!ENV_NAME_PATTERN.test(envName)) { + throw new Error( + `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`, + ); + } + headers[headerName] = { kind: "env", name: envName }; + } + return Object.keys(headers).length > 0 ? headers : undefined; +}; + const profileConnectionInput = (input: { readonly origin: string; readonly displayName: Option.Option; readonly kind: Option.Option<"http" | "desktop-sidecar">; + readonly headerEnv: Option.Option>; }): ExecutorServerConnectionInput => { const selectedKind = Option.getOrUndefined(input.kind); const displayName = Option.getOrUndefined(input.displayName); + const headers = parseHeaderEnvOption(input.headerEnv); return { kind: selectedKind ?? "http", origin: input.origin, ...(displayName ? { displayName } : {}), + ...(headers ? { headers } : {}), }; }; @@ -2136,13 +2183,16 @@ const printServerProfiles = () => origin: profile.connection.origin, displayName: profile.connection.displayName, auth: profile.connection.auth ? "stored-auth" : "env-auth", + headers: profile.connection.headers + ? `${Object.keys(profile.connection.headers).length} header-env` + : "no-headers", })); const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); for (const row of rows) { console.log( - `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, + `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`, ); } }); @@ -2160,17 +2210,23 @@ const serverAddCommand = Command.make( Options.optional, Options.withDescription("Server kind. Defaults to http."), ), + headerEnv: Options.keyValuePair("header-env").pipe( + Options.optional, + Options.withDescription( + "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.", + ), + ), makeDefault: Options.boolean("default").pipe( Options.withDefault(false), Options.withDescription("Make this profile the default server."), ), }, - ({ name, origin, displayName, kind, makeDefault }) => + ({ name, origin, displayName, kind, headerEnv, makeDefault }) => Effect.gen(function* () { const profileName = validateCliServerConnectionProfileName(name); const store = yield* upsertCliServerConnectionProfile({ name: profileName, - connection: profileConnectionInput({ origin, displayName, kind }), + connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), makeDefault, }); const profile = findCliServerConnectionProfile(store, profileName); @@ -2366,12 +2422,18 @@ const loginCommand = Command.make( Effect.gen(function* () { const target = yield* resolveLoginOrigin({ baseUrl, server }); const explicitName = Option.getOrUndefined(name); + const targetProfile = target.profile; + const headers = targetProfile + ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe( + Effect.mapError(toError), + ) + : {}; const discovery = yield* Effect.tryPromise({ - try: () => discoverCliLogin(target.origin), + try: () => discoverCliLogin(target.origin, { headers }), catch: toError, }); const grant = yield* Effect.tryPromise({ - try: () => requestDeviceCode(discovery), + try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), catch: toError, }); const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; @@ -2382,7 +2444,7 @@ const loginCommand = Command.make( if (!noBrowser) openBrowser(verifyUrl); console.log("Waiting for you to approve in the browser..."); const tokens = yield* Effect.tryPromise({ - try: () => pollForDeviceTokens(discovery, grant), + try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), catch: toError, }); @@ -2407,6 +2469,9 @@ const loginCommand = Command.make( kind: "http", origin: target.origin, ...(email ? { displayName: email } : {}), + ...(target.profile?.connection.headers + ? { headers: target.profile.connection.headers } + : {}), auth: { kind: "oauth", accessToken: tokens.accessToken, @@ -2451,6 +2516,7 @@ const logoutCommand = Command.make( kind: profile.connection.kind, origin: profile.connection.origin, displayName: profile.connection.displayName, + ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), }, makeDefault: store.defaultProfile === profile.name, }); diff --git a/apps/cli/src/server-profile.test.ts b/apps/cli/src/server-profile.test.ts index 465aa75fe..2c12ea27a 100644 --- a/apps/cli/src/server-profile.test.ts +++ b/apps/cli/src/server-profile.test.ts @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { connection: { origin: "https://executor.example/api", auth: { kind: "bearer", token: "key_123" }, + headers: { + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }, }, makeDefault: true, }); @@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { kind: "bearer", token: "key_123", }); + expect(store.profiles[0]?.connection.headers).toEqual({ + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }); expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); } finally { rmSync(dataDir, { recursive: true, force: true }); @@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { key: "desktop-sidecar", origin: "http://127.0.0.1:4789", auth: { kind: "basic", username: "executor", password: "secret" }, + headers: { + "CF-Access-Client-Id": { + kind: "env", + name: "EXECUTOR_CF_ACCESS_CLIENT_ID", + }, + }, }, }, ], @@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { username: "executor", password: "secret", }); + expect(store.profiles[0]?.connection.headers).toEqual({ + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }); }); }); diff --git a/apps/cli/src/server-profile.ts b/apps/cli/src/server-profile.ts index dd8df3085..27e4d3d15 100644 --- a/apps/cli/src/server-profile.ts +++ b/apps/cli/src/server-profile.ts @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ }), ]); +const PersistedHeader = Schema.Struct({ + kind: Schema.Literal("env"), + name: Schema.String, +}); + const PersistedConnection = Schema.Struct({ kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), key: Schema.optional(Schema.String), @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ apiBaseUrl: Schema.optional(Schema.String), displayName: Schema.optional(Schema.String), auth: Schema.optional(PersistedAuth), + headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), }); const PersistedProfile = Schema.Struct({ diff --git a/e2e/local/server-profile-header-env.test.ts b/e2e/local/server-profile-header-env.test.ts new file mode 100644 index 000000000..3bc768c8c --- /dev/null +++ b/e2e/local/server-profile-header-env.test.ts @@ -0,0 +1,109 @@ +// Local-only: server profiles can carry HTTP headers backed by environment +// variables. This is the CLI surface needed for Cloudflare Access service +// tokens on self-hosted Executor deployments. +import { execFile } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const CLI_ENTRY = "apps/cli/src/main.ts"; +const execFileAsync = promisify(execFile); + +const runCli = async ( + dataDir: string, + args: ReadonlyArray, + env: Record = {}, +): Promise => { + try { + const { stdout } = await execFileAsync("bun", ["run", CLI_ENTRY, ...args], { + cwd: repoRoot, + env: { + ...process.env, + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_DISABLE_UPDATE_CHECK: "1", + ...env, + }, + maxBuffer: 1024 * 1024, + timeout: 60_000, + }); + return stdout; + } catch (cause) { + const error = cause as { + readonly code?: number | string; + readonly stdout?: string; + readonly stderr?: string; + }; + throw new Error( + `executor ${args.join(" ")} failed with ${error.code ?? "unknown"}\n${error.stdout ?? ""}\n${error.stderr ?? ""}`, + ); + } +}; + +scenario( + "CLI server profiles ยท header env mappings are persisted without secret values", + {}, + Effect.promise(async () => { + const dataDir = mkdtempSync(join(tmpdir(), "executor-profile-headers-")); + try { + await runCli(dataDir, [ + "server", + "add", + "cloudflare", + "https://executor.example", + "--header-env", + "CF-Access-Client-Id=EXECUTOR_CF_ACCESS_CLIENT_ID", + "--header-env", + "CF-Access-Client-Secret=EXECUTOR_CF_ACCESS_CLIENT_SECRET", + "--default", + ]); + + const listing = await runCli(dataDir, ["server", "list"], { + EXECUTOR_CF_ACCESS_CLIENT_ID: "dummy-client-id-secret-value", + EXECUTOR_CF_ACCESS_CLIENT_SECRET: "dummy-client-secret-value", + }); + expect(listing, "the profile lists its env-backed headers").toContain("2 header-env"); + expect(listing, "listing does not print the client id secret").not.toContain( + "dummy-client-id-secret-value", + ); + expect(listing, "listing does not print the client secret").not.toContain( + "dummy-client-secret-value", + ); + + const raw = readFileSync(join(dataDir, "server-connections.json"), "utf8"); + const store = JSON.parse(raw) as { + readonly profiles: ReadonlyArray<{ + readonly connection: { + readonly headers?: Record; + }; + }>; + }; + expect(store.profiles[0]?.connection.headers).toEqual({ + "CF-Access-Client-Id": { + kind: "env", + name: "EXECUTOR_CF_ACCESS_CLIENT_ID", + }, + "CF-Access-Client-Secret": { + kind: "env", + name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET", + }, + }); + expect(raw, "profile storage keeps env names").toContain("EXECUTOR_CF_ACCESS_CLIENT_ID"); + expect(raw, "profile storage does not keep resolved client id").not.toContain( + "dummy-client-id-secret-value", + ); + expect(raw, "profile storage does not keep resolved client secret").not.toContain( + "dummy-client-secret-value", + ); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }), +); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index fc11fb9bf..ec6625596 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -271,15 +271,20 @@ export { DEFAULT_EXECUTOR_SERVER_ORIGIN, DEFAULT_EXECUTOR_SERVER_USERNAME, EXECUTOR_ORG_SELECTOR_HEADER, + ExecutorServerHeaderResolutionError, apiBaseUrlForServerOrigin, getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, originFromApiBaseUrl, + resolveExecutorServerConfiguredHeaders, + resolveExecutorServerRequestHeaders, type ExecutorServerAuth, type ExecutorServerConnection, type ExecutorServerConnectionInput, type ExecutorServerConnectionKind, + type ExecutorServerHeaders, + type ExecutorServerHeaderValue, } from "./server-connection"; export { diff --git a/packages/core/sdk/src/server-connection.test.ts b/packages/core/sdk/src/server-connection.test.ts index 8b26049d2..7f000c6f8 100644 --- a/packages/core/sdk/src/server-connection.test.ts +++ b/packages/core/sdk/src/server-connection.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { apiBaseUrlForServerOrigin, @@ -7,6 +8,8 @@ import { normalizeExecutorServerOrigin, originFromApiBaseUrl, parseExecutorLocalServerManifest, + resolveExecutorServerConfiguredHeaders, + resolveExecutorServerRequestHeaders, serializeExecutorLocalServerManifest, } from "./server-connection"; @@ -62,6 +65,54 @@ describe("Executor server connection", () => { ).toBe("Bearer remote-token"); }); + it("normalizes env-backed server headers", () => { + const connection = normalizeExecutorServerConnection({ + origin: "https://executor.example", + headers: { + " CF-Access-Client-Id ": { kind: "env", name: " EXECUTOR_CF_ACCESS_CLIENT_ID " }, + "CF-Access-Client-Secret": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET" }, + }, + }); + + expect(connection.headers).toEqual({ + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + "CF-Access-Client-Secret": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET" }, + }); + }); + + it("resolves configured request headers from env without storing values", () => { + const connection = normalizeExecutorServerConnection({ + origin: "https://executor.example", + auth: { kind: "bearer", token: "api-token" }, + headers: { + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }, + }); + + expect( + Effect.runSync( + resolveExecutorServerConfiguredHeaders(connection, { + EXECUTOR_CF_ACCESS_CLIENT_ID: "client-id", + }), + ), + ).toEqual({ + "CF-Access-Client-Id": "client-id", + }); + expect( + Effect.runSync( + resolveExecutorServerRequestHeaders(connection, { + EXECUTOR_CF_ACCESS_CLIENT_ID: "client-id", + }), + ), + ).toEqual({ + "CF-Access-Client-Id": "client-id", + authorization: "Bearer api-token", + }); + expect(() => Effect.runSync(resolveExecutorServerConfiguredHeaders(connection, {}))).toThrow( + 'Server profile header "CF-Access-Client-Id" references unset environment variable "EXECUTOR_CF_ACCESS_CLIENT_ID".', + ); + }); + it("round-trips local server owner manifests", () => { const manifest = { version: 1 as const, @@ -75,6 +126,9 @@ describe("Executor server connection", () => { key: "desktop-sidecar", origin: "http://127.0.0.1:4789", auth: { kind: "basic", username: "executor", password: "secret" }, + headers: { + "CF-Access-Client-Id": { kind: "env" as const, name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }, }), owner: { client: "desktop" as const, diff --git a/packages/core/sdk/src/server-connection.ts b/packages/core/sdk/src/server-connection.ts index d733cfe44..157b8b3f0 100644 --- a/packages/core/sdk/src/server-connection.ts +++ b/packages/core/sdk/src/server-connection.ts @@ -1,4 +1,4 @@ -import { Option, Schema } from "effect"; +import { Data, Effect, Option, Schema } from "effect"; export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; @@ -6,6 +6,22 @@ export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground"; +export type ExecutorServerHeaderValue = { + readonly kind: "env"; + readonly name: string; +}; +export type ExecutorServerHeaders = Readonly>; + +export class ExecutorServerHeaderResolutionError extends Data.TaggedError( + "ExecutorServerHeaderResolutionError", +)<{ + readonly headerName: string; + readonly envName: string; +}> { + override get message(): string { + return `Server profile header "${this.headerName}" references unset environment variable "${this.envName}".`; + } +} export type ExecutorServerAuth = | { @@ -37,6 +53,7 @@ export interface ExecutorServerConnection { readonly apiBaseUrl: string; readonly displayName: string; readonly auth?: ExecutorServerAuth; + readonly headers?: ExecutorServerHeaders; } export interface ExecutorServerConnectionInput { @@ -46,6 +63,7 @@ export interface ExecutorServerConnectionInput { readonly apiBaseUrl?: string; readonly displayName?: string; readonly auth?: ExecutorServerAuth; + readonly headers?: ExecutorServerHeaders; } export interface ExecutorLocalServerManifest { @@ -68,6 +86,19 @@ const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); const displayNameFromOrigin = (origin: string): string => origin.replace(/^https?:\/\//, "").replace(/\/+$/, ""); +const normalizeExecutorServerHeaders = ( + headers: ExecutorServerHeaders | undefined, +): ExecutorServerHeaders | undefined => { + if (!headers) return undefined; + const entries = Object.entries(headers).flatMap(([rawHeaderName, value]) => { + const headerName = rawHeaderName.trim(); + const envName = value.name.trim(); + if (!headerName || !envName) return []; + return [[headerName, { kind: "env" as const, name: envName }] as const]; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +}; + export const normalizeExecutorServerOrigin = (raw: string): string => { const trimmed = stripTrailingSlash(raw.trim()); if (!trimmed) return DEFAULT_EXECUTOR_SERVER_ORIGIN; @@ -102,6 +133,7 @@ export const normalizeExecutorServerConnection = ( ); const apiBaseUrl = stripTrailingSlash(input.apiBaseUrl ?? apiBaseUrlForServerOrigin(origin)); const kind = input.kind ?? "http"; + const headers = normalizeExecutorServerHeaders(input.headers); return { kind, @@ -110,6 +142,7 @@ export const normalizeExecutorServerConnection = ( apiBaseUrl, displayName: input.displayName ?? displayNameFromOrigin(origin), ...(input.auth ? { auth: input.auth } : {}), + ...(headers ? { headers } : {}), }; }; @@ -145,6 +178,35 @@ export const getExecutorServerAuthorizationHeader = ( return encoded ? `Basic ${encoded}` : null; }; +export const resolveExecutorServerConfiguredHeaders = ( + connection: ExecutorServerConnection, + env: Readonly>, +): Effect.Effect, ExecutorServerHeaderResolutionError> => + Effect.gen(function* () { + const resolved: Record = {}; + for (const [headerName, value] of Object.entries(connection.headers ?? {})) { + if (value.kind !== "env") continue; + const headerValue = env[value.name]; + if (headerValue === undefined || headerValue.length === 0) { + return yield* new ExecutorServerHeaderResolutionError({ headerName, envName: value.name }); + } + resolved[headerName] = headerValue; + } + return resolved; + }); + +export const resolveExecutorServerRequestHeaders = ( + connection: ExecutorServerConnection, + env: Readonly>, +): Effect.Effect, ExecutorServerHeaderResolutionError> => + resolveExecutorServerConfiguredHeaders(connection, env).pipe( + Effect.map((headers) => { + const authorization = getExecutorServerAuthorizationHeader(connection); + if (!authorization) return headers; + return { ...headers, authorization }; + }), + ); + const ExecutorServerAuthJson = Schema.Union([ Schema.Struct({ kind: Schema.Literal("basic"), @@ -165,6 +227,11 @@ const ExecutorServerAuthJson = Schema.Union([ }), ]); +const ExecutorServerHeaderJson = Schema.Struct({ + kind: Schema.Literal("env"), + name: Schema.String, +}); + const ExecutorServerConnectionJson = Schema.Struct({ kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), key: Schema.optional(Schema.String), @@ -172,6 +239,7 @@ const ExecutorServerConnectionJson = Schema.Struct({ apiBaseUrl: Schema.optional(Schema.String), displayName: Schema.optional(Schema.String), auth: Schema.optional(ExecutorServerAuthJson), + headers: Schema.optional(Schema.Record(Schema.String, ExecutorServerHeaderJson)), }); const ExecutorLocalServerManifestJson = Schema.Struct({ diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 931a332e1..46774470a 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -152,17 +152,22 @@ export { DEFAULT_EXECUTOR_SERVER_ORIGIN, DEFAULT_EXECUTOR_SERVER_USERNAME, EXECUTOR_ORG_SELECTOR_HEADER, + ExecutorServerHeaderResolutionError, apiBaseUrlForServerOrigin, getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, originFromApiBaseUrl, parseExecutorLocalServerManifest, + resolveExecutorServerConfiguredHeaders, + resolveExecutorServerRequestHeaders, serializeExecutorLocalServerManifest, type ExecutorServerAuth, type ExecutorServerConnection, type ExecutorServerConnectionInput, type ExecutorServerConnectionKind, + type ExecutorServerHeaders, + type ExecutorServerHeaderValue, type ExecutorLocalServerKind, type ExecutorLocalServerManifest, } from "./server-connection";