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:
@@ -78,9 +78,10 @@ const trackedSessions = new Set<string>()
|
|||||||
function createMockContext(overrides?: {
|
function createMockContext(overrides?: {
|
||||||
sessionStatusResult?: { data?: Record<string, { type: string }> }
|
sessionStatusResult?: { data?: Record<string, { type: string }> }
|
||||||
sessionMessagesResult?: { data?: unknown[] }
|
sessionMessagesResult?: { data?: unknown[] }
|
||||||
|
serverUrl?: URL | undefined
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
serverUrl: new URL('http://localhost:4096'),
|
serverUrl: overrides && 'serverUrl' in overrides ? overrides.serverUrl : new URL('http://localhost:4096'),
|
||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
status: mock(async () => {
|
status: mock(async () => {
|
||||||
@@ -226,6 +227,59 @@ describe('TmuxSessionManager', () => {
|
|||||||
// then
|
// then
|
||||||
expect(manager).toBeDefined()
|
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', () => {
|
describe('onSessionCreated', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { TmuxConfig } from "../../config/schema"
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||||
import { log, normalizeSDKResponse } from "../../shared"
|
import { log, normalizeSDKResponse, getServerBaseUrl } from "../../shared"
|
||||||
import {
|
import {
|
||||||
isInsideTmux as defaultIsInsideTmux,
|
isInsideTmux as defaultIsInsideTmux,
|
||||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||||
@@ -72,12 +72,28 @@ export class TmuxSessionManager {
|
|||||||
this.client = ctx.client
|
this.client = ctx.client
|
||||||
this.tmuxConfig = tmuxConfig
|
this.tmuxConfig = tmuxConfig
|
||||||
this.deps = deps
|
this.deps = deps
|
||||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
let serverUrl: string | null = null
|
||||||
try {
|
|
||||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
if (ctx.serverUrl) {
|
||||||
} catch {
|
serverUrl = ctx.serverUrl.origin
|
||||||
this.serverUrl = `http://localhost:${defaultPort}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.sourcePaneId = deps.getCurrentPaneId()
|
||||||
this.pollingManager = new TmuxPollingManager(
|
this.pollingManager = new TmuxPollingManager(
|
||||||
this.client,
|
this.client,
|
||||||
|
|||||||
+7
-1
@@ -12,7 +12,7 @@ import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
|
|||||||
import { loadPluginConfig } from "./plugin-config"
|
import { loadPluginConfig } from "./plugin-config"
|
||||||
import { createModelCacheState } from "./plugin-state"
|
import { createModelCacheState } from "./plugin-state"
|
||||||
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
||||||
import { injectServerAuthIntoClient, log } from "./shared"
|
import { injectServerAuthIntoClient, injectServerBaseUrlIntoClient, log } from "./shared"
|
||||||
import { startTmuxCheck } from "./tools"
|
import { startTmuxCheck } from "./tools"
|
||||||
|
|
||||||
let activePluginDispose: PluginDispose | null = null
|
let activePluginDispose: PluginDispose | null = null
|
||||||
@@ -24,6 +24,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
directory: ctx.directory,
|
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)
|
injectServerAuthIntoClient(ctx.client)
|
||||||
startTmuxCheck()
|
startTmuxCheck()
|
||||||
await activePluginDispose?.()
|
await activePluginDispose?.()
|
||||||
|
|||||||
@@ -58,6 +58,41 @@ describe("getServerBaseUrl", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toBeNull()
|
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", () => {
|
describe("patchPart", () => {
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { getServerBasicAuthHeader } from "./opencode-server-auth"
|
import { getServerBasicAuthHeader } from "./opencode-server-auth"
|
||||||
import { log } from "./logger"
|
import { log } from "./logger"
|
||||||
import { isRecord } from "./record-type-guard"
|
import { getInternalClient, isRecord } from "./sdk-internal-client"
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerBaseUrl(client: unknown): string | null {
|
export function getServerBaseUrl(client: unknown): string | null {
|
||||||
// Try client._client.getConfig().baseUrl
|
// 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)) {
|
if (isRecord(client)) {
|
||||||
const session = client["session"]
|
const session = client["session"]
|
||||||
if (isRecord(session)) {
|
if (isRecord(session)) {
|
||||||
const internal = session["_client"]
|
const internal = getInternalClient(session)
|
||||||
if (isRecord(internal)) {
|
if (internal) {
|
||||||
const getConfig = internal["getConfig"]
|
const getConfig = internal["getConfig"]
|
||||||
if (typeof getConfig === "function") {
|
if (typeof getConfig === "function") {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
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", () => {
|
describe("opencode-server-auth", () => {
|
||||||
let originalEnv: Record<string, string | undefined>
|
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", () => {
|
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
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
const result = getServerBasicAuthHeader()
|
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", () => {
|
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"
|
process.env.OPENCODE_SERVER_USERNAME = "dan"
|
||||||
|
|
||||||
const result = getServerBasicAuthHeader()
|
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", () => {
|
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
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
|
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
|
||||||
@@ -68,14 +68,14 @@ describe("opencode-server-auth", () => {
|
|||||||
|
|
||||||
expect(receivedHeadersConfig).toEqual({
|
expect(receivedHeadersConfig).toEqual({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Basic b3BlbmNvZGU6c2VjcmV0",
|
Authorization: "Basic b3BlbmNvZGU6dGVzdC1wYXNzd29yZC1wbGFjZWhvbGRlcg==",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
|
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
|
||||||
//#given
|
//#given
|
||||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder"
|
||||||
delete process.env.OPENCODE_SERVER_USERNAME
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
let receivedAuthorization: string | null = null
|
let receivedAuthorization: string | null = null
|
||||||
@@ -112,12 +112,12 @@ describe("opencode-server-auth", () => {
|
|||||||
await currentConfig.fetch(new Request("http://example.com"))
|
await currentConfig.fetch(new Request("http://example.com"))
|
||||||
|
|
||||||
//#then
|
//#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 () => {
|
test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => {
|
||||||
//#given
|
//#given
|
||||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder"
|
||||||
delete process.env.OPENCODE_SERVER_USERNAME
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
let receivedAuthorization: string | null = null
|
let receivedAuthorization: string | null = null
|
||||||
@@ -141,12 +141,12 @@ describe("opencode-server-auth", () => {
|
|||||||
await internal._config.fetch(new Request("http://example.com"))
|
await internal._config.fetch(new Request("http://example.com"))
|
||||||
|
|
||||||
//#then
|
//#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 () => {
|
test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => {
|
||||||
//#given
|
//#given
|
||||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder"
|
||||||
delete process.env.OPENCODE_SERVER_USERNAME
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
let receivedAuthorization: string | null = null
|
let receivedAuthorization: string | null = null
|
||||||
@@ -164,12 +164,12 @@ describe("opencode-server-auth", () => {
|
|||||||
await client.fetch(new Request("http://example.com"))
|
await client.fetch(new Request("http://example.com"))
|
||||||
|
|
||||||
//#then
|
//#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 () => {
|
test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => {
|
||||||
//#given
|
//#given
|
||||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
process.env.OPENCODE_SERVER_PASSWORD = "test-password-placeholder"
|
||||||
delete process.env.OPENCODE_SERVER_USERNAME
|
delete process.env.OPENCODE_SERVER_USERNAME
|
||||||
|
|
||||||
let registeredInterceptor:
|
let registeredInterceptor:
|
||||||
@@ -200,7 +200,7 @@ describe("opencode-server-auth", () => {
|
|||||||
const result = await registeredInterceptor(request, {})
|
const result = await registeredInterceptor(request, {})
|
||||||
|
|
||||||
//#then
|
//#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 () => {
|
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", () => {
|
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 = {}
|
const client = {}
|
||||||
|
|
||||||
expect(() => injectServerAuthIntoClient(client)).not.toThrow()
|
expect(() => injectServerAuthIntoClient(client)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given server password #when client._client has no setConfig #then does not throw", () => {
|
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: {} }
|
const client = { _client: {} }
|
||||||
|
|
||||||
expect(() => injectServerAuthIntoClient(client)).not.toThrow()
|
expect(() => injectServerAuthIntoClient(client)).not.toThrow()
|
||||||
@@ -261,4 +261,88 @@ describe("opencode-server-auth", () => {
|
|||||||
|
|
||||||
expect(() => injectServerAuthIntoClient(client)).not.toThrow()
|
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 { 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.
|
* Builds HTTP Basic Auth header from environment variables.
|
||||||
@@ -17,12 +19,6 @@ export function getServerBasicAuthHeader(): string | undefined {
|
|||||||
return `Basic ${token}`
|
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> {
|
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
|
||||||
return typeof value === "function"
|
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 {
|
function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean {
|
||||||
const setConfig = internal["setConfig"]
|
const setConfig = internal["setConfig"]
|
||||||
if (typeof setConfig !== "function") {
|
if (typeof setConfig !== "function") {
|
||||||
@@ -188,3 +175,61 @@ export function injectServerAuthIntoClient(client: unknown): void {
|
|||||||
log("[opencode-server-auth] Failed to inject server auth", { message })
|
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