diff --git a/src/cli/run/server-connection.test.ts b/src/cli/run/server-connection.test.ts index 90bad1812..2cd4ed660 100644 --- a/src/cli/run/server-connection.test.ts +++ b/src/cli/run/server-connection.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun import * as originalSdk from "@opencode-ai/sdk" import * as originalPortUtils from "../../shared/port-utils" import * as originalBinaryResolver from "./opencode-binary-resolver" +import * as originalServerAuth from "../../shared/opencode-server-auth" const originalConsole = globalThis.console @@ -13,11 +14,15 @@ const mockCreateOpencode = mock(() => server: { url: "http://127.0.0.1:4096", close: mockServerClose }, }) ) -const mockCreateOpencodeClient = mock(() => ({ session: {} })) +const mockCreateOpencodeClient = mock((options?: { baseUrl?: string }) => ({ + session: {}, + baseUrl: options?.baseUrl, +})) const mockIsPortAvailable = mock(() => Promise.resolve(true)) const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false })) const mockConsoleLog = mock(() => {}) const mockWithWorkingOpencodePath = mock((startServer: () => Promise) => startServer()) +const mockInjectServerAuthIntoClient = mock(() => {}) mock.module("@opencode-ai/sdk", () => ({ createOpencode: mockCreateOpencode, @@ -34,10 +39,15 @@ mock.module("./opencode-binary-resolver", () => ({ withWorkingOpencodePath: mockWithWorkingOpencodePath, })) +mock.module("../../shared/opencode-server-auth", () => ({ + injectServerAuthIntoClient: mockInjectServerAuthIntoClient, +})) + afterAll(() => { mock.module("@opencode-ai/sdk", () => originalSdk) mock.module("../../shared/port-utils", () => originalPortUtils) mock.module("./opencode-binary-resolver", () => originalBinaryResolver) + mock.module("../../shared/opencode-server-auth", () => originalServerAuth) mock.restore() }) @@ -52,6 +62,7 @@ describe("createServerConnection", () => { mockServerClose.mockClear() mockConsoleLog.mockClear() mockWithWorkingOpencodePath.mockClear() + mockInjectServerAuthIntoClient.mockClear() globalThis.console = { ...console, log: mockConsoleLog } as typeof console }) @@ -59,6 +70,49 @@ describe("createServerConnection", () => { globalThis.console = originalConsole }) + it("attach mode injects auth only for loopback URLs", async () => { + // given + const signal = new AbortController().signal + + // when + const localhostResult = await createServerConnection({ attach: "http://localhost:8080", signal }) + const loopbackResult = await createServerConnection({ attach: "http://127.0.0.1:8080", signal }) + const anyBindResult = await createServerConnection({ attach: "http://0.0.0.0:8080", signal }) + const remoteResult = await createServerConnection({ attach: "https://example.com", signal }) + + // then + expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://localhost:8080" }) + expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" }) + expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://0.0.0.0:8080" }) + expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "https://example.com" }) + expect(mockInjectServerAuthIntoClient).toHaveBeenCalledTimes(3) + expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(1, localhostResult.client) + expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(2, loopbackResult.client) + expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(3, anyBindResult.client) + expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalledWith(remoteResult.client) + expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled() + localhostResult.cleanup() + loopbackResult.cleanup() + anyBindResult.cleanup() + remoteResult.cleanup() + expect(mockServerClose).not.toHaveBeenCalled() + }) + + it("attach mode skips auth injection for invalid attach URLs", async () => { + // given + const signal = new AbortController().signal + const attachUrl = "not-a-url" + + // when + const result = await createServerConnection({ attach: attachUrl, signal }) + + // then + expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl }) + expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalled() + result.cleanup() + expect(mockServerClose).not.toHaveBeenCalled() + }) + it("attach mode returns client with no-op cleanup", async () => { // given const signal = new AbortController().signal @@ -68,8 +122,6 @@ describe("createServerConnection", () => { const result = await createServerConnection({ attach: attachUrl, signal }) // then - expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl }) - expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled() expect(result.client).toBeDefined() expect(result.cleanup).toBeDefined() result.cleanup() diff --git a/src/cli/run/server-connection.ts b/src/cli/run/server-connection.ts index bf658ff05..f92aa1a08 100644 --- a/src/cli/run/server-connection.ts +++ b/src/cli/run/server-connection.ts @@ -1,9 +1,21 @@ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk" import pc from "picocolors" import type { ServerConnection } from "./types" +import { injectServerAuthIntoClient } from "../../shared/opencode-server-auth" import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils" import { withWorkingOpencodePath } from "./opencode-binary-resolver" +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"]) + +function isLoopbackAttachUrl(url: string): boolean { + try { + const parsed = new URL(url) + return LOOPBACK_HOSTS.has(parsed.hostname) + } catch { + return false + } +} + function isPortStartFailure(error: unknown, port: number): boolean { if (!(error instanceof Error)) { return false @@ -40,6 +52,9 @@ export async function createServerConnection(options: { if (attach !== undefined) { console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach)) const client = createOpencodeClient({ baseUrl: attach }) + if (isLoopbackAttachUrl(attach)) { + injectServerAuthIntoClient(client) + } return { client, cleanup: () => {} } } @@ -66,12 +81,14 @@ export async function createServerConnection(options: { console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server")) const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` }) + injectServerAuthIntoClient(client) return { client, cleanup: () => {} } } } console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server")) const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` }) + injectServerAuthIntoClient(client) return { client, cleanup: () => {} } } @@ -93,6 +110,7 @@ export async function createServerConnection(options: { console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString())) const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` }) + injectServerAuthIntoClient(client) return { client, cleanup: () => {} } } diff --git a/src/shared/opencode-server-auth.ts b/src/shared/opencode-server-auth.ts index 8d4957512..02ebfa928 100644 --- a/src/shared/opencode-server-auth.ts +++ b/src/shared/opencode-server-auth.ts @@ -78,7 +78,7 @@ function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolea return false } - use((request: Request): Request => { + use.call(requestInterceptors, (request: Request): Request => { if (!request.headers.get("Authorization")) { request.headers.set("Authorization", auth) }