diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 72c1c0683de5..f973044f38da 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -180,6 +180,7 @@ export const OpenCodeDriver: ProviderDriver const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, + external: server.external, ...(effectiveConfig.serverPassword ? { serverPassword: effectiveConfig.serverPassword } : {}), @@ -192,6 +193,7 @@ export const OpenCodeDriver: ProviderDriver openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, + external: false, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 81e799c9095e..57dfd0d70eea 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -535,6 +535,13 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ serverPassword: "secret-password", }); +// A non-loopback server URL, so the adapter treats the session as remote. +const openCodeAdapterRemoteSettings = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://10.0.0.5:4096", + serverPassword: "secret-password", +}); + const OpenCodeAdapterTestLayer = Layer.effect( OpenCodeAdapter, makeOpenCodeAdapter(openCodeAdapterTestSettings), @@ -1240,6 +1247,57 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "reuses a remote session without forking even when the server directory differs", + () => { + const remoteLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterRemoteSettings), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://10.0.0.5:4096", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + // The server-side directory differs from this test's local cwd. + runtimeMock.state.sessionDirectoryById.set("ses_remote", "/var/log"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-remote-reuse"), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_remote" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_remote"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_remote", + }); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_remote"); + + yield* adapter.stopSession(asThreadId("thread-opencode-remote-reuse")); + }).pipe(Effect.provide(remoteLayer)); + }, + ); + it.effect("fails sendTurn for missing sessions through the typed error channel", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b8aa7d4a9a52..ed50864560f2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -46,6 +46,7 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, + isLoopbackBaseUrl, OpenCodeRuntime, OpenCodeRuntimeError, openCodeQuestionId, @@ -2811,6 +2812,7 @@ export function makeOpenCodeAdapter( const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, + external: server.external, ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); @@ -2845,12 +2847,16 @@ export function makeOpenCodeAdapter( ), ) : undefined; + // Non-loopback external servers retain their own session directory. + const isRemote = server.external && !isLoopbackBaseUrl(server.url); - // Reuse in place only when the session still matches the - // requested cwd; on a cwd change it is forked below instead. + // Remote sessions keep their server-side cwd. Local sessions need a + // matching cwd to be reused; otherwise they are forked below. const reusable = adopted && - (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) + (isRemote || + !adopted.directory || + (yield* sameDirectory(adopted.directory, directory))) ? adopted : undefined; @@ -2871,7 +2877,7 @@ export function makeOpenCodeAdapter( // moved into a git worktree). Fork it into the requested // directory instead of minting an empty one — the fork carries // the full history, so the follow-up keeps its context (#3604). - if (adopted) { + if (adopted && !isRemote) { yield* Effect.logInfo( `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 0c0bf0c28801..cd0f8c53e7d0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -45,6 +45,7 @@ const runtimeMock = { sdkClientInputs: [] as Array<{ baseUrl: string; directory: string; + external: boolean; serverPassword?: string; }>, inventory: { @@ -372,6 +373,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { { baseUrl: "http://127.0.0.1:4301", directory: process.cwd(), + external: false, serverPassword: "secret-password", }, ]); @@ -390,6 +392,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { { baseUrl: "http://127.0.0.1:4301", directory: process.cwd(), + external: false, serverPassword: "environment-password", }, ]); @@ -438,6 +441,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i { baseUrl: "http://127.0.0.1:9999", directory: process.cwd(), + external: true, }, ]); }), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 7fc33d2bb9f2..81639c4bd74c 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -478,12 +478,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu readonly url: string; readonly serverPassword?: string; readonly version: string; + readonly external?: boolean; }) => openCodeRuntime .loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, + external: server.external ?? false, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }), ) diff --git a/apps/server/src/provider/opencodeRuntime.requests.test.ts b/apps/server/src/provider/opencodeRuntime.requests.test.ts new file mode 100644 index 000000000000..f1fb9971376f --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.requests.test.ts @@ -0,0 +1,322 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + OpenCodeSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; + +import { checkOpenCodeProviderStatus } from "./Layers/OpenCodeProvider.ts"; +import { makeOpenCodeAdapter } from "./Layers/OpenCodeAdapter.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OpenCodeDriver } from "./Drivers/OpenCodeDriver.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./Layers/ProviderEventLoggers.ts"; +import { OpenCodeServerOwner } from "./OpenCodeServerOwner.ts"; +import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; + +const directory = "C:\\Users\\example\\project"; +const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); +const requests: Request[] = []; +function assertRequestDirectories(expectedDirectory: string | null) { + for (const request of requests) { + const readRequest = request.method === "GET" || request.method === "HEAD"; + const url = new URL(request.url); + const queryDirectory = readRequest || url.pathname.endsWith("/fork"); + NodeAssert.equal( + url.searchParams.get("directory"), + queryDirectory ? expectedDirectory : null, + request.url, + ); + NodeAssert.equal( + request.headers.get("x-opencode-directory"), + !readRequest && expectedDirectory !== null ? encodeURIComponent(expectedDirectory) : null, + request.url, + ); + NodeAssert.equal( + request.headers.get("authorization"), + `Basic ${btoa("opencode:audit-only-password")}`, + ); + } +} +const noSpawn = ChildProcessSpawner.make(() => Effect.die("This request test must not spawn")); +const testLayer = OpenCodeRuntimeLive.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, noSpawn)), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.succeed(OpenCodeServerOwner, { + withServer: () => Effect.die("Configured external server must not acquire a local server"), + }), + ), +); + +const driverLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "opencode-request-test-", +}).pipe( + Layer.provideMerge(testLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ enableProviderUpdateChecks: false })), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("No external metadata requests are allowed")), + ), + ), +); + +beforeEach(() => { + requests.length = 0; + vi.stubGlobal("fetch", async (input: string | URL | Request) => { + const request = input instanceof Request ? input : new Request(input.toString()); + requests.push(request); + const pathname = new URL(request.url).pathname; + switch (pathname) { + case "/proxy/global/health": + return Response.json({ healthy: true, version: "1.14.19" }); + case "/proxy/provider": + return Response.json({ connected: [], all: [], default: {} }); + case "/proxy/agent": + return Response.json([]); + case "/proxy/skill": + return Response.json([ + { + name: "audit-skill", + location: "/server/skills/audit/SKILL.md", + description: "Synthetic skill", + }, + ]); + case "/proxy/session": + return Response.json({ id: "ses_audit_commit" }); + case "/proxy/session/ses_audit_commit/message": + return Response.json({ + parts: [ + { + type: "text", + text: JSON.stringify({ subject: "Audit request routing", body: "Synthetic result." }), + }, + ], + }); + case "/proxy/session/ses_remote": + return Response.json({ id: "ses_remote", directory: "/var/log" }); + case "/proxy/session/ses_forked": + case "/proxy/session/ses_remote/fork": + return Response.json({ id: "ses_forked", directory }); + case "/proxy/permission": + case "/proxy/question": + case "/proxy/session/ses_remote/children": + case "/proxy/session/ses_forked/children": + return Response.json([]); + case "/proxy/session/ses_remote/abort": + case "/proxy/session/ses_forked/abort": + return Response.json(true); + case "/proxy/event": { + let closed = false; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"type":"server.connected","properties":{}}\n\n'), + ); + request.signal.addEventListener( + "abort", + () => { + if (!closed) { + closed = true; + controller.close(); + } + }, + { once: true }, + ); + }, + cancel() { + closed = true; + }, + }), + { headers: { "Content-Type": "text/event-stream" } }, + ); + } + default: + throw new Error(`Unexpected intercepted request ${request.method} ${pathname}`); + } + }); +}); + +it.layer(driverLayer)("OpenCode driver SDK requests", (it) => { + for (const [label, serverUrl, expectedDirectory] of [ + ["remote", "http://opencode.example.test/proxy", null], + ["external loopback", "http://localhost:4096/proxy", directory], + ["managed", "", directory], + ] as const) { + for (const operation of ["skills", "text generation"] as const) { + it.effect(`routes ${operation} requests for ${label}`, () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-directory-test"), + displayName: "OpenCode fixture", + enabled: true, + environment: [], + config: yield* decodeOpenCodeSettings({ + enabled: true, + binaryPath: "/nonexistent/opencode-request-fixture", + serverUrl, + serverPassword: "audit-only-password", + }), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.provideService(OpenCodeRuntime, { + ...runtime, + runOpenCodeCommand: () => + Effect.succeed({ stdout: "opencode 1.14.19\n", stderr: "", code: 0 }), + startOpenCodeServerProcess: () => + Effect.succeed({ + url: "http://127.0.0.1:4096/proxy", + serverPassword: "audit-only-password", + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }), + }), + ); + // Wait for the snapshot's refresh semaphore before observing workspace requests. + yield* instance.snapshot.refresh; + requests.length = 0; + if (operation === "skills") { + NodeAssert.ok(instance.snapshotForCwd); + const snapshot = yield* instance.snapshotForCwd(directory); + NodeAssert.deepEqual( + snapshot.skills.map((skill) => skill.name), + ["audit-skill"], + ); + NodeAssert.ok( + requests.some((request) => new URL(request.url).pathname === "/proxy/skill"), + ); + } else { + const result = yield* instance.textGeneration.generateCommitMessage({ + cwd: directory, + branch: "audit/request-routing", + stagedSummary: "M README.md", + stagedPatch: "synthetic fixture patch", + modelSelection: { instanceId: instance.instanceId, model: "openai/audit-model" }, + }); + NodeAssert.equal(result.subject, "Audit request routing"); + NodeAssert.ok( + requests.some( + (request) => + new URL(request.url).pathname === "/proxy/session/ses_audit_commit/message", + ), + ); + } + assertRequestDirectories(expectedDirectory); + }).pipe(Effect.scoped), + ); + } + } +}); + +it.layer(driverLayer)("OpenCode resume request routing", (it) => { + for (const [serverUrl, expectedSession, expectedDirectory] of [ + ["http://10.0.0.5:4096/proxy", "ses_remote", null], + ["http://localhost:4096/proxy", "ses_forked", directory], + ] as const) { + it.effect(`preserves remote adoption or local cwd fork at ${serverUrl}`, () => + Effect.gen(function* () { + const settings = yield* decodeOpenCodeSettings({ + enabled: true, + serverUrl, + serverPassword: "audit-only-password", + }); + const adapter = yield* makeOpenCodeAdapter(settings); + const threadId = ThreadId.make("remote-directory-resume-test"); + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + cwd: directory, + resumeCursor: { schemaVersion: 1, sessionId: "ses_remote" }, + }); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: expectedSession, + }); + const forkRequests = requests.filter((request) => + new URL(request.url).pathname.endsWith("/fork"), + ); + NodeAssert.equal(forkRequests.length, expectedSession === "ses_forked" ? 1 : 0); + NodeAssert.equal( + requests.some( + (request) => + request.method === "POST" && new URL(request.url).pathname === "/proxy/session", + ), + false, + ); + const update = requests.find( + (request) => + request.method === "PATCH" && + new URL(request.url).pathname === `/proxy/session/${expectedSession}`, + ); + NodeAssert.ok(update); + const updateBody = yield* Effect.promise(() => update.clone().json()); + NodeAssert.deepEqual(updateBody, { + permission: [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ], + }); + assertRequestDirectories(expectedDirectory); + const eventRequest = requests.find( + (request) => new URL(request.url).pathname === "/proxy/event", + ); + NodeAssert.ok(eventRequest); + yield* adapter.stopSession(threadId); + NodeAssert.equal(eventRequest.signal.aborted, true); + assertRequestDirectories(expectedDirectory); + }).pipe(Effect.scoped), + ); + } +}); + +afterEach(() => vi.unstubAllGlobals()); + +it.layer(testLayer)("OpenCode remote directory requests", (it) => { + for (const serverUrl of [ + "http://opencode.example.test/proxy", + "http://10.0.0.5:4096/proxy", + "http://localhost:4096/proxy", + "http://127.0.0.1:4096/proxy", + ]) { + const isLoopback = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1"); + it.effect(`routes actual initial health and inventory requests at ${serverUrl}`, () => + Effect.gen(function* () { + const settings = yield* decodeOpenCodeSettings({ + enabled: true, + serverUrl, + serverPassword: "audit-only-password", + }); + const snapshot = yield* checkOpenCodeProviderStatus(settings, directory); + NodeAssert.equal(snapshot.version, "1.14.19", snapshot.message ?? undefined); + NodeAssert.deepEqual(requests.map((request) => new URL(request.url).pathname).toSorted(), [ + "/proxy/agent", + "/proxy/global/health", + "/proxy/provider", + "/proxy/skill", + ]); + assertRequestDirectories(isLoopback ? directory : null); + }), + ); + } +}); diff --git a/apps/server/src/provider/opencodeRuntime.sdkClient.test.ts b/apps/server/src/provider/opencodeRuntime.sdkClient.test.ts new file mode 100644 index 000000000000..266507dafc9c --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.sdkClient.test.ts @@ -0,0 +1,104 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { buildOpenCodeSdkClientConfig } from "./opencodeRuntime.ts"; + +const WINDOWS_DIRECTORY = "C:\\Users\\someone\\code\\project"; + +describe("buildOpenCodeSdkClientConfig", () => { + it("omits the directory for an external server on another host", () => { + const config = buildOpenCodeSdkClientConfig({ + baseUrl: "http://10.0.0.5:4096", + directory: WINDOWS_DIRECTORY, + external: true, + }); + + NodeAssert.equal("directory" in config, false); + NodeAssert.equal(config.baseUrl, "http://10.0.0.5:4096"); + }); + + it("keeps the directory for a managed server", () => { + const config = buildOpenCodeSdkClientConfig({ + baseUrl: "http://127.0.0.1:51234", + directory: WINDOWS_DIRECTORY, + external: false, + }); + + NodeAssert.equal(config.directory, WINDOWS_DIRECTORY); + }); + + it("keeps the directory for an external server on this machine", () => { + for (const baseUrl of [ + "http://localhost:4096", + "http://LOCALHOST:4096", + // A fully qualified `localhost` keeps its root label through URL parsing. + "http://localhost.:4096", + // RFC 6761 reserves the whole `.localhost` tree for loopback. + "http://name.localhost:4096", + "http://foo.bar.localhost:4096", + "http://127.0.0.1:4096", + // Shorthand and trailing-dot IPv4 both canonicalise to 127.0.0.1. + "http://127.1:4096", + "http://127.0.0.1.:4096", + "http://127.255.255.254:4096", + "http://[::1]:4096", + // IPv4-mapped loopback, which serialises as [::ffff:7f00:1]. + "http://[::ffff:127.0.0.1]:4096", + "http://0.0.0.0:4096", + "http://[::]:4096", + ]) { + const config = buildOpenCodeSdkClientConfig({ + baseUrl, + directory: WINDOWS_DIRECTORY, + external: true, + }); + + NodeAssert.equal(config.directory, WINDOWS_DIRECTORY, `expected directory for ${baseUrl}`); + } + }); + + it("treats a domain that merely looks like a loopback address as remote", () => { + // `127.example.com` is somebody else's server. Reading the leading `127.` as an + // address is what sends a local Windows path to it. + for (const baseUrl of [ + "http://127.example.com:4096", + "http://localhost.example.com:4096", + "http://notlocalhost:4096", + "http://127.0.0.1.example.com:4096", + ]) { + const config = buildOpenCodeSdkClientConfig({ + baseUrl, + directory: WINDOWS_DIRECTORY, + external: true, + }); + + NodeAssert.equal("directory" in config, false, `expected no directory for ${baseUrl}`); + } + }); + + it("keeps the directory when the base URL cannot be parsed", () => { + const config = buildOpenCodeSdkClientConfig({ + baseUrl: "not a url", + directory: WINDOWS_DIRECTORY, + external: true, + }); + + NodeAssert.equal(config.directory, WINDOWS_DIRECTORY); + }); + + it("still sends the authorization header when the directory is dropped", () => { + const config = buildOpenCodeSdkClientConfig({ + baseUrl: "http://build-server.internal:4096", + directory: WINDOWS_DIRECTORY, + external: true, + serverPassword: "hunter2", + }); + + NodeAssert.equal("directory" in config, false); + NodeAssert.equal( + config.headers?.Authorization, + `Basic ${Buffer.from("opencode:hunter2", "utf8").toString("base64")}`, + ); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 19725d9472ca..d7b2b7481f4a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -247,11 +247,7 @@ export interface OpenCodeRuntimeShape { readonly cwd?: string; readonly maxOutputBytes?: number; }) => Effect.Effect; - readonly createOpenCodeSdkClient: (input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; - }) => OpencodeClient; + readonly createOpenCodeSdkClient: (input: OpenCodeSdkClientInput) => OpencodeClient; readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; @@ -270,6 +266,68 @@ export interface OpenCodeRuntimeShape { }) => Effect.Effect, OpenCodeRuntimeError>; } +export interface OpenCodeSdkClientInput { + readonly baseUrl: string; + readonly directory: string; + // Callers must explicitly identify managed or externally configured servers. + readonly external: boolean; + readonly serverPassword?: string; +} + +// Directory routing treats non-loopback external URLs as remote. This hostname policy +// cannot distinguish SSH tunnels or same-host LAN URLs. Malformed URLs retain the +// local-directory policy. +export function isLoopbackBaseUrl(baseUrl: string): boolean { + let hostname: string; + try { + hostname = new URL(baseUrl).hostname; + } catch { + return true; + } + + return isLoopbackHostname(hostname.toLowerCase()); +} + +// URL brackets IPv6 literals and normalizes IPv4 spellings such as `127.1`. +// Domains such as `127.example.com` remain names, not IPv4 literals. +function isLoopbackHostname(hostname: string): boolean { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + const address = hostname.slice(1, -1); + // An IPv4-mapped address is serialised in hex, so `::ffff:127.0.0.1` arrives as + // `::ffff:7f00:1`. Match the mapped 127.0.0.0/8 range, not the readable spelling. + return ( + address === "::1" || address === "::" || /^::ffff:7f[\da-f]{2}:[\da-f]{1,4}$/u.test(address) + ); + } + + const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname); + if (octets) { + // Only a real IPv4 literal reaches here, so the first octet can be trusted. + return octets[1] === "127" || hostname === "0.0.0.0"; + } + + // RFC 6761 reserves `localhost` and everything under it for loopback. `URL` keeps the + // root label on a domain, so `localhost.` arrives with its trailing dot still attached. + const name = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; + return name === "localhost" || name.endsWith(".localhost"); +} + +export function buildOpenCodeSdkClientConfig(input: OpenCodeSdkClientInput) { + const sendDirectory = !input.external || isLoopbackBaseUrl(input.baseUrl); + return { + baseUrl: input.baseUrl, + ...(sendDirectory ? { directory: input.directory } : {}), + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true as const, + }; +} + function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { @@ -627,18 +685,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, - }); + createOpencodeClient(buildOpenCodeSdkClientConfig(input)); const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => Effect.gen(function* () { @@ -821,6 +868,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { createOpenCodeSdkClient({ baseUrl: url, directory: input.directory, + external: false, ...(serverPassword !== undefined ? { serverPassword } : {}), }), ); @@ -848,6 +896,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { createOpenCodeSdkClient({ baseUrl: serverUrl, directory: input.directory, + external: true, ...(serverPassword !== undefined ? { serverPassword } : {}), }), ).pipe( diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e0e960422b18..162a7d11131d 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -209,6 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, + external: openCodeSettings.serverUrl.length > 0, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({