fix(sdk): use dynamic port instead of hardcoded 4096 (fixes #2311)

- TmuxSessionManager resolves server URL with priority:
  ctx.serverUrl.origin > getServerBaseUrl(client) > OPENCODE_PORT env > localhost:4096
- Inject resolved baseUrl into SDK client (root + session subclient) at
  startup so all session tools use the dynamic port
- Extract shared getInternalClient utility to sdk-internal-client.ts;
  prefer candidate with getConfig/setConfig for correct v1/v2 detection
- Support v1 (client._client) and v2 (client.client) SDK structures
  across opencode-server-auth.ts and opencode-http-api.ts
- Use createMockContext factory for serverUrl overrides in tests
This commit is contained in:
Hakancan
2026-03-09 08:22:48 +00:00
committed by YeonGyu-Kim
parent 91b7cedb04
commit a2687a8d02
8 changed files with 327 additions and 55 deletions
+55 -1
View File
@@ -78,9 +78,10 @@ const trackedSessions = new Set<string>()
function createMockContext(overrides?: {
sessionStatusResult?: { data?: Record<string, { type: string }> }
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', () => {
+22 -6
View File
@@ -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,
+7 -1
View File
@@ -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?.()
+35
View File
@@ -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", () => {
+4 -15
View File
@@ -1,17 +1,6 @@
import { getServerBasicAuthHeader } from "./opencode-server-auth"
import { log } from "./logger"
import { isRecord } from "./record-type-guard"
type UnknownRecord = Record<string, unknown>
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()
+101 -17
View File
@@ -1,7 +1,7 @@
/// <reference types="bun-types" />
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<string, string | undefined>
@@ -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<string, string> } | 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<string, string> } | undefined
const client = {
client: {
setConfig: (config: { headers?: Record<string, string> }) => {
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<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 }
},
},
}
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")
})
})
+60 -15
View File
@@ -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<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"
}
@@ -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 })
}
}
+43
View File
@@ -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<string, unknown>
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<string, unknown>.
*
* @param value - The value to check
* @returns True if the value is a Record<string, unknown>
*/
export { isRecord }