History

feat(cli): support env-backed server headers (#1539)

brosand committed 3 months ago

Commit 01663af1 parent 8ba64f610 files · +538 −43Browse files

Unified · Split

10 files
  1. apps/cli/src/device-login.test.ts +118 −2
  2. apps/cli/src/device-login.ts +57 −8
  3. apps/cli/src/main.ts +100 −32
  4. apps/cli/src/server-profile.test.ts +15 −0
  5. apps/cli/src/server-profile.ts +6 −0
  6. e2e/local/server-profile-header-env.test.ts +109 −0
  7. packages/core/sdk/src/index.ts +5 −0
  8. packages/core/sdk/src/server-connection.test.ts +54 −0
  9. packages/core/sdk/src/server-connection.ts +69 −1
  10. packages/core/sdk/src/shared.ts +5 −0
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { afterEach, describe, expect, it } from "@effect/vitest";
22
3−import { browserOpenCommand } from "./device-login";
3+import {
4+ browserOpenCommand,
5+ discoverCliLogin,
6+ refreshDeviceTokens,
7+ requestDeviceCode,
8+ type CliLoginDiscovery,
9+} from "./device-login";
10+
11+const originalFetch = globalThis.fetch;
12+
13+interface FetchCall {
14+ readonly url: string;
15+ readonly headers: Record<string, string>;
16+}
17+
18+const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19+ new Response(JSON.stringify(body), {
20+ status,
21+ headers: { "content-type": "application/json" },
22+ });
23+
24+const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26+ const url =
27+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28+ return Promise.resolve(handler(url, init));
29+ }) as typeof fetch;
30+};
31+
32+const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33+ calls.push({
34+ url,
35+ headers: Object.fromEntries(new Headers(init?.headers).entries()),
36+ });
37+};
38+
39+afterEach(() => {
40+ globalThis.fetch = originalFetch;
41+});
442
543 describe("browserOpenCommand", () => {
644 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
3270 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
3371 });
3472 });
73+
74+describe("device login headers", () => {
75+ it("sends configured headers when discovering CLI login", async () => {
76+ const calls: Array<FetchCall> = [];
77+ installFetch((url, init) => {
78+ recordCall(calls, url, init);
79+ return responseJson({
80+ provider: "better-auth",
81+ deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82+ tokenEndpoint: "https://executor.example/api/auth/device/token",
83+ clientId: "executor-cli",
84+ requestFormat: "json",
85+ });
86+ });
87+
88+ const discovery = await discoverCliLogin("https://executor.example", {
89+ headers: { "CF-Access-Client-Id": "client-id" },
90+ });
91+
92+ expect(discovery.clientId).toBe("executor-cli");
93+ expect(calls).toHaveLength(1);
94+ expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95+ expect(calls[0]?.headers).toMatchObject({
96+ accept: "application/json",
97+ "cf-access-client-id": "client-id",
98+ });
99+ });
100+
101+ it("sends configured headers only to same-origin device endpoints", async () => {
102+ const calls: Array<FetchCall> = [];
103+ installFetch((url, init) => {
104+ recordCall(calls, url, init);
105+ if (url.endsWith("/api/auth/device/code")) {
106+ return responseJson({
107+ device_code: "device-code",
108+ user_code: "USER-CODE",
109+ verification_uri: "https://executor.example/device",
110+ expires_in: 300,
111+ interval: 5,
112+ });
113+ }
114+ return responseJson({
115+ access_token: "access-token",
116+ refresh_token: "refresh-token-2",
117+ expires_in: 600,
118+ });
119+ });
120+ const discovery: CliLoginDiscovery = {
121+ provider: "better-auth",
122+ deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123+ tokenEndpoint: "https://accounts.example/oauth/token",
124+ clientId: "executor-cli",
125+ requestFormat: "form",
126+ };
127+ const headers = { "CF-Access-Client-Id": "client-id" };
128+
129+ await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130+ await refreshDeviceTokens({
131+ tokenEndpoint: discovery.tokenEndpoint,
132+ clientId: discovery.clientId,
133+ refreshToken: "refresh-token",
134+ serverOrigin: "https://executor.example",
135+ headers,
136+ });
137+
138+ expect(calls).toHaveLength(2);
139+ expect(calls[0]?.headers).toMatchObject({
140+ accept: "application/json",
141+ "content-type": "application/x-www-form-urlencoded",
142+ "cf-access-client-id": "client-id",
143+ });
144+ expect(calls[1]?.headers).toMatchObject({
145+ accept: "application/json",
146+ "content-type": "application/x-www-form-urlencoded",
147+ });
148+ expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149+ });
150+});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
5050 readonly organizationId?: string;
5151 }
5252
53+export interface DeviceLoginHttpOptions {
54+ readonly headers?: Readonly<Record<string, string>>;
55+ readonly serverOrigin?: string;
56+}
57+
58+export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59+ readonly now?: () => number;
60+}
61+
5362 const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5463 const DEFAULT_INTERVAL_SECONDS = 5;
5564
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126135 string
127136 >;
128137
138+const isSameOrigin = (url: string, origin: string): boolean => {
139+ try {
140+ return new URL(url).origin === new URL(origin).origin;
141+ } catch {
142+ return false;
143+ }
144+};
145+
146+const headersForUrl = (
147+ url: string,
148+ baseHeaders: Record<string, string>,
149+ options: DeviceLoginHttpOptions = {},
150+): Record<string, string> => {
151+ const configured =
152+ options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153+ ? options.headers
154+ : {};
155+ return { ...configured, ...baseHeaders };
156+};
157+
129158 const post = async (
130159 url: string,
131160 fields: Record<string, string | undefined>,
132161 format: "form" | "json",
162+ options: DeviceLoginHttpOptions = {},
133163 ) =>
134164 fetch(url, {
135165 method: "POST",
136− headers: {
137− "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",
138− accept: "application/json",
139− },
166+ headers: headersForUrl(
167+ url,
168+ {
169+ "content-type":
170+ format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171+ accept: "application/json",
172+ },
173+ options,
174+ ),
140175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141176 });
142177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150185 }
151186 };
152187
153−export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {
188+export const discoverCliLogin = async (
189+ origin: string,
190+ options: DeviceLoginHttpOptions = {},
191+): Promise<CliLoginDiscovery> => {
154192 let response: Response;
193+ const url = cliLoginUrl(origin);
155194 try {
156− response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });
195+ response = await fetch(url, {
196+ headers: headersForUrl(url, { accept: "application/json" }, options),
197+ });
157198 } catch (cause) {
158199 throw new DeviceLoginError(
159200 `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<CliLoginDiscover · 22 unmodified lines
182223 };
183224 };
184225
185−export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {
226+export const requestDeviceCode = async (
227+ discovery: CliLoginDiscovery,
228+ options: DeviceLoginHttpOptions = {},
229+): Promise<DeviceCodeGrant> => {
186230 const response = await post(
187231 discovery.deviceAuthorizationEndpoint,
188232 { client_id: discovery.clientId, scope: discovery.scope },
189233 discovery.requestFormat,
234+ options,
190235 );
191236 const body = await readJson(response);
192237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220265 export const pollForDeviceTokens = async (
221266 discovery: CliLoginDiscovery,
222267 grant: DeviceCodeGrant,
223− options: { readonly now?: () => number } = {},
268+ options: PollForDeviceTokensOptions = {},
224269 ): Promise<DeviceTokens> => {
225270 const now = options.now ?? (() => Date.now());
226271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240285 client_id: discovery.clientId,
241286 },
242287 discovery.requestFormat,
288+ options,
243289 );
244290 const body = await readJson(response);
245291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286332 readonly tokenEndpoint: string;
287333 readonly clientId: string;
288334 readonly refreshToken: string;
335+ readonly headers?: Readonly<Record<string, string>>;
336+ readonly serverOrigin?: string;
289337 }): Promise<DeviceTokens> => {
290338 const response = await post(
291339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295343 client_id: input.clientId,
296344 },
297345 "form",
346+ input,
298347 );
299348 const body = await readJson(response);
300349 if (!response.ok) {
apps/cli/src/main.ts+100−32
@@ -84,10 +84,13 @@ import { · 83 unmodified lines
8484 getExecutorServerAuthorizationHeader,
8585 normalizeExecutorServerConnection,
8686 normalizeExecutorServerOrigin,
87+ resolveExecutorServerConfiguredHeaders,
88+ resolveExecutorServerRequestHeaders,
8789 type ExecutorLocalServerKind,
8890 type ExecutorLocalServerManifest,
8991 type ExecutorServerConnection,
9092 type ExecutorServerConnectionInput,
93+ type ExecutorServerHeaders,
9194 } from "@executor-js/sdk/shared";
9295 import {
9396 decodeAccessTokenClaims,
@@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines
752755
753756 const refreshOAuthConnection = (
754757 connection: ExecutorServerConnection,
755−): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
758+): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
756759 Effect.gen(function* () {
757760 const auth = connection.auth;
758761 if (!auth || auth.kind !== "oauth") return connection;
@@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
763766 const { refreshToken, tokenEndpoint, clientId } = auth;
764767 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
765768
769+ const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
770+ Effect.mapError(toError),
771+ );
772+
766773 const refreshed = yield* Effect.tryPromise({
767− try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),
774+ try: () =>
775+ refreshDeviceTokens({
776+ tokenEndpoint,
777+ clientId,
778+ refreshToken,
779+ serverOrigin: connection.origin,
780+ headers,
781+ }),
768782 catch: toError,
769783 // On a failed refresh, keep the existing token and let the eventual 401
770784 // surface, better than blocking the command on a transient hiccup.
@@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines
10541068 // Typed API client
10551069 // ---------------------------------------------------------------------------
10561070
1057−const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {
1058− const authorization = getExecutorServerAuthorizationHeader(connection);
1059− return HttpApiClient.make(ExecutorApi, {
1060− baseUrl: connection.apiBaseUrl,
1061− ...(authorization
1062− ? {
1063− transformClient: HttpClient.mapRequest((request) =>
1064− HttpClientRequest.setHeader(request, "authorization", authorization),
1065− ),
1066− }
1067− : {}),
1068− // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1069− // the transport-level error into the login hint. Without this the client
1070− // fails decoding the unexpected status and prints the opaque
1071− // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1072− // decode before this catch and pass through untouched.
1073− transformResponse: (effect) =>
1074− Effect.catchIf(
1075− effect,
1076− (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1077− () =>
1078− Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))),
1079− ),
1071+const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1072+ Effect.gen(function* () {
1073+ const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1074+ Effect.mapError(toError),
1075+ );
1076+ return yield* HttpApiClient.make(ExecutorApi, {
1077+ baseUrl: connection.apiBaseUrl,
1078+ ...(Object.keys(headers).length > 0
1079+ ? {
1080+ transformClient: HttpClient.mapRequest((request) =>
1081+ HttpClientRequest.setHeaders(request, headers),
1082+ ),
1083+ }
1084+ : {}),
1085+ // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1086+ // the transport-level error into the login hint. Without this the client
1087+ // fails decoding the unexpected status and prints the opaque
1088+ // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1089+ // decode before this catch and pass through untouched.
1090+ transformResponse: (effect) =>
1091+ Effect.catchIf(
1092+ effect,
1093+ (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1094+ () =>
1095+ Effect.fail(
1096+ new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })),
1097+ ),
1098+ ),
1099+ });
10801100 }).pipe(Effect.provide(FetchHttpClient.layer));
1081−};
10821101
10831102 // ---------------------------------------------------------------------------
10841103 // Foreground session
@@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines
21772196 Command.withDescription("Discover available tools and integrations"),
21782197 );
21792198
2199+const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2200+const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2201+
2202+const parseHeaderEnvOption = (
2203+ headerEnv: Option.Option<Record<string, string>>,
2204+): ExecutorServerHeaders | undefined => {
2205+ const values = Option.getOrUndefined(headerEnv);
2206+ if (!values) return undefined;
2207+ const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {};
2208+ for (const [rawHeaderName, rawEnvName] of Object.entries(values)) {
2209+ const headerName = rawHeaderName.trim();
2210+ const envName = rawEnvName.trim();
2211+ if (!HEADER_NAME_PATTERN.test(headerName)) {
2212+ throw new Error(
2213+ `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`,
2214+ );
2215+ }
2216+ if (!ENV_NAME_PATTERN.test(envName)) {
2217+ throw new Error(
2218+ `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`,
2219+ );
2220+ }
2221+ headers[headerName] = { kind: "env", name: envName };
2222+ }
2223+ return Object.keys(headers).length > 0 ? headers : undefined;
2224+};
2225+
21802226 const profileConnectionInput = (input: {
21812227 readonly origin: string;
21822228 readonly displayName: Option.Option<string>;
21832229 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2230+ readonly headerEnv: Option.Option<Record<string, string>>;
21842231 }): ExecutorServerConnectionInput => {
21852232 const selectedKind = Option.getOrUndefined(input.kind);
21862233 const displayName = Option.getOrUndefined(input.displayName);
2234+ const headers = parseHeaderEnvOption(input.headerEnv);
21872235 return {
21882236 kind: selectedKind ?? "http",
21892237 origin: input.origin,
21902238 ...(displayName ? { displayName } : {}),
2239+ ...(headers ? { headers } : {}),
21912240 };
21922241 };
21932242
@@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines
22072256 origin: profile.connection.origin,
22082257 displayName: profile.connection.displayName,
22092258 auth: profile.connection.auth ? "stored-auth" : "env-auth",
2259+ headers: profile.connection.headers
2260+ ? `${Object.keys(profile.connection.headers).length} header-env`
2261+ : "no-headers",
22102262 }));
22112263 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
22122264 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
22132265
22142266 for (const row of rows) {
22152267 console.log(
2216− `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,
2268+ `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`,
22172269 );
22182270 }
22192271 });
@@ -2231,17 +2283,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
22312283 Options.optional,
22322284 Options.withDescription("Server kind. Defaults to http."),
22332285 ),
2286+ headerEnv: Options.keyValuePair("header-env").pipe(
2287+ Options.optional,
2288+ Options.withDescription(
2289+ "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.",
2290+ ),
2291+ ),
22342292 makeDefault: Options.boolean("default").pipe(
22352293 Options.withDefault(false),
22362294 Options.withDescription("Make this profile the default server."),
22372295 ),
22382296 },
2239− ({ name, origin, displayName, kind, makeDefault }) =>
2297+ ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
22402298 Effect.gen(function* () {
22412299 const profileName = validateCliServerConnectionProfileName(name);
22422300 const store = yield* upsertCliServerConnectionProfile({
22432301 name: profileName,
2244− connection: profileConnectionInput({ origin, displayName, kind }),
2302+ connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
22452303 makeDefault,
22462304 });
22472305 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines
24402498 // The target may have been picked implicitly (default profile, or the
24412499 // hosted fallback): say where the login is going before the device flow.
24422500 console.log(`Signing in to ${target.origin}`);
2501+ const targetProfile = target.profile;
2502+ const headers = targetProfile
2503+ ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe(
2504+ Effect.mapError(toError),
2505+ )
2506+ : {};
24432507 const discovery = yield* Effect.tryPromise({
2444− try: () => discoverCliLogin(target.origin),
2508+ try: () => discoverCliLogin(target.origin, { headers }),
24452509 catch: toError,
24462510 });
24472511 const grant = yield* Effect.tryPromise({
2448− try: () => requestDeviceCode(discovery),
2512+ try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
24492513 catch: toError,
24502514 });
24512515 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines
24562520 if (!noBrowser) openBrowser(verifyUrl);
24572521 console.log("Waiting for you to approve in the browser...");
24582522 const tokens = yield* Effect.tryPromise({
2459− try: () => pollForDeviceTokens(discovery, grant),
2523+ try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
24602524 catch: toError,
24612525 });
24622526
@@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines
24812545 kind: "http",
24822546 origin: target.origin,
24832547 ...(email ? { displayName: email } : {}),
2548+ ...(target.profile?.connection.headers
2549+ ? { headers: target.profile.connection.headers }
2550+ : {}),
24842551 auth: {
24852552 kind: "oauth",
24862553 accessToken: tokens.accessToken,
@@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines
25432610 kind: profile.connection.kind,
25442611 origin: profile.connection.origin,
25452612 displayName: profile.connection.displayName,
2613+ ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
25462614 },
25472615 makeDefault: store.defaultProfile === profile.name,
25482616 });
apps/cli/src/server-profile.test.ts+15−0
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines
3636 connection: {
3737 origin: "https://executor.example/api",
3838 auth: { kind: "bearer", token: "key_123" },
39+ headers: {
40+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41+ },
3942 },
4043 makeDefault: true,
4144 });
@@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
5053 kind: "bearer",
5154 token: "key_123",
5255 });
56+ expect(store.profiles[0]?.connection.headers).toEqual({
57+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58+ });
5359 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
5460 } finally {
5561 rmSync(dataDir, { recursive: true, force: true });
@@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines
114120 key: "desktop-sidecar",
115121 origin: "http://127.0.0.1:4789",
116122 auth: { kind: "basic", username: "executor", password: "secret" },
123+ headers: {
124+ "CF-Access-Client-Id": {
125+ kind: "env",
126+ name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
127+ },
128+ },
117129 },
118130 },
119131 ],
@@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127139 username: "executor",
128140 password: "secret",
129141 });
142+ expect(store.profiles[0]?.connection.headers).toEqual({
143+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
144+ });
130145 });
131146 });
apps/cli/src/server-profile.ts+6−0
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
6262 }),
6363 ]);
6464
65+const PersistedHeader = Schema.Struct({
66+ kind: Schema.Literal("env"),
67+ name: Schema.String,
68+});
69+
6570 const PersistedConnection = Schema.Struct({
6671 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
6772 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
6974 apiBaseUrl: Schema.optional(Schema.String),
7075 displayName: Schema.optional(Schema.String),
7176 auth: Schema.optional(PersistedAuth),
77+ headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
7278 });
7379
7480 const PersistedProfile = Schema.Struct({
e2e/local/server-profile-header-env.test.ts+109−0
@@ -0,0 +1,109 @@
1+// Local-only: server profiles can carry HTTP headers backed by environment
2+// variables. This is the CLI surface needed for Cloudflare Access service
3+// tokens on self-hosted Executor deployments.
4+import { execFile } from "node:child_process";
5+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6+import { tmpdir } from "node:os";
7+import { join } from "node:path";
8+import { fileURLToPath } from "node:url";
9+import { promisify } from "node:util";
10+
11+import { expect } from "@effect/vitest";
12+import { Effect } from "effect";
13+
14+import { scenario } from "../src/scenario";
15+
16+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
17+const CLI_ENTRY = "apps/cli/src/main.ts";
18+const execFileAsync = promisify(execFile);
19+
20+const runCli = async (
21+ dataDir: string,
22+ args: ReadonlyArray<string>,
23+ env: Record<string, string> = {},
24+): Promise<string> => {
25+ try {
26+ const { stdout } = await execFileAsync("bun", ["run", CLI_ENTRY, ...args], {
27+ cwd: repoRoot,
28+ env: {
29+ ...process.env,
30+ EXECUTOR_DATA_DIR: dataDir,
31+ EXECUTOR_DISABLE_UPDATE_CHECK: "1",
32+ ...env,
33+ },
34+ maxBuffer: 1024 * 1024,
35+ timeout: 60_000,
36+ });
37+ return stdout;
38+ } catch (cause) {
39+ const error = cause as {
40+ readonly code?: number | string;
41+ readonly stdout?: string;
42+ readonly stderr?: string;
43+ };
44+ throw new Error(
45+ `executor ${args.join(" ")} failed with ${error.code ?? "unknown"}\n${error.stdout ?? ""}\n${error.stderr ?? ""}`,
46+ );
47+ }
48+};
49+
50+scenario(
51+ "CLI server profiles · header env mappings are persisted without secret values",
52+ {},
53+ Effect.promise(async () => {
54+ const dataDir = mkdtempSync(join(tmpdir(), "executor-profile-headers-"));
55+ try {
56+ await runCli(dataDir, [
57+ "server",
58+ "add",
59+ "cloudflare",
60+ "https://executor.example",
61+ "--header-env",
62+ "CF-Access-Client-Id=EXECUTOR_CF_ACCESS_CLIENT_ID",
63+ "--header-env",
64+ "CF-Access-Client-Secret=EXECUTOR_CF_ACCESS_CLIENT_SECRET",
65+ "--default",
66+ ]);
67+
68+ const listing = await runCli(dataDir, ["server", "list"], {
69+ EXECUTOR_CF_ACCESS_CLIENT_ID: "dummy-client-id-secret-value",
70+ EXECUTOR_CF_ACCESS_CLIENT_SECRET: "dummy-client-secret-value",
71+ });
72+ expect(listing, "the profile lists its env-backed headers").toContain("2 header-env");
73+ expect(listing, "listing does not print the client id secret").not.toContain(
74+ "dummy-client-id-secret-value",
75+ );
76+ expect(listing, "listing does not print the client secret").not.toContain(
77+ "dummy-client-secret-value",
78+ );
79+
80+ const raw = readFileSync(join(dataDir, "server-connections.json"), "utf8");
81+ const store = JSON.parse(raw) as {
82+ readonly profiles: ReadonlyArray<{
83+ readonly connection: {
84+ readonly headers?: Record<string, { readonly kind: string; readonly name: string }>;
85+ };
86+ }>;
87+ };
88+ expect(store.profiles[0]?.connection.headers).toEqual({
89+ "CF-Access-Client-Id": {
90+ kind: "env",
91+ name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
92+ },
93+ "CF-Access-Client-Secret": {
94+ kind: "env",
95+ name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET",
96+ },
97+ });
98+ expect(raw, "profile storage keeps env names").toContain("EXECUTOR_CF_ACCESS_CLIENT_ID");
99+ expect(raw, "profile storage does not keep resolved client id").not.toContain(
100+ "dummy-client-id-secret-value",
101+ );
102+ expect(raw, "profile storage does not keep resolved client secret").not.toContain(
103+ "dummy-client-secret-value",
104+ );
105+ } finally {
106+ rmSync(dataDir, { recursive: true, force: true });
107+ }
108+ }),
109+);
packages/core/sdk/src/index.ts+5−0
@@ -314,15 +314,20 @@ export { · 313 unmodified lines
314314 DEFAULT_EXECUTOR_SERVER_ORIGIN,
315315 DEFAULT_EXECUTOR_SERVER_USERNAME,
316316 EXECUTOR_ORG_SELECTOR_HEADER,
317+ ExecutorServerHeaderResolutionError,
317318 apiBaseUrlForServerOrigin,
318319 getExecutorServerAuthorizationHeader,
319320 normalizeExecutorServerConnection,
320321 normalizeExecutorServerOrigin,
321322 originFromApiBaseUrl,
323+ resolveExecutorServerConfiguredHeaders,
324+ resolveExecutorServerRequestHeaders,
322325 type ExecutorServerAuth,
323326 type ExecutorServerConnection,
324327 type ExecutorServerConnectionInput,
325328 type ExecutorServerConnectionKind,
329+ type ExecutorServerHeaders,
330+ type ExecutorServerHeaderValue,
326331 } from "./server-connection";
327332
328333 export {
packages/core/sdk/src/server-connection.test.ts+54−0
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
23
34 import {
45 apiBaseUrlForServerOrigin,
@@ -7,6 +8,8 @@ import { · 2 unmodified lines
78 normalizeExecutorServerOrigin,
89 originFromApiBaseUrl,
910 parseExecutorLocalServerManifest,
11+ resolveExecutorServerConfiguredHeaders,
12+ resolveExecutorServerRequestHeaders,
1013 serializeExecutorLocalServerManifest,
1114 } from "./server-connection";
1215
@@ -62,6 +65,54 @@ describe("Executor server connection", () => { · 49 unmodified lines
6265 ).toBe("Bearer remote-token");
6366 });
6467
68+ it("normalizes env-backed server headers", () => {
69+ const connection = normalizeExecutorServerConnection({
70+ origin: "https://executor.example",
71+ headers: {
72+ " CF-Access-Client-Id ": { kind: "env", name: " EXECUTOR_CF_ACCESS_CLIENT_ID " },
73+ "CF-Access-Client-Secret": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET" },
74+ },
75+ });
76+
77+ expect(connection.headers).toEqual({
78+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
79+ "CF-Access-Client-Secret": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET" },
80+ });
81+ });
82+
83+ it("resolves configured request headers from env without storing values", () => {
84+ const connection = normalizeExecutorServerConnection({
85+ origin: "https://executor.example",
86+ auth: { kind: "bearer", token: "api-token" },
87+ headers: {
88+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
89+ },
90+ });
91+
92+ expect(
93+ Effect.runSync(
94+ resolveExecutorServerConfiguredHeaders(connection, {
95+ EXECUTOR_CF_ACCESS_CLIENT_ID: "client-id",
96+ }),
97+ ),
98+ ).toEqual({
99+ "CF-Access-Client-Id": "client-id",
100+ });
101+ expect(
102+ Effect.runSync(
103+ resolveExecutorServerRequestHeaders(connection, {
104+ EXECUTOR_CF_ACCESS_CLIENT_ID: "client-id",
105+ }),
106+ ),
107+ ).toEqual({
108+ "CF-Access-Client-Id": "client-id",
109+ authorization: "Bearer api-token",
110+ });
111+ expect(() => Effect.runSync(resolveExecutorServerConfiguredHeaders(connection, {}))).toThrow(
112+ 'Server profile header "CF-Access-Client-Id" references unset environment variable "EXECUTOR_CF_ACCESS_CLIENT_ID".',
113+ );
114+ });
115+
65116 it("round-trips local server owner manifests", () => {
66117 const manifest = {
67118 version: 1 as const,
@@ -75,6 +126,9 @@ describe("Executor server connection", () => { · 7 unmodified lines
75126 key: "desktop-sidecar",
76127 origin: "http://127.0.0.1:4789",
77128 auth: { kind: "basic", username: "executor", password: "secret" },
129+ headers: {
130+ "CF-Access-Client-Id": { kind: "env" as const, name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
131+ },
78132 }),
79133 owner: {
80134 client: "desktop" as const,
packages/core/sdk/src/server-connection.ts+69−1
@@ -1,4 +1,4 @@
1−import { Option, Schema } from "effect";
1+import { Data, Effect, Option, Schema } from "effect";
22
33 export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";
44 export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";
@@ -6,6 +6,22 @@ export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; · 1 unmodified line
66
77 export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";
88 export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";
9+export type ExecutorServerHeaderValue = {
10+ readonly kind: "env";
11+ readonly name: string;
12+};
13+export type ExecutorServerHeaders = Readonly<Record<string, ExecutorServerHeaderValue>>;
14+
15+export class ExecutorServerHeaderResolutionError extends Data.TaggedError(
16+ "ExecutorServerHeaderResolutionError",
17+)<{
18+ readonly headerName: string;
19+ readonly envName: string;
20+}> {
21+ override get message(): string {
22+ return `Server profile header "${this.headerName}" references unset environment variable "${this.envName}".`;
23+ }
24+}
925
1026 export type ExecutorServerAuth =
1127 | {
@@ -37,6 +53,7 @@ export interface ExecutorServerConnection { · 25 unmodified lines
3753 readonly apiBaseUrl: string;
3854 readonly displayName: string;
3955 readonly auth?: ExecutorServerAuth;
56+ readonly headers?: ExecutorServerHeaders;
4057 }
4158
4259 export interface ExecutorServerConnectionInput {
@@ -46,6 +63,7 @@ export interface ExecutorServerConnectionInput { · 3 unmodified lines
4663 readonly apiBaseUrl?: string;
4764 readonly displayName?: string;
4865 readonly auth?: ExecutorServerAuth;
66+ readonly headers?: ExecutorServerHeaders;
4967 }
5068
5169 export interface ExecutorLocalServerManifest {
@@ -68,6 +86,19 @@ const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); · 16 unmodified lines
6886 const displayNameFromOrigin = (origin: string): string =>
6987 origin.replace(/^https?:\/\//, "").replace(/\/+$/, "");
7088
89+const normalizeExecutorServerHeaders = (
90+ headers: ExecutorServerHeaders | undefined,
91+): ExecutorServerHeaders | undefined => {
92+ if (!headers) return undefined;
93+ const entries = Object.entries(headers).flatMap(([rawHeaderName, value]) => {
94+ const headerName = rawHeaderName.trim();
95+ const envName = value.name.trim();
96+ if (!headerName || !envName) return [];
97+ return [[headerName, { kind: "env" as const, name: envName }] as const];
98+ });
99+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
100+};
101+
71102 export const normalizeExecutorServerOrigin = (raw: string): string => {
72103 const trimmed = stripTrailingSlash(raw.trim());
73104 if (!trimmed) return DEFAULT_EXECUTOR_SERVER_ORIGIN;
@@ -102,6 +133,7 @@ export const normalizeExecutorServerConnection = ( · 28 unmodified lines
102133 );
103134 const apiBaseUrl = stripTrailingSlash(input.apiBaseUrl ?? apiBaseUrlForServerOrigin(origin));
104135 const kind = input.kind ?? "http";
136+ const headers = normalizeExecutorServerHeaders(input.headers);
105137
106138 return {
107139 kind,
@@ -110,6 +142,7 @@ export const normalizeExecutorServerConnection = ( · 2 unmodified lines
110142 apiBaseUrl,
111143 displayName: input.displayName ?? displayNameFromOrigin(origin),
112144 ...(input.auth ? { auth: input.auth } : {}),
145+ ...(headers ? { headers } : {}),
113146 };
114147 };
115148
@@ -145,6 +178,35 @@ export const getExecutorServerAuthorizationHeader = ( · 29 unmodified lines
145178 return encoded ? `Basic ${encoded}` : null;
146179 };
147180
181+export const resolveExecutorServerConfiguredHeaders = (
182+ connection: ExecutorServerConnection,
183+ env: Readonly<Record<string, string | undefined>>,
184+): Effect.Effect<Record<string, string>, ExecutorServerHeaderResolutionError> =>
185+ Effect.gen(function* () {
186+ const resolved: Record<string, string> = {};
187+ for (const [headerName, value] of Object.entries(connection.headers ?? {})) {
188+ if (value.kind !== "env") continue;
189+ const headerValue = env[value.name];
190+ if (headerValue === undefined || headerValue.length === 0) {
191+ return yield* new ExecutorServerHeaderResolutionError({ headerName, envName: value.name });
192+ }
193+ resolved[headerName] = headerValue;
194+ }
195+ return resolved;
196+ });
197+
198+export const resolveExecutorServerRequestHeaders = (
199+ connection: ExecutorServerConnection,
200+ env: Readonly<Record<string, string | undefined>>,
201+): Effect.Effect<Record<string, string>, ExecutorServerHeaderResolutionError> =>
202+ resolveExecutorServerConfiguredHeaders(connection, env).pipe(
203+ Effect.map((headers) => {
204+ const authorization = getExecutorServerAuthorizationHeader(connection);
205+ if (!authorization) return headers;
206+ return { ...headers, authorization };
207+ }),
208+ );
209+
148210 const ExecutorServerAuthJson = Schema.Union([
149211 Schema.Struct({
150212 kind: Schema.Literal("basic"),
@@ -165,6 +227,11 @@ const ExecutorServerAuthJson = Schema.Union([ · 14 unmodified lines
165227 }),
166228 ]);
167229
230+const ExecutorServerHeaderJson = Schema.Struct({
231+ kind: Schema.Literal("env"),
232+ name: Schema.String,
233+});
234+
168235 const ExecutorServerConnectionJson = Schema.Struct({
169236 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
170237 key: Schema.optional(Schema.String),
@@ -172,6 +239,7 @@ const ExecutorServerConnectionJson = Schema.Struct({ · 1 unmodified line
172239 apiBaseUrl: Schema.optional(Schema.String),
173240 displayName: Schema.optional(Schema.String),
174241 auth: Schema.optional(ExecutorServerAuthJson),
242+ headers: Schema.optional(Schema.Record(Schema.String, ExecutorServerHeaderJson)),
175243 });
176244
177245 const ExecutorLocalServerManifestJson = Schema.Struct({
packages/core/sdk/src/shared.ts+5−0
@@ -172,17 +172,22 @@ export { · 171 unmodified lines
172172 DEFAULT_EXECUTOR_SERVER_ORIGIN,
173173 DEFAULT_EXECUTOR_SERVER_USERNAME,
174174 EXECUTOR_ORG_SELECTOR_HEADER,
175+ ExecutorServerHeaderResolutionError,
175176 apiBaseUrlForServerOrigin,
176177 getExecutorServerAuthorizationHeader,
177178 normalizeExecutorServerConnection,
178179 normalizeExecutorServerOrigin,
179180 originFromApiBaseUrl,
180181 parseExecutorLocalServerManifest,
182+ resolveExecutorServerConfiguredHeaders,
183+ resolveExecutorServerRequestHeaders,
181184 serializeExecutorLocalServerManifest,
182185 type ExecutorServerAuth,
183186 type ExecutorServerConnection,
184187 type ExecutorServerConnectionInput,
185188 type ExecutorServerConnectionKind,
189+ type ExecutorServerHeaders,
190+ type ExecutorServerHeaderValue,
186191 type ExecutorLocalServerKind,
187192 type ExecutorLocalServerManifest,
188193 } from "./server-connection";