Unified · Split
| @@ -1,6 +1,44 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | |
| 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 | +}); | |
| 4 | 42 | |
| 5 | 43 | describe("browserOpenCommand", () => { |
| 6 | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | ||
| 32 | 70 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); |
| 33 | 71 | }); |
| 34 | 72 | }); |
| 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 | +}); | |
| @@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines | ||
| 50 | 50 | readonly organizationId?: string; |
| 51 | 51 | } |
| 52 | 52 | |
| 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 | + | |
| 53 | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | 63 | const DEFAULT_INTERVAL_SECONDS = 5; |
| 55 | 64 | |
| @@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines | ||
| 126 | 135 | string |
| 127 | 136 | >; |
| 128 | 137 | |
| 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 | + | |
| 129 | 158 | const post = async ( |
| 130 | 159 | url: string, |
| 131 | 160 | fields: Record<string, string | undefined>, |
| 132 | 161 | format: "form" | "json", |
| 162 | + options: DeviceLoginHttpOptions = {}, | |
| 133 | 163 | ) => |
| 134 | 164 | fetch(url, { |
| 135 | 165 | 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 | + ), | |
| 140 | 175 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), |
| 141 | 176 | }); |
| 142 | 177 | |
| @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines | ||
| 150 | 185 | } |
| 151 | 186 | }; |
| 152 | 187 | |
| 153 | −export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => { | |
| 188 | +export const discoverCliLogin = async ( | |
| 189 | + origin: string, | |
| 190 | + options: DeviceLoginHttpOptions = {}, | |
| 191 | +): Promise<CliLoginDiscovery> => { | |
| 154 | 192 | let response: Response; |
| 193 | + const url = cliLoginUrl(origin); | |
| 155 | 194 | 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 | + }); | |
| 157 | 198 | } catch (cause) { |
| 158 | 199 | throw new DeviceLoginError( |
| 159 | 200 | `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 | ||
| 182 | 223 | }; |
| 183 | 224 | }; |
| 184 | 225 | |
| 185 | −export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => { | |
| 226 | +export const requestDeviceCode = async ( | |
| 227 | + discovery: CliLoginDiscovery, | |
| 228 | + options: DeviceLoginHttpOptions = {}, | |
| 229 | +): Promise<DeviceCodeGrant> => { | |
| 186 | 230 | const response = await post( |
| 187 | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | 233 | discovery.requestFormat, |
| 234 | + options, | |
| 190 | 235 | ); |
| 191 | 236 | const body = await readJson(response); |
| 192 | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | ||
| 220 | 265 | export const pollForDeviceTokens = async ( |
| 221 | 266 | discovery: CliLoginDiscovery, |
| 222 | 267 | grant: DeviceCodeGrant, |
| 223 | − options: { readonly now?: () => number } = {}, | |
| 268 | + options: PollForDeviceTokensOptions = {}, | |
| 224 | 269 | ): Promise<DeviceTokens> => { |
| 225 | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | ||
| 240 | 285 | client_id: discovery.clientId, |
| 241 | 286 | }, |
| 242 | 287 | discovery.requestFormat, |
| 288 | + options, | |
| 243 | 289 | ); |
| 244 | 290 | const body = await readJson(response); |
| 245 | 291 | |
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | ||
| 286 | 332 | readonly tokenEndpoint: string; |
| 287 | 333 | readonly clientId: string; |
| 288 | 334 | readonly refreshToken: string; |
| 335 | + readonly headers?: Readonly<Record<string, string>>; | |
| 336 | + readonly serverOrigin?: string; | |
| 289 | 337 | }): Promise<DeviceTokens> => { |
| 290 | 338 | const response = await post( |
| 291 | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | ||
| 295 | 343 | client_id: input.clientId, |
| 296 | 344 | }, |
| 297 | 345 | "form", |
| 346 | + input, | |
| 298 | 347 | ); |
| 299 | 348 | const body = await readJson(response); |
| 300 | 349 | if (!response.ok) { |
| @@ -84,10 +84,13 @@ import { · 83 unmodified lines | ||
| 84 | 84 | getExecutorServerAuthorizationHeader, |
| 85 | 85 | normalizeExecutorServerConnection, |
| 86 | 86 | normalizeExecutorServerOrigin, |
| 87 | + resolveExecutorServerConfiguredHeaders, | |
| 88 | + resolveExecutorServerRequestHeaders, | |
| 87 | 89 | type ExecutorLocalServerKind, |
| 88 | 90 | type ExecutorLocalServerManifest, |
| 89 | 91 | type ExecutorServerConnection, |
| 90 | 92 | type ExecutorServerConnectionInput, |
| 93 | + type ExecutorServerHeaders, | |
| 91 | 94 | } from "@executor-js/sdk/shared"; |
| 92 | 95 | import { |
| 93 | 96 | decodeAccessTokenClaims, |
| @@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines | ||
| 752 | 755 | |
| 753 | 756 | const refreshOAuthConnection = ( |
| 754 | 757 | connection: ExecutorServerConnection, |
| 755 | −): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | |
| 758 | +): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => | |
| 756 | 759 | Effect.gen(function* () { |
| 757 | 760 | const auth = connection.auth; |
| 758 | 761 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | ||
| 763 | 766 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 764 | 767 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; |
| 765 | 768 | |
| 769 | + const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( | |
| 770 | + Effect.mapError(toError), | |
| 771 | + ); | |
| 772 | + | |
| 766 | 773 | 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 | + }), | |
| 768 | 782 | catch: toError, |
| 769 | 783 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 770 | 784 | // surface, better than blocking the command on a transient hiccup. |
| @@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines | ||
| 1054 | 1068 | // Typed API client |
| 1055 | 1069 | // --------------------------------------------------------------------------- |
| 1056 | 1070 | |
| 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 | + }); | |
| 1080 | 1100 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1081 | −}; | |
| 1082 | 1101 | |
| 1083 | 1102 | // --------------------------------------------------------------------------- |
| 1084 | 1103 | // Foreground session |
| @@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines | ||
| 2177 | 2196 | Command.withDescription("Discover available tools and integrations"), |
| 2178 | 2197 | ); |
| 2179 | 2198 | |
| 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 | + | |
| 2180 | 2226 | const profileConnectionInput = (input: { |
| 2181 | 2227 | readonly origin: string; |
| 2182 | 2228 | readonly displayName: Option.Option<string>; |
| 2183 | 2229 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2230 | + readonly headerEnv: Option.Option<Record<string, string>>; | |
| 2184 | 2231 | }): ExecutorServerConnectionInput => { |
| 2185 | 2232 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2186 | 2233 | const displayName = Option.getOrUndefined(input.displayName); |
| 2234 | + const headers = parseHeaderEnvOption(input.headerEnv); | |
| 2187 | 2235 | return { |
| 2188 | 2236 | kind: selectedKind ?? "http", |
| 2189 | 2237 | origin: input.origin, |
| 2190 | 2238 | ...(displayName ? { displayName } : {}), |
| 2239 | + ...(headers ? { headers } : {}), | |
| 2191 | 2240 | }; |
| 2192 | 2241 | }; |
| 2193 | 2242 | |
| @@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines | ||
| 2207 | 2256 | origin: profile.connection.origin, |
| 2208 | 2257 | displayName: profile.connection.displayName, |
| 2209 | 2258 | 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", | |
| 2210 | 2262 | })); |
| 2211 | 2263 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2212 | 2264 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2213 | 2265 | |
| 2214 | 2266 | for (const row of rows) { |
| 2215 | 2267 | 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}`, | |
| 2217 | 2269 | ); |
| 2218 | 2270 | } |
| 2219 | 2271 | }); |
| @@ -2231,17 +2283,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines | ||
| 2231 | 2283 | Options.optional, |
| 2232 | 2284 | Options.withDescription("Server kind. Defaults to http."), |
| 2233 | 2285 | ), |
| 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 | + ), | |
| 2234 | 2292 | makeDefault: Options.boolean("default").pipe( |
| 2235 | 2293 | Options.withDefault(false), |
| 2236 | 2294 | Options.withDescription("Make this profile the default server."), |
| 2237 | 2295 | ), |
| 2238 | 2296 | }, |
| 2239 | − ({ name, origin, displayName, kind, makeDefault }) => | |
| 2297 | + ({ name, origin, displayName, kind, headerEnv, makeDefault }) => | |
| 2240 | 2298 | Effect.gen(function* () { |
| 2241 | 2299 | const profileName = validateCliServerConnectionProfileName(name); |
| 2242 | 2300 | const store = yield* upsertCliServerConnectionProfile({ |
| 2243 | 2301 | name: profileName, |
| 2244 | − connection: profileConnectionInput({ origin, displayName, kind }), | |
| 2302 | + connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), | |
| 2245 | 2303 | makeDefault, |
| 2246 | 2304 | }); |
| 2247 | 2305 | const profile = findCliServerConnectionProfile(store, profileName); |
| @@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines | ||
| 2440 | 2498 | // The target may have been picked implicitly (default profile, or the |
| 2441 | 2499 | // hosted fallback): say where the login is going before the device flow. |
| 2442 | 2500 | 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 | + : {}; | |
| 2443 | 2507 | const discovery = yield* Effect.tryPromise({ |
| 2444 | − try: () => discoverCliLogin(target.origin), | |
| 2508 | + try: () => discoverCliLogin(target.origin, { headers }), | |
| 2445 | 2509 | catch: toError, |
| 2446 | 2510 | }); |
| 2447 | 2511 | const grant = yield* Effect.tryPromise({ |
| 2448 | − try: () => requestDeviceCode(discovery), | |
| 2512 | + try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), | |
| 2449 | 2513 | catch: toError, |
| 2450 | 2514 | }); |
| 2451 | 2515 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines | ||
| 2456 | 2520 | if (!noBrowser) openBrowser(verifyUrl); |
| 2457 | 2521 | console.log("Waiting for you to approve in the browser..."); |
| 2458 | 2522 | const tokens = yield* Effect.tryPromise({ |
| 2459 | − try: () => pollForDeviceTokens(discovery, grant), | |
| 2523 | + try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), | |
| 2460 | 2524 | catch: toError, |
| 2461 | 2525 | }); |
| 2462 | 2526 | |
| @@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines | ||
| 2481 | 2545 | kind: "http", |
| 2482 | 2546 | origin: target.origin, |
| 2483 | 2547 | ...(email ? { displayName: email } : {}), |
| 2548 | + ...(target.profile?.connection.headers | |
| 2549 | + ? { headers: target.profile.connection.headers } | |
| 2550 | + : {}), | |
| 2484 | 2551 | auth: { |
| 2485 | 2552 | kind: "oauth", |
| 2486 | 2553 | accessToken: tokens.accessToken, |
| @@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines | ||
| 2543 | 2610 | kind: profile.connection.kind, |
| 2544 | 2611 | origin: profile.connection.origin, |
| 2545 | 2612 | displayName: profile.connection.displayName, |
| 2613 | + ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | |
| 2546 | 2614 | }, |
| 2547 | 2615 | makeDefault: store.defaultProfile === profile.name, |
| 2548 | 2616 | }); |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines | ||
| 36 | 36 | connection: { |
| 37 | 37 | origin: "https://executor.example/api", |
| 38 | 38 | auth: { kind: "bearer", token: "key_123" }, |
| 39 | + headers: { | |
| 40 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 41 | + }, | |
| 39 | 42 | }, |
| 40 | 43 | makeDefault: true, |
| 41 | 44 | }); |
| @@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | ||
| 50 | 53 | kind: "bearer", |
| 51 | 54 | token: "key_123", |
| 52 | 55 | }); |
| 56 | + expect(store.profiles[0]?.connection.headers).toEqual({ | |
| 57 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 58 | + }); | |
| 53 | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | 60 | } finally { |
| 55 | 61 | rmSync(dataDir, { recursive: true, force: true }); |
| @@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines | ||
| 114 | 120 | key: "desktop-sidecar", |
| 115 | 121 | origin: "http://127.0.0.1:4789", |
| 116 | 122 | 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 | + }, | |
| 117 | 129 | }, |
| 118 | 130 | }, |
| 119 | 131 | ], |
| @@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines | ||
| 127 | 139 | username: "executor", |
| 128 | 140 | password: "secret", |
| 129 | 141 | }); |
| 142 | + expect(store.profiles[0]?.connection.headers).toEqual({ | |
| 143 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 144 | + }); | |
| 130 | 145 | }); |
| 131 | 146 | }); |
| @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines | ||
| 62 | 62 | }), |
| 63 | 63 | ]); |
| 64 | 64 | |
| 65 | +const PersistedHeader = Schema.Struct({ | |
| 66 | + kind: Schema.Literal("env"), | |
| 67 | + name: Schema.String, | |
| 68 | +}); | |
| 69 | + | |
| 65 | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | ||
| 69 | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | 75 | displayName: Schema.optional(Schema.String), |
| 71 | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | + headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | |
| 72 | 78 | }); |
| 73 | 79 | |
| 74 | 80 | const PersistedProfile = Schema.Struct({ |
| @@ -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 | +); | |
| @@ -314,15 +314,20 @@ export { · 313 unmodified lines | ||
| 314 | 314 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 315 | 315 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 316 | 316 | EXECUTOR_ORG_SELECTOR_HEADER, |
| 317 | + ExecutorServerHeaderResolutionError, | |
| 317 | 318 | apiBaseUrlForServerOrigin, |
| 318 | 319 | getExecutorServerAuthorizationHeader, |
| 319 | 320 | normalizeExecutorServerConnection, |
| 320 | 321 | normalizeExecutorServerOrigin, |
| 321 | 322 | originFromApiBaseUrl, |
| 323 | + resolveExecutorServerConfiguredHeaders, | |
| 324 | + resolveExecutorServerRequestHeaders, | |
| 322 | 325 | type ExecutorServerAuth, |
| 323 | 326 | type ExecutorServerConnection, |
| 324 | 327 | type ExecutorServerConnectionInput, |
| 325 | 328 | type ExecutorServerConnectionKind, |
| 329 | + type ExecutorServerHeaders, | |
| 330 | + type ExecutorServerHeaderValue, | |
| 326 | 331 | } from "./server-connection"; |
| 327 | 332 | |
| 328 | 333 | export { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import * as Effect from "effect/Effect"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | apiBaseUrlForServerOrigin, |
| @@ -7,6 +8,8 @@ import { · 2 unmodified lines | ||
| 7 | 8 | normalizeExecutorServerOrigin, |
| 8 | 9 | originFromApiBaseUrl, |
| 9 | 10 | parseExecutorLocalServerManifest, |
| 11 | + resolveExecutorServerConfiguredHeaders, | |
| 12 | + resolveExecutorServerRequestHeaders, | |
| 10 | 13 | serializeExecutorLocalServerManifest, |
| 11 | 14 | } from "./server-connection"; |
| 12 | 15 | |
| @@ -62,6 +65,54 @@ describe("Executor server connection", () => { · 49 unmodified lines | ||
| 62 | 65 | ).toBe("Bearer remote-token"); |
| 63 | 66 | }); |
| 64 | 67 | |
| 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 | + | |
| 65 | 116 | it("round-trips local server owner manifests", () => { |
| 66 | 117 | const manifest = { |
| 67 | 118 | version: 1 as const, |
| @@ -75,6 +126,9 @@ describe("Executor server connection", () => { · 7 unmodified lines | ||
| 75 | 126 | key: "desktop-sidecar", |
| 76 | 127 | origin: "http://127.0.0.1:4789", |
| 77 | 128 | 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 | + }, | |
| 78 | 132 | }), |
| 79 | 133 | owner: { |
| 80 | 134 | client: "desktop" as const, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Option, Schema } from "effect"; | |
| 1 | +import { Data, Effect, Option, Schema } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; |
| 4 | 4 | export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; |
| @@ -6,6 +6,22 @@ export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; · 1 unmodified line | ||
| 6 | 6 | |
| 7 | 7 | export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; |
| 8 | 8 | 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 | +} | |
| 9 | 25 | |
| 10 | 26 | export type ExecutorServerAuth = |
| 11 | 27 | | { |
| @@ -37,6 +53,7 @@ export interface ExecutorServerConnection { · 25 unmodified lines | ||
| 37 | 53 | readonly apiBaseUrl: string; |
| 38 | 54 | readonly displayName: string; |
| 39 | 55 | readonly auth?: ExecutorServerAuth; |
| 56 | + readonly headers?: ExecutorServerHeaders; | |
| 40 | 57 | } |
| 41 | 58 | |
| 42 | 59 | export interface ExecutorServerConnectionInput { |
| @@ -46,6 +63,7 @@ export interface ExecutorServerConnectionInput { · 3 unmodified lines | ||
| 46 | 63 | readonly apiBaseUrl?: string; |
| 47 | 64 | readonly displayName?: string; |
| 48 | 65 | readonly auth?: ExecutorServerAuth; |
| 66 | + readonly headers?: ExecutorServerHeaders; | |
| 49 | 67 | } |
| 50 | 68 | |
| 51 | 69 | export interface ExecutorLocalServerManifest { |
| @@ -68,6 +86,19 @@ const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); · 16 unmodified lines | ||
| 68 | 86 | const displayNameFromOrigin = (origin: string): string => |
| 69 | 87 | origin.replace(/^https?:\/\//, "").replace(/\/+$/, ""); |
| 70 | 88 | |
| 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 | + | |
| 71 | 102 | export const normalizeExecutorServerOrigin = (raw: string): string => { |
| 72 | 103 | const trimmed = stripTrailingSlash(raw.trim()); |
| 73 | 104 | if (!trimmed) return DEFAULT_EXECUTOR_SERVER_ORIGIN; |
| @@ -102,6 +133,7 @@ export const normalizeExecutorServerConnection = ( · 28 unmodified lines | ||
| 102 | 133 | ); |
| 103 | 134 | const apiBaseUrl = stripTrailingSlash(input.apiBaseUrl ?? apiBaseUrlForServerOrigin(origin)); |
| 104 | 135 | const kind = input.kind ?? "http"; |
| 136 | + const headers = normalizeExecutorServerHeaders(input.headers); | |
| 105 | 137 | |
| 106 | 138 | return { |
| 107 | 139 | kind, |
| @@ -110,6 +142,7 @@ export const normalizeExecutorServerConnection = ( · 2 unmodified lines | ||
| 110 | 142 | apiBaseUrl, |
| 111 | 143 | displayName: input.displayName ?? displayNameFromOrigin(origin), |
| 112 | 144 | ...(input.auth ? { auth: input.auth } : {}), |
| 145 | + ...(headers ? { headers } : {}), | |
| 113 | 146 | }; |
| 114 | 147 | }; |
| 115 | 148 | |
| @@ -145,6 +178,35 @@ export const getExecutorServerAuthorizationHeader = ( · 29 unmodified lines | ||
| 145 | 178 | return encoded ? `Basic ${encoded}` : null; |
| 146 | 179 | }; |
| 147 | 180 | |
| 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 | + | |
| 148 | 210 | const ExecutorServerAuthJson = Schema.Union([ |
| 149 | 211 | Schema.Struct({ |
| 150 | 212 | kind: Schema.Literal("basic"), |
| @@ -165,6 +227,11 @@ const ExecutorServerAuthJson = Schema.Union([ · 14 unmodified lines | ||
| 165 | 227 | }), |
| 166 | 228 | ]); |
| 167 | 229 | |
| 230 | +const ExecutorServerHeaderJson = Schema.Struct({ | |
| 231 | + kind: Schema.Literal("env"), | |
| 232 | + name: Schema.String, | |
| 233 | +}); | |
| 234 | + | |
| 168 | 235 | const ExecutorServerConnectionJson = Schema.Struct({ |
| 169 | 236 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 170 | 237 | key: Schema.optional(Schema.String), |
| @@ -172,6 +239,7 @@ const ExecutorServerConnectionJson = Schema.Struct({ · 1 unmodified line | ||
| 172 | 239 | apiBaseUrl: Schema.optional(Schema.String), |
| 173 | 240 | displayName: Schema.optional(Schema.String), |
| 174 | 241 | auth: Schema.optional(ExecutorServerAuthJson), |
| 242 | + headers: Schema.optional(Schema.Record(Schema.String, ExecutorServerHeaderJson)), | |
| 175 | 243 | }); |
| 176 | 244 | |
| 177 | 245 | const ExecutorLocalServerManifestJson = Schema.Struct({ |
| @@ -172,17 +172,22 @@ export { · 171 unmodified lines | ||
| 172 | 172 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 173 | 173 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 174 | 174 | EXECUTOR_ORG_SELECTOR_HEADER, |
| 175 | + ExecutorServerHeaderResolutionError, | |
| 175 | 176 | apiBaseUrlForServerOrigin, |
| 176 | 177 | getExecutorServerAuthorizationHeader, |
| 177 | 178 | normalizeExecutorServerConnection, |
| 178 | 179 | normalizeExecutorServerOrigin, |
| 179 | 180 | originFromApiBaseUrl, |
| 180 | 181 | parseExecutorLocalServerManifest, |
| 182 | + resolveExecutorServerConfiguredHeaders, | |
| 183 | + resolveExecutorServerRequestHeaders, | |
| 181 | 184 | serializeExecutorLocalServerManifest, |
| 182 | 185 | type ExecutorServerAuth, |
| 183 | 186 | type ExecutorServerConnection, |
| 184 | 187 | type ExecutorServerConnectionInput, |
| 185 | 188 | type ExecutorServerConnectionKind, |
| 189 | + type ExecutorServerHeaders, | |
| 190 | + type ExecutorServerHeaderValue, | |
| 186 | 191 | type ExecutorLocalServerKind, |
| 187 | 192 | type ExecutorLocalServerManifest, |
| 188 | 193 | } from "./server-connection"; |