diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 976871982..dbcdf9435 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -78,9 +78,10 @@ const trackedSessions = new Set() function createMockContext(overrides?: { sessionStatusResult?: { data?: Record } sessionMessagesResult?: { data?: unknown[] } + serverUrl?: URL | undefined }) { return { - serverUrl: new URL('http://localhost:4096'), + serverUrl: overrides && 'serverUrl' in overrides ? overrides.serverUrl : new URL('http://localhost:4096'), client: { session: { status: mock(async () => { @@ -226,6 +227,59 @@ describe('TmuxSessionManager', () => { // then expect(manager).toBeDefined() }) + + test('uses dynamic port from ctx.serverUrl instead of hardcoded 4096', async () => { + // given - server running on dynamic port 55535 (not 4096) + const dynamicPort = 55535 + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext({ serverUrl: new URL(`http://localhost:${dynamicPort}`) }) + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + + // when + const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + + // then - should use dynamic port, not hardcoded 4096 + const serverUrl = (manager as any).serverUrl as string + expect(serverUrl).toContain(`:${dynamicPort}`) + expect(serverUrl).not.toContain('4096') + }) + + test('falls back to OPENCODE_PORT env var when ctx.serverUrl is undefined', async () => { + // given - ctx.serverUrl is undefined, but OPENCODE_PORT is set + const originalPort = process.env.OPENCODE_PORT + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext({ serverUrl: undefined }) + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + + try { + process.env.OPENCODE_PORT = '8080' + // when + const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps) + + // then - should use env var port, not hardcoded 4096 + const serverUrl = (manager as any).serverUrl as string + expect(serverUrl).toBe('http://localhost:8080') + expect(serverUrl).not.toContain('4096') + } finally { + if (originalPort !== undefined) { + process.env.OPENCODE_PORT = originalPort + } else { + delete process.env.OPENCODE_PORT + } + } + }) }) describe('onSessionCreated', () => { diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 27909db71..792e6ce2e 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig, WindowState } from "./types" -import { log, normalizeSDKResponse } from "../../shared" +import { log, normalizeSDKResponse, getServerBaseUrl } from "../../shared" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -72,12 +72,28 @@ export class TmuxSessionManager { this.client = ctx.client this.tmuxConfig = tmuxConfig this.deps = deps - const defaultPort = process.env.OPENCODE_PORT ?? "4096" - try { - this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` - } catch { - this.serverUrl = `http://localhost:${defaultPort}` + let serverUrl: string | null = null + + if (ctx.serverUrl) { + serverUrl = ctx.serverUrl.origin } + + if (!serverUrl) { + serverUrl = getServerBaseUrl(ctx.client) + } + + if (!serverUrl) { + const envPort = process.env.OPENCODE_PORT + if (envPort) { + serverUrl = `http://localhost:${envPort}` + } + } + + if (!serverUrl) { + serverUrl = "http://localhost:4096" + } + + this.serverUrl = serverUrl this.sourcePaneId = deps.getCurrentPaneId() this.pollingManager = new TmuxPollingManager( this.client, diff --git a/src/index.ts b/src/index.ts index 70156f109..386fca8ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { createPluginDispose, type PluginDispose } from "./plugin-dispose" import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" -import { injectServerAuthIntoClient, log } from "./shared" +import { injectServerAuthIntoClient, injectServerBaseUrlIntoClient, log } from "./shared" import { startTmuxCheck } from "./tools" let activePluginDispose: PluginDispose | null = null @@ -24,6 +24,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { directory: ctx.directory, }) + // Inject correct server baseUrl to ensure SDK client uses dynamic port + // Priority: ctx.serverUrl > fallback to existing client baseUrl + if (ctx.serverUrl) { + injectServerBaseUrlIntoClient(ctx.client, ctx.serverUrl.toString()) + } + injectServerAuthIntoClient(ctx.client) startTmuxCheck() await activePluginDispose?.() diff --git a/src/shared/opencode-http-api.test.ts b/src/shared/opencode-http-api.test.ts index 80b86bae6..d365defb2 100644 --- a/src/shared/opencode-http-api.test.ts +++ b/src/shared/opencode-http-api.test.ts @@ -58,6 +58,41 @@ describe("getServerBaseUrl", () => { // then expect(result).toBeNull() }) + + it("returns baseUrl from client.client.getConfig().baseUrl (v2 SDK)", () => { + // given + const mockClient = { + client: { + getConfig: () => ({ baseUrl: "https://v2-api.example.com" }), + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://v2-api.example.com") + }) + + it("returns baseUrl from client.session.client.getConfig().baseUrl (v2 SDK session path)", () => { + // given + const mockClient = { + _client: { + getConfig: () => ({}), + }, + session: { + client: { + getConfig: () => ({ baseUrl: "https://v2-session.example.com" }), + }, + }, + } + + // when + const result = getServerBaseUrl(mockClient) + + // then + expect(result).toBe("https://v2-session.example.com") + }) }) describe("patchPart", () => { diff --git a/src/shared/opencode-http-api.ts b/src/shared/opencode-http-api.ts index 451d98e6a..f25c8c985 100644 --- a/src/shared/opencode-http-api.ts +++ b/src/shared/opencode-http-api.ts @@ -1,17 +1,6 @@ import { getServerBasicAuthHeader } from "./opencode-server-auth" import { log } from "./logger" -import { isRecord } from "./record-type-guard" - -type UnknownRecord = Record - -function getInternalClient(client: unknown): UnknownRecord | null { - if (!isRecord(client)) { - return null - } - - const internal = client["_client"] - return isRecord(internal) ? internal : null -} +import { getInternalClient, isRecord } from "./sdk-internal-client" export function getServerBaseUrl(client: unknown): string | null { // Try client._client.getConfig().baseUrl @@ -29,12 +18,12 @@ export function getServerBaseUrl(client: unknown): string | null { } } - // Try client.session._client.getConfig().baseUrl + // Try client.session._client.getConfig().baseUrl (v1) or client.session.client.getConfig().baseUrl (v2) if (isRecord(client)) { const session = client["session"] if (isRecord(session)) { - const internal = session["_client"] - if (isRecord(internal)) { + const internal = getInternalClient(session) + if (internal) { const getConfig = internal["getConfig"] if (typeof getConfig === "function") { const config = getConfig() diff --git a/src/shared/opencode-server-auth.test.ts b/src/shared/opencode-server-auth.test.ts index 87b419fd8..8f975623d 100644 --- a/src/shared/opencode-server-auth.test.ts +++ b/src/shared/opencode-server-auth.test.ts @@ -1,7 +1,7 @@ /// import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { getServerBasicAuthHeader, injectServerAuthIntoClient } from "./opencode-server-auth" +import { getServerBasicAuthHeader, injectServerAuthIntoClient, injectServerBaseUrlIntoClient } from "./opencode-server-auth" describe("opencode-server-auth", () => { let originalEnv: Record @@ -32,25 +32,25 @@ describe("opencode-server-auth", () => { }) test("#given server password without username #when building auth header #then uses default username", () => { - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME const result = getServerBasicAuthHeader() - expect(result).toBe("Basic b3BlbmNvZGU6c2VjcmV0") + expect(result).toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") }) test("#given server password and username #when building auth header #then uses provided username", () => { - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" process.env.OPENCODE_SERVER_USERNAME = "dan" const result = getServerBasicAuthHeader() - expect(result).toBe("Basic ZGFuOnNlY3JldA==") + expect(result).toBe("Basic ZGFuOnRlc3QtcGFzc3dvcmQtcGxhY2Vob2xkZXI=") }) test("#given server password #when injecting into client #then updates client headers", () => { - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME let receivedHeadersConfig: { headers: Record } | undefined @@ -68,14 +68,14 @@ describe("opencode-server-auth", () => { expect(receivedHeadersConfig).toEqual({ headers: { - Authorization: "Basic b3BlbmNvZGU6c2VjcmV0", + Authorization: "Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==", }, }) }) test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => { //#given - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME let receivedAuthorization: string | null = null @@ -112,12 +112,12 @@ describe("opencode-server-auth", () => { await currentConfig.fetch(new Request("http://example.com")) //#then - expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") }) 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" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME let receivedAuthorization: string | null = null @@ -141,12 +141,12 @@ describe("opencode-server-auth", () => { await internal._config.fetch(new Request("http://example.com")) //#then - expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") }) 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" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME let receivedAuthorization: string | null = null @@ -164,12 +164,12 @@ describe("opencode-server-auth", () => { await client.fetch(new Request("http://example.com")) //#then - expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0") + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") }) test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => { //#given - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" delete process.env.OPENCODE_SERVER_USERNAME let registeredInterceptor: @@ -200,7 +200,7 @@ describe("opencode-server-auth", () => { const result = await registeredInterceptor(request, {}) //#then - expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0") + expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") }) test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => { @@ -242,14 +242,14 @@ describe("opencode-server-auth", () => { }) test("#given server password #when client has no _client #then does not throw", () => { - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" const client = {} expect(() => injectServerAuthIntoClient(client)).not.toThrow() }) test("#given server password #when client._client has no setConfig #then does not throw", () => { - process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" const client = { _client: {} } expect(() => injectServerAuthIntoClient(client)).not.toThrow() @@ -261,4 +261,88 @@ describe("opencode-server-auth", () => { expect(() => injectServerAuthIntoClient(client)).not.toThrow() }) + + // v2 SDK compatibility tests + test("#given server password #when injecting into v2 client #then updates client headers", () => { + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedHeadersConfig: { headers: Record } | undefined + const client = { + client: { + setConfig: (config: { headers?: Record }) => { + if (config.headers) { + receivedHeadersConfig = { headers: config.headers } + } + }, + }, + } + + injectServerAuthIntoClient(client) + + expect(receivedHeadersConfig).toEqual({ + headers: { + Authorization: "Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==", + }, + }) + }) + + test("#given server password #when injecting into v2 client with fetch #then fetch is wrapped", async () => { + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" + 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 } + }, + }, + } + + injectServerAuthIntoClient(client) + if (!currentConfig.fetch) { + throw new Error("expected fetch to be set") + } + await currentConfig.fetch(new Request("http://example.com")) + + expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==") + }) + + test("#given server password #when injecting baseUrl into v2 client #then updates baseUrl", () => { + process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder" + delete process.env.OPENCODE_SERVER_USERNAME + + let receivedBaseUrl: string | undefined + const client = { + client: { + setConfig: (config: { baseUrl?: string }) => { + if (config.baseUrl) { + receivedBaseUrl = config.baseUrl + } + }, + }, + } + + injectServerBaseUrlIntoClient(client, "http://localhost:3000") + + expect(receivedBaseUrl).toBe("http://localhost:3000") + }) }) diff --git a/src/shared/opencode-server-auth.ts b/src/shared/opencode-server-auth.ts index 8d4957512..4feba146d 100644 --- a/src/shared/opencode-server-auth.ts +++ b/src/shared/opencode-server-auth.ts @@ -1,4 +1,6 @@ import { log } from "./logger" +import type { UnknownRecord } from "./sdk-internal-client" +import { getInternalClient, isRecord } from "./sdk-internal-client" /** * Builds HTTP Basic Auth header from environment variables. @@ -17,12 +19,6 @@ 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" } @@ -38,15 +34,6 @@ function wrapRequestFetch( } } -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") { @@ -188,3 +175,61 @@ export function injectServerAuthIntoClient(client: unknown): void { log("[opencode-server-auth] Failed to inject server auth", { message }) } } + +/** + * Injects/updates the server baseUrl in the OpenCode SDK client. + * + * This ensures the SDK client uses the correct server URL (dynamic port) instead of + * any hardcoded default. Uses the same setConfig() API as auth injection. + * + * @param client - The SDK client to update + * @param serverUrl - The correct server URL to use + */ +export function injectServerBaseUrlIntoClient(client: unknown, serverUrl: string): void { + try { + let injected = false + + const internal = getInternalClient(client) + if (internal) { + const setConfig = internal["setConfig"] + if (typeof setConfig === "function") { + setConfig({ baseUrl: serverUrl }) + log("[opencode-server-auth] Updated client baseUrl", { serverUrl }) + injected = true + } + } + + // Also inject into session subclient if it exists (mirrors getServerBaseUrl fallback path) + if (isRecord(client)) { + const session = client["session"] + if (isRecord(session)) { + const sessionInternal = getInternalClient(session) + if (sessionInternal) { + const setConfig = sessionInternal["setConfig"] + if (typeof setConfig === "function") { + setConfig({ baseUrl: serverUrl }) + log("[opencode-server-auth] Updated session client baseUrl", { serverUrl }) + injected = true + } + } + } + } + + // Fallback: try to set baseUrl directly on client if it has setConfig + if (!injected && isRecord(client)) { + const setConfig = client["setConfig"] + if (typeof setConfig === "function") { + setConfig({ baseUrl: serverUrl }) + log("[opencode-server-auth] Updated client baseUrl (top-level)", { serverUrl }) + injected = true + } + } + + if (!injected) { + log("[opencode-server-auth] Could not update client baseUrl - incompatible client structure") + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log("[opencode-server-auth] Failed to inject server baseUrl", { message, serverUrl }) + } +} diff --git a/src/shared/sdk-internal-client.ts b/src/shared/sdk-internal-client.ts new file mode 100644 index 000000000..cafd1bafb --- /dev/null +++ b/src/shared/sdk-internal-client.ts @@ -0,0 +1,43 @@ +/** + * Shared utilities for accessing OpenCode SDK internal client structure. + * + * Provides safe access to the SDK's internal `_client` (v1) or `client` (v2) properties. + */ + +export type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +/** + * Extracts the internal SDK client from a wrapper client. + * + * Supports both v1 SDK (client._client) and v2 SDK (client.client). + * + * @param client - The SDK client wrapper + * @returns The internal client record, or null if not accessible + */ +export function getInternalClient(client: unknown): UnknownRecord | null { + if (!isRecord(client)) { + return null + } + + // Support both v1 SDK (client._client) and v2 SDK (client.client) + // Prefer the candidate that has getConfig or setConfig (the actual transport client) + const candidates = [client["_client"], client["client"]].filter(isRecord) + for (const candidate of candidates) { + if (typeof candidate["getConfig"] === "function" || typeof candidate["setConfig"] === "function") { + return candidate + } + } + return candidates[0] ?? null +} + +/** + * Type guard for Record. + * + * @param value - The value to check + * @returns True if the value is a Record + */ +export { isRecord }