diff --git a/src/shared/opencode-server-auth.test.ts b/src/shared/opencode-server-auth.test.ts index 7d7623acc..87b419fd8 100644 --- a/src/shared/opencode-server-auth.test.ts +++ b/src/shared/opencode-server-auth.test.ts @@ -53,24 +53,194 @@ describe("opencode-server-auth", () => { process.env.OPENCODE_SERVER_PASSWORD = "secret" delete process.env.OPENCODE_SERVER_USERNAME - let receivedConfig: { headers: Record } | undefined + let receivedHeadersConfig: { headers: Record } | undefined const client = { _client: { - setConfig: (config: { headers: Record }) => { - receivedConfig = config + setConfig: (config: { headers?: Record }) => { + if (config.headers) { + receivedHeadersConfig = { headers: config.headers } + } }, }, } injectServerAuthIntoClient(client) - expect(receivedConfig).toEqual({ + expect(receivedHeadersConfig).toEqual({ headers: { Authorization: "Basic b3BlbmNvZGU6c2VjcmV0", }, }) }) + test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => { + //#given + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedAuthorization: string | null = null + const baseFetch = async (request: Request): Promise => { + receivedAuthorization = request.headers.get("Authorization") + return new Response("ok") + } + + type InternalConfig = { + fetch?: (request: Request) => Promise + headers?: Record + } + + let currentConfig: InternalConfig = { + fetch: baseFetch, + headers: {}, + } + + const client = { + _client: { + getConfig: (): InternalConfig => ({ ...currentConfig }), + setConfig: (config: InternalConfig): InternalConfig => { + currentConfig = { ...currentConfig, ...config } + return { ...currentConfig } + }, + }, + } + + //#when + injectServerAuthIntoClient(client) + if (!currentConfig.fetch) { + throw new Error("expected fetch to be set") + } + await currentConfig.fetch(new Request("http://example.com")) + + //#then + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + }) + + test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => { + //#given + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedAuthorization: string | null = null + const baseFetch = async (request: Request): Promise => { + receivedAuthorization = request.headers.get("Authorization") + return new Response("ok") + } + + const internal = { + _config: { + fetch: baseFetch, + }, + } + + const client = { + _client: internal, + } + + //#when + injectServerAuthIntoClient(client) + await internal._config.fetch(new Request("http://example.com")) + + //#then + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + }) + + test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => { + //#given + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedAuthorization: string | null = null + const baseFetch = async (request: Request): Promise => { + receivedAuthorization = request.headers.get("Authorization") + return new Response("ok") + } + + const client = { + fetch: baseFetch, + } + + //#when + injectServerAuthIntoClient(client) + await client.fetch(new Request("http://example.com")) + + //#then + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + }) + + test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => { + //#given + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + let registeredInterceptor: + | ((request: Request, options: { headers?: Headers }) => Promise | Request) + | undefined + + const client = { + _client: { + interceptors: { + request: { + use: ( + interceptor: (request: Request, options: { headers?: Headers }) => Promise | Request + ): number => { + registeredInterceptor = interceptor + return 0 + }, + }, + }, + }, + } + + //#when + injectServerAuthIntoClient(client) + if (!registeredInterceptor) { + throw new Error("expected interceptor to be registered") + } + const request = new Request("http://example.com") + const result = await registeredInterceptor(request, {}) + + //#then + expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0") + }) + + test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => { + //#given + delete process.env.OPENCODE_SERVER_PASSWORD + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedAuthorization: string | null = null + const baseFetch = async (request: Request): Promise => { + receivedAuthorization = request.headers.get("Authorization") + return new Response("ok") + } + + type InternalConfig = { fetch?: (request: Request) => Promise } + let currentConfig: InternalConfig = { fetch: baseFetch } + let setConfigCalled = false + + const client = { + _client: { + getConfig: (): InternalConfig => ({ ...currentConfig }), + setConfig: (config: InternalConfig): InternalConfig => { + setConfigCalled = true + currentConfig = { ...currentConfig, ...config } + return { ...currentConfig } + }, + }, + } + + //#when + injectServerAuthIntoClient(client) + if (!currentConfig.fetch) { + throw new Error("expected fetch to exist") + } + await currentConfig.fetch(new Request("http://example.com")) + + //#then + expect(setConfigCalled).toBe(false) + expect(receivedAuthorization).toBeNull() + }) + test("#given server password #when client has no _client #then does not throw", () => { process.env.OPENCODE_SERVER_PASSWORD = "secret" const client = {} diff --git a/src/shared/opencode-server-auth.ts b/src/shared/opencode-server-auth.ts index d4bbb346d..8d4957512 100644 --- a/src/shared/opencode-server-auth.ts +++ b/src/shared/opencode-server-auth.ts @@ -1,3 +1,5 @@ +import { log } from "./logger" + /** * Builds HTTP Basic Auth header from environment variables. * @@ -15,6 +17,132 @@ export function getServerBasicAuthHeader(): string | undefined { return `Basic ${token}` } +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function isRequestFetch(value: unknown): value is (request: Request) => Promise { + return typeof value === "function" +} + +function wrapRequestFetch( + baseFetch: (request: Request) => Promise, + auth: string +): (request: Request) => Promise { + return async (request: Request): Promise => { + const headers = new Headers(request.headers) + headers.set("Authorization", auth) + return baseFetch(new Request(request, { headers })) + } +} + +function getInternalClient(client: unknown): UnknownRecord | null { + if (!isRecord(client)) { + return null + } + + const internal = client["_client"] + return isRecord(internal) ? internal : null +} + +function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean { + const setConfig = internal["setConfig"] + if (typeof setConfig !== "function") { + return false + } + + setConfig({ + headers: { + Authorization: auth, + }, + }) + + return true +} + +function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolean { + const interceptors = internal["interceptors"] + if (!isRecord(interceptors)) { + return false + } + + const requestInterceptors = interceptors["request"] + if (!isRecord(requestInterceptors)) { + return false + } + + const use = requestInterceptors["use"] + if (typeof use !== "function") { + return false + } + + use((request: Request): Request => { + if (!request.headers.get("Authorization")) { + request.headers.set("Authorization", auth) + } + return request + }) + + return true +} + +function tryInjectViaFetchWrapper(internal: UnknownRecord, auth: string): boolean { + const getConfig = internal["getConfig"] + const setConfig = internal["setConfig"] + if (typeof getConfig !== "function" || typeof setConfig !== "function") { + return false + } + + const config = getConfig() + if (!isRecord(config)) { + return false + } + + const fetchValue = config["fetch"] + if (!isRequestFetch(fetchValue)) { + return false + } + + setConfig({ + fetch: wrapRequestFetch(fetchValue, auth), + }) + + return true +} + +function tryInjectViaMutableInternalConfig(internal: UnknownRecord, auth: string): boolean { + const configValue = internal["_config"] + if (!isRecord(configValue)) { + return false + } + + const fetchValue = configValue["fetch"] + if (!isRequestFetch(fetchValue)) { + return false + } + + configValue["fetch"] = wrapRequestFetch(fetchValue, auth) + + return true +} + +function tryInjectViaTopLevelFetch(client: unknown, auth: string): boolean { + if (!isRecord(client)) { + return false + } + + const fetchValue = client["fetch"] + if (!isRequestFetch(fetchValue)) { + return false + } + + client["fetch"] = wrapRequestFetch(fetchValue, auth) + + return true +} + /** * Injects HTTP Basic Auth header into the OpenCode SDK client. * @@ -34,36 +162,29 @@ export function injectServerAuthIntoClient(client: unknown): void { } try { - if ( - typeof client !== "object" || - client === null || - !("_client" in client) || - typeof (client as { _client: unknown })._client !== "object" || - (client as { _client: unknown })._client === null - ) { - throw new Error( - "[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible. " + - "This may indicate an OpenCode SDK version mismatch." - ) + const internal = getInternalClient(client) + if (internal) { + const injectedHeaders = tryInjectViaSetConfigHeaders(internal, auth) + const injectedInterceptors = tryInjectViaInterceptors(internal, auth) + const injectedFetch = tryInjectViaFetchWrapper(internal, auth) + const injectedMutable = tryInjectViaMutableInternalConfig(internal, auth) + + const injected = injectedHeaders || injectedInterceptors || injectedFetch || injectedMutable + + if (!injected) { + log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible", { + keys: Object.keys(internal), + }) + } + return } - const internal = (client as { _client: { setConfig?: (config: { headers: Record }) => void } }) - ._client - - if (typeof internal.setConfig !== "function") { - throw new Error( - "[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function. " + - "This may indicate an OpenCode SDK version mismatch." - ) + const injected = tryInjectViaTopLevelFetch(client, auth) + if (!injected) { + log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but no compatible SDK client found") } - - internal.setConfig({ - headers: { - Authorization: auth, - }, - }) } catch (error) { const message = error instanceof Error ? error.message : String(error) - console.warn(`[opencode-server-auth] Failed to inject server auth: ${message}`) + log("[opencode-server-auth] Failed to inject server auth", { message }) } }