fix(auth): add multi-layer auth injection for desktop app compatibility

Desktop app sets OPENCODE_SERVER_PASSWORD which activates basicAuth on
the server, but the SDK client provided to plugins lacks auth headers.
The previous setConfig-only approach may silently fail depending on SDK
version.

Add belt-and-suspenders fallback chain:
1. setConfig headers (existing)
2. request interceptors
3. fetch wrapper via getConfig/setConfig
4. mutable _config.fetch wrapper
5. top-level client.fetch wrapper

Replace console.warn with structured log() for better diagnostics.
This commit is contained in:
YeonGyu-Kim
2026-02-12 17:20:48 +09:00
parent 118150035c
commit ff8a5f343a
2 changed files with 321 additions and 30 deletions

View File

@@ -53,24 +53,194 @@ describe("opencode-server-auth", () => {
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let receivedConfig: { headers: Record<string, string> } | undefined
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
const client = {
_client: {
setConfig: (config: { headers: Record<string, string> }) => {
receivedConfig = config
setConfig: (config: { headers?: Record<string, string> }) => {
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<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
type InternalConfig = {
fetch?: (request: Request) => Promise<Response>
headers?: Record<string, string>
}
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<Response> => {
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<Response> => {
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> | Request)
| undefined
const client = {
_client: {
interceptors: {
request: {
use: (
interceptor: (request: Request, options: { headers?: Headers }) => Promise<Request> | 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<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
type InternalConfig = { fetch?: (request: Request) => Promise<Response> }
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 = {}

View File

@@ -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<string, unknown>
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null
}
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
return typeof value === "function"
}
function wrapRequestFetch(
baseFetch: (request: Request) => Promise<Response>,
auth: string
): (request: Request) => Promise<Response> {
return async (request: Request): Promise<Response> => {
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<string, string> }) => 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 })
}
}