History

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

brosand committed 3 months ago

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

Unified · Split

10 files
  1. apps/cli/src/device-login.test.ts +118 −2
  2. apps/cli/src/device-login.ts +57 −8
  3. apps/cli/src/main.ts +100 −32
  4. apps/cli/src/server-profile.test.ts +15 −0
  5. apps/cli/src/server-profile.ts +6 −0
  6. e2e/local/server-profile-header-env.test.ts +109 −0
  7. packages/core/sdk/src/index.ts +5 −0
  8. packages/core/sdk/src/server-connection.test.ts +54 −0
  9. packages/core/sdk/src/server-connection.ts +69 −1
  10. packages/core/sdk/src/shared.ts +5 −0
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2 2
3import { browserOpenCommand } from "./device-login";3import {
4 browserOpenCommand,
5 discoverCliLogin,
6 refreshDeviceTokens,
7 requestDeviceCode,
8 type CliLoginDiscovery,
9} from "./device-login";
10
11const originalFetch = globalThis.fetch;
12
13interface FetchCall {
14 readonly url: string;
15 readonly headers: Record<string, string>;
16}
17
18const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19 new Response(JSON.stringify(body), {
20 status,
21 headers: { "content-type": "application/json" },
22 });
23
24const 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
32const 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
39afterEach(() => {
40 globalThis.fetch = originalFetch;
41});
4 42
5describe("browserOpenCommand", () => {43describe("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
74describe("device login headers", () => {
75 it("sends configured headers when discovering CLI login", async () => {
76 const calls: Array<FetchCall> = [];
77 installFetch((url, init) => {
78 recordCall(calls, url, init);
79 return responseJson({
80 provider: "better-auth",
81 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82 tokenEndpoint: "https://executor.example/api/auth/device/token",
83 clientId: "executor-cli",
84 requestFormat: "json",
85 });
86 });
87
88 const discovery = await discoverCliLogin("https://executor.example", {
89 headers: { "CF-Access-Client-Id": "client-id" },
90 });
91
92 expect(discovery.clientId).toBe("executor-cli");
93 expect(calls).toHaveLength(1);
94 expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95 expect(calls[0]?.headers).toMatchObject({
96 accept: "application/json",
97 "cf-access-client-id": "client-id",
98 });
99 });
100
101 it("sends configured headers only to same-origin device endpoints", async () => {
102 const calls: Array<FetchCall> = [];
103 installFetch((url, init) => {
104 recordCall(calls, url, init);
105 if (url.endsWith("/api/auth/device/code")) {
106 return responseJson({
107 device_code: "device-code",
108 user_code: "USER-CODE",
109 verification_uri: "https://executor.example/device",
110 expires_in: 300,
111 interval: 5,
112 });
113 }
114 return responseJson({
115 access_token: "access-token",
116 refresh_token: "refresh-token-2",
117 expires_in: 600,
118 });
119 });
120 const discovery: CliLoginDiscovery = {
121 provider: "better-auth",
122 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123 tokenEndpoint: "https://accounts.example/oauth/token",
124 clientId: "executor-cli",
125 requestFormat: "form",
126 };
127 const headers = { "CF-Access-Client-Id": "client-id" };
128
129 await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130 await refreshDeviceTokens({
131 tokenEndpoint: discovery.tokenEndpoint,
132 clientId: discovery.clientId,
133 refreshToken: "refresh-token",
134 serverOrigin: "https://executor.example",
135 headers,
136 });
137
138 expect(calls).toHaveLength(2);
139 expect(calls[0]?.headers).toMatchObject({
140 accept: "application/json",
141 "content-type": "application/x-www-form-urlencoded",
142 "cf-access-client-id": "client-id",
143 });
144 expect(calls[1]?.headers).toMatchObject({
145 accept: "application/json",
146 "content-type": "application/x-www-form-urlencoded",
147 });
148 expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149 });
150});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
50 readonly organizationId?: string;50 readonly organizationId?: string;
51}51}
52 52
53export interface DeviceLoginHttpOptions {
54 readonly headers?: Readonly<Record<string, string>>;
55 readonly serverOrigin?: string;
56}
57
58export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59 readonly now?: () => number;
60}
61
53const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";62const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL_SECONDS = 5;63const DEFAULT_INTERVAL_SECONDS = 5;
55 64
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126 string135 string
127 >;136 >;
128 137
138const 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
146const 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
129const post = async (158const 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
153export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {188export 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
185export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {226export 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
220export const pollForDeviceTokens = async (265export 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) {
apps/cli/src/main.ts+100−32
@@ -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";
92import {95import {
93 decodeAccessTokenClaims,96 decodeAccessTokenClaims,
@@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines
752 755
753const refreshOAuthConnection = (756const 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 401783 // 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 client1068// Typed API client
1055// ---------------------------------------------------------------------------1069// ---------------------------------------------------------------------------
1056 1070
1057const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {1071const 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 ...(authorization1075 );
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: rewrite1082 ),
1069 // the transport-level error into the login hint. Without this the client1083 }
1070 // fails decoding the unexpected status and prints the opaque1084 : {}),
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 session1103// 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
2199const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2200const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2201
2202const 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
2180const profileConnectionInput = (input: {2226const 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 the2498 // 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 });
apps/cli/src/server-profile.test.ts+15−0
@@ -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});
apps/cli/src/server-profile.ts+6−0
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
62 }),62 }),
63]);63]);
64 64
65const PersistedHeader = Schema.Struct({
66 kind: Schema.Literal("env"),
67 name: Schema.String,
68});
69
65const PersistedConnection = Schema.Struct({70const 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
74const PersistedProfile = Schema.Struct({80const PersistedProfile = Schema.Struct({
e2e/local/server-profile-header-env.test.ts+109−0
@@ -0,0 +1,109 @@
1// Local-only: server profiles can carry HTTP headers backed by environment
2// variables. This is the CLI surface needed for Cloudflare Access service
3// tokens on self-hosted Executor deployments.
4import { execFile } from "node:child_process";
5import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6import { tmpdir } from "node:os";
7import { join } from "node:path";
8import { fileURLToPath } from "node:url";
9import { promisify } from "node:util";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13
14import { scenario } from "../src/scenario";
15
16const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
17const CLI_ENTRY = "apps/cli/src/main.ts";
18const execFileAsync = promisify(execFile);
19
20const 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
50scenario(
51 "CLI server profiles · header env mappings are persisted without secret values",
52 {},
53 Effect.promise(async () => {
54 const dataDir = mkdtempSync(join(tmpdir(), "executor-profile-headers-"));
55 try {
56 await runCli(dataDir, [
57 "server",
58 "add",
59 "cloudflare",
60 "https://executor.example",
61 "--header-env",
62 "CF-Access-Client-Id=EXECUTOR_CF_ACCESS_CLIENT_ID",
63 "--header-env",
64 "CF-Access-Client-Secret=EXECUTOR_CF_ACCESS_CLIENT_SECRET",
65 "--default",
66 ]);
67
68 const listing = await runCli(dataDir, ["server", "list"], {
69 EXECUTOR_CF_ACCESS_CLIENT_ID: "dummy-client-id-secret-value",
70 EXECUTOR_CF_ACCESS_CLIENT_SECRET: "dummy-client-secret-value",
71 });
72 expect(listing, "the profile lists its env-backed headers").toContain("2 header-env");
73 expect(listing, "listing does not print the client id secret").not.toContain(
74 "dummy-client-id-secret-value",
75 );
76 expect(listing, "listing does not print the client secret").not.toContain(
77 "dummy-client-secret-value",
78 );
79
80 const raw = readFileSync(join(dataDir, "server-connections.json"), "utf8");
81 const store = JSON.parse(raw) as {
82 readonly profiles: ReadonlyArray<{
83 readonly connection: {
84 readonly headers?: Record<string, { readonly kind: string; readonly name: string }>;
85 };
86 }>;
87 };
88 expect(store.profiles[0]?.connection.headers).toEqual({
89 "CF-Access-Client-Id": {
90 kind: "env",
91 name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
92 },
93 "CF-Access-Client-Secret": {
94 kind: "env",
95 name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET",
96 },
97 });
98 expect(raw, "profile storage keeps env names").toContain("EXECUTOR_CF_ACCESS_CLIENT_ID");
99 expect(raw, "profile storage does not keep resolved client id").not.toContain(
100 "dummy-client-id-secret-value",
101 );
102 expect(raw, "profile storage does not keep resolved client secret").not.toContain(
103 "dummy-client-secret-value",
104 );
105 } finally {
106 rmSync(dataDir, { recursive: true, force: true });
107 }
108 }),
109);
packages/core/sdk/src/index.ts+5−0
@@ -314,15 +314,20 @@ export { · 313 unmodified lines
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
328export {333export {
packages/core/sdk/src/server-connection.test.ts+54−0
@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
2 3
3import {4import {
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,
packages/core/sdk/src/server-connection.ts+69−1
@@ -1,4 +1,4 @@
1import { Option, Schema } from "effect";1import { Data, Effect, Option, Schema } from "effect";
2 2
3export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";3export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";
4export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";4export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";
@@ -6,6 +6,22 @@ export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; · 1 unmodified line
6 6
7export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";7export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";
8export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";8export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";
9export type ExecutorServerHeaderValue = {
10 readonly kind: "env";
11 readonly name: string;
12};
13export type ExecutorServerHeaders = Readonly<Record<string, ExecutorServerHeaderValue>>;
14
15export 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
10export type ExecutorServerAuth =26export 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
42export interface ExecutorServerConnectionInput {59export 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
51export interface ExecutorLocalServerManifest {69export interface ExecutorLocalServerManifest {
@@ -68,6 +86,19 @@ const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); · 16 unmodified lines
68const displayNameFromOrigin = (origin: string): string =>86const displayNameFromOrigin = (origin: string): string =>
69 origin.replace(/^https?:\/\//, "").replace(/\/+$/, "");87 origin.replace(/^https?:\/\//, "").replace(/\/+$/, "");
70 88
89const 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
71export const normalizeExecutorServerOrigin = (raw: string): string => {102export 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
181export 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
198export 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
148const ExecutorServerAuthJson = Schema.Union([210const 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
230const ExecutorServerHeaderJson = Schema.Struct({
231 kind: Schema.Literal("env"),
232 name: Schema.String,
233});
234
168const ExecutorServerConnectionJson = Schema.Struct({235const 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
177const ExecutorLocalServerManifestJson = Schema.Struct({245const ExecutorLocalServerManifestJson = Schema.Struct({
packages/core/sdk/src/shared.ts+5−0
@@ -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";