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