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:
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user