From 2f9004f076f8a18863b34ba1cf3926cdde62adf0 Mon Sep 17 00:00:00 2001 From: dan <79137382+dan-myles@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:07:02 -0600 Subject: [PATCH] fix(auth): opencode desktop server unauthorized bugfix on subagent spawn (#1399) * fix(auth): opencode desktop server unauthorized bugfix on subagent spawn * refactor(auth): add runtime guard and throw on SDK mismatch - Add JSDoc with SDK API documentation reference - Replace silent failure with explicit Error throw when OPENCODE_SERVER_PASSWORD is set but client structure is incompatible - Add runtime type guard for SDK client structure - Add tests for error cases (missing _client, missing setConfig) - Remove unrelated bun.lock changes Co-authored-by: dan-myles --------- Co-authored-by: YeonGyu-Kim Co-authored-by: dan-myles --- src/index.ts | 2 + src/shared/index.ts | 1 + src/shared/opencode-server-auth.test.ts | 95 +++++++++++++++++++++++++ src/shared/opencode-server-auth.ts | 65 +++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/shared/opencode-server-auth.test.ts create mode 100644 src/shared/opencode-server-auth.ts diff --git a/src/index.ts b/src/index.ts index c37b84157..1e74d0e43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ import { getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, + injectServerAuthIntoClient, } from "./shared"; import { loadPluginConfig } from "./plugin-config"; import { createModelCacheState } from "./plugin-state"; @@ -107,6 +108,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory, }); + injectServerAuthIntoClient(ctx.client); // Start background tmux check immediately startTmuxCheck(); diff --git a/src/shared/index.ts b/src/shared/index.ts index 479335125..99b43262a 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -39,3 +39,4 @@ export * from "./connected-providers-cache" export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" +export * from "./opencode-server-auth" diff --git a/src/shared/opencode-server-auth.test.ts b/src/shared/opencode-server-auth.test.ts new file mode 100644 index 000000000..809dd502f --- /dev/null +++ b/src/shared/opencode-server-auth.test.ts @@ -0,0 +1,95 @@ +import { getServerBasicAuthHeader, injectServerAuthIntoClient } from "./opencode-server-auth" + +describe("opencode-server-auth", () => { + let originalEnv: Record + + beforeEach(() => { + originalEnv = { + OPENCODE_SERVER_PASSWORD: process.env.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME, + } + }) + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) { + process.env[key] = value + } else { + delete process.env[key] + } + } + }) + + test("#given no server password #when building auth header #then returns undefined", () => { + delete process.env.OPENCODE_SERVER_PASSWORD + + const result = getServerBasicAuthHeader() + + expect(result).toBeUndefined() + }) + + test("#given server password without username #when building auth header #then uses default username", () => { + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + const result = getServerBasicAuthHeader() + + expect(result).toBe("Basic b3BlbmNvZGU6c2VjcmV0") + }) + + test("#given server password and username #when building auth header #then uses provided username", () => { + process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_USERNAME = "dan" + + const result = getServerBasicAuthHeader() + + expect(result).toBe("Basic ZGFuOnNlY3JldA==") + }) + + test("#given server password #when injecting into client #then updates client headers", () => { + process.env.OPENCODE_SERVER_PASSWORD = "secret" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedConfig: { headers: Record } | undefined + const client = { + _client: { + setConfig: (config: { headers: Record }) => { + receivedConfig = config + }, + }, + } + + injectServerAuthIntoClient(client) + + expect(receivedConfig).toEqual({ + headers: { + Authorization: "Basic b3BlbmNvZGU6c2VjcmV0", + }, + }) + }) + + test("#given server password #when client has no _client #then throws error", () => { + process.env.OPENCODE_SERVER_PASSWORD = "secret" + const client = {} + + expect(() => injectServerAuthIntoClient(client)).toThrow( + "[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible" + ) + }) + + test("#given server password #when client._client has no setConfig #then throws error", () => { + process.env.OPENCODE_SERVER_PASSWORD = "secret" + const client = { _client: {} } + + expect(() => injectServerAuthIntoClient(client)).toThrow( + "[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function" + ) + }) + + test("#given no server password #when client is invalid #then does not throw", () => { + delete process.env.OPENCODE_SERVER_PASSWORD + const client = {} + + expect(() => injectServerAuthIntoClient(client)).not.toThrow() + }) +}) diff --git a/src/shared/opencode-server-auth.ts b/src/shared/opencode-server-auth.ts new file mode 100644 index 000000000..75ac81b2b --- /dev/null +++ b/src/shared/opencode-server-auth.ts @@ -0,0 +1,65 @@ +/** + * Builds HTTP Basic Auth header from environment variables. + * + * @returns Basic Auth header string, or undefined if OPENCODE_SERVER_PASSWORD is not set + */ +export function getServerBasicAuthHeader(): string | undefined { + const password = process.env.OPENCODE_SERVER_PASSWORD + if (!password) { + return undefined + } + + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64") + + return `Basic ${token}` +} + +/** + * Injects HTTP Basic Auth header into the OpenCode SDK client. + * + * This function accesses the SDK's internal `_client.setConfig()` method. + * While `_client` has an underscore prefix (suggesting internal use), this is actually + * a stable public API from `@hey-api/openapi-ts` generated client: + * - `setConfig()` MERGES headers (does not replace existing ones) + * - This is the documented way to update client config at runtime + * + * @see https://github.com/sst/opencode/blob/main/packages/sdk/js/src/gen/client/client.gen.ts + * @throws {Error} If OPENCODE_SERVER_PASSWORD is set but client structure is incompatible + */ +export function injectServerAuthIntoClient(client: unknown): void { + const auth = getServerBasicAuthHeader() + if (!auth) { + return + } + + // Runtime type guard for SDK client structure + 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 = (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." + ) + } + + internal.setConfig({ + headers: { + Authorization: auth, + }, + }) +}