From cb6447ca9708bf272714f4d07d0859884ba296c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:12 -0700 Subject: [PATCH 1/4] feat(server): detect OpenCode 1.x or 2.x per instance and route by it The OpenCode driver now probes each instance's version once and remembers it: `opencode --version` for a local binary, `/api/info` then `/global/health` for a configured server (JSON only, since each version serves HTML on the other's path; a 401 is reported as an auth error). A failed probe is never remembered, and status checks re-probe so an in-place upgrade re-routes. The orchestration adapter goes through a runtime selector. Only the 1.x runtime exists, so a detected 2.x gets a temporary "not supported yet" error until the OpenCode 2 runtime lands. The bundled compatibility policy gives 1.x limited support; a test pins that a ready 1.x instance stays ready and selectable beside that advisory. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Drivers/OpenCodeDriver.ts | 74 +++++++- .../provider/Layers/OpenCodeProvider.test.ts | 61 ++++++- .../src/provider/Layers/OpenCodeProvider.ts | 104 +++++------ .../src/provider/opencodeVersionProbe.test.ts | 119 ++++++++++++ .../src/provider/opencodeVersionProbe.ts | 171 ++++++++++++++++++ .../provider/providerCompatibility.test.ts | 8 + .../testFixtures/opencodeProbeResponses.ts | 69 +++++++ 7 files changed, 546 insertions(+), 60 deletions(-) create mode 100644 apps/server/src/provider/opencodeVersionProbe.test.ts create mode 100644 apps/server/src/provider/opencodeVersionProbe.ts create mode 100644 apps/server/src/provider/testFixtures/opencodeProbeResponses.ts diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index f5c6de2c161e..14e8a1359dd3 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -28,6 +28,11 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; +import { + ProviderAdapterCapabilitiesError, + ProviderAdapterOpenSessionError, + type ProviderAdapterV2Shape, +} from "../../orchestration-v2/ProviderAdapter.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; @@ -38,7 +43,13 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "../Layers/OpenCodeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime, loadOpenCodeCommands } from "../opencodeRuntime.ts"; +import { OpenCodeRuntime, OpenCodeRuntimeError, loadOpenCodeCommands } from "../opencodeRuntime.ts"; +import { + makeOpenCodeRuntimeProbe, + OPENCODE_2_UNSUPPORTED_MESSAGE, + probeOpenCodeRuntime, + type ProbedOpenCode, +} from "../opencodeVersionProbe.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { defaultProviderContinuationIdentity, @@ -80,6 +91,50 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }, }); +/** + * Routes each adapter call to the runtime the instance's probe detected. Only the 1.x runtime + * exists so far, so a detected 2.x is refused instead of spoken to in the 1.x protocol. A failed + * probe keeps the 1.x path, whose own server checks report the failure, and is retried next call. + */ +function selectOpenCodeRuntimeAdapter(input: { + readonly probe: Effect.Effect; + readonly v1: ProviderAdapterV2Shape; +}): ProviderAdapterV2Shape { + const unsupported = new OpenCodeRuntimeError({ + operation: "selectOpenCodeRuntime", + detail: OPENCODE_2_UNSUPPORTED_MESSAGE, + }); + const selectV1 = input.probe.pipe( + Effect.map((probed) => probed.generation === "v1"), + Effect.orElseSucceed(() => true), + ); + const onV1 = (use: Effect.Effect, refuse: E) => + Effect.flatMap(selectV1, (isV1) => (isV1 ? use : Effect.fail(refuse))); + return { + instanceId: input.v1.instanceId, + driver: DRIVER_KIND, + getCapabilities: () => + onV1( + input.v1.getCapabilities(), + new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause: unsupported }), + ), + planSelectionTransition: (transition) => + onV1( + input.v1.planSelectionTransition(transition), + new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause: unsupported }), + ), + openSession: (session) => + onV1( + input.v1.openSession(session), + new ProviderAdapterOpenSessionError({ + driver: DRIVER_KIND, + providerSessionId: session.providerSessionId, + cause: unsupported, + }), + ), + }; +} + export type OpenCodeDriverEnv = | OpenCodeAdapterV2DriverEnv | BackgroundPolicy.BackgroundPolicy @@ -133,7 +188,12 @@ export const OpenCodeDriver: ProviderDriver ), ); - const orchestrationAdapter = yield* OpenCodeAdapterV2Driver.create({ + const runtimeProbe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime(openCodeRuntime, effectiveConfig, processEnv).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ); + const openCodeV1Adapter = yield* OpenCodeAdapterV2Driver.create({ instanceId, displayName, accentColor, @@ -151,6 +211,10 @@ export const OpenCodeDriver: ProviderDriver }), ), ); + const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ + probe: runtimeProbe.get, + v1: openCodeV1Adapter, + }); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, directory: serverConfig.cwd, @@ -165,7 +229,11 @@ export const OpenCodeDriver: ProviderDriver const checkProvider = Effect.all( { - provider: checkOpenCodeProviderStatus(effectiveConfig, serverConfig.cwd, processEnv), + provider: checkOpenCodeProviderStatus( + effectiveConfig, + serverConfig.cwd, + runtimeProbe.refresh, + ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, serverUrl: effectiveConfig.serverUrl, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 55e159197e6b..f10cc8d33efe 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -27,6 +27,12 @@ import { } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; +import { OPENCODE_2_UNSUPPORTED_MESSAGE, probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "../testFixtures/opencodeProbeResponses.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; @@ -324,6 +330,7 @@ const checkProvider = Effect.fn("checkProvider")(function* ( settings: OpenCodeSettings, cwd = process.cwd(), environment?: NodeJS.ProcessEnv, + server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), ) { return yield* Effect.scoped( Effect.gen(function* () { @@ -333,7 +340,10 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), ...(environment ? { environment } : {}), }); - return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe( + const probe = probeOpenCodeRuntime(OpenCodeRuntimeTestDouble, settings, environment).pipe( + Effect.provideService(HttpClient.HttpClient, server), + ); + return yield* checkOpenCodeProviderStatus(settings, cwd, probe).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ); }), @@ -522,6 +532,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("refuses a local OpenCode 2 binary before starting a 1.x server for it", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.equal(snapshot.message, OPENCODE_2_UNSUPPORTED_MESSAGE); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + it.effect("uses an environment-only password for local inventory", () => Effect.gen(function* () { yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { @@ -585,6 +607,43 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i }), ); + it.effect("refuses a configured OpenCode 2 server before speaking 1.x to it", () => + Effect.gen(function* () { + const settings = makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }); + const snapshot = yield* checkProvider( + settings, + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.equal(snapshot.message, OPENCODE_2_UNSUPPORTED_MESSAGE); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + + it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + it.effect("rejects an unsupported server before loading inventory", () => Effect.gen(function* () { runtimeMock.state.connectionError = new Error( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 0495f8b30737..77edffb6126a 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -16,7 +16,6 @@ import { buildServerProvider, COMPACT_SLASH_COMMAND, nonEmptyTrimmed, - parseGenericCliVersion, providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; @@ -25,7 +24,9 @@ import { OpenCodeRuntime, openCodeRuntimeErrorDetail, type OpenCodeInventory, + type OpenCodeRuntimeError, } from "../opencodeRuntime.ts"; +import { OPENCODE_2_UNSUPPORTED_MESSAGE, type ProbedOpenCode } from "../opencodeVersionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; @@ -33,7 +34,6 @@ const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause?: unknown; @@ -381,10 +381,15 @@ export const makePendingOpenCodeProvider = ( }); }); +/** + * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, + * the version endpoints for a configured server. The status check refreshes it, so an in-place + * upgrade re-routes the instance. + */ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, - environment?: NodeJS.ProcessEnv, + probeRuntime: Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -392,7 +397,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu > { const openCodeRuntime = yield* OpenCodeRuntime; const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -441,58 +445,46 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu }); } - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: openCodeSettings.binaryPath, - args: ["--version"], - environment: resolvedEnvironment, - }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeProbeError({ - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, - }), - ), - }), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } + const probedExit = yield* Effect.exit( + probeRuntime.pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + ); + if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); + const probed = probedExit.value; + // Temporary until the OpenCode 2 runtime lands: 2.x must never reach the 1.x server path. + if (probed.generation === "v2") { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version: probed.version, + status: "error", + auth: { status: "unknown" }, + message: OPENCODE_2_UNSUPPORTED_MESSAGE, + }, + }); + } + let version: string | null = probed.version; + if (compareSemverVersions(probed.version, MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version: probed.version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${probed.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); } const loadInventory = (server: { diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts new file mode 100644 index 000000000000..8429e29fe3ed --- /dev/null +++ b/apps/server/src/provider/opencodeVersionProbe.test.ts @@ -0,0 +1,119 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; + +import { OpenCodeRuntimeError, type OpenCodeRuntimeShape } from "./opencodeRuntime.ts"; +import { + classifyOpenCodeCliVersion, + makeOpenCodeRuntimeProbe, + probeOpenCodeRuntime, +} from "./opencodeVersionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "./testFixtures/opencodeProbeResponses.ts"; + +const external = (serverPassword: string) => ({ + binaryPath: "opencode", + serverUrl: "http://127.0.0.1:4096/", + serverPassword, +}); + +describe("OpenCode version probe", () => { + it("classifies the recorded `opencode --version` output of both versions", () => { + assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", + }); + assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { + generation: "v2", + version: "2.0.18", + }); + assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); + }); + + it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => + Effect.gen(function* () { + const paths: Array = []; + const probed = yield* probeOpenCodeRuntime({} as OpenCodeRuntimeShape, external("pw")).pipe( + Effect.provideService( + HttpClient.HttpClient, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), + ), + ); + assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); + assert.deepStrictEqual(paths, ["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/api/info"]); + }), + ); + + it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => + Effect.gen(function* () { + const paths: Array = []; + const probed = yield* probeOpenCodeRuntime({} as OpenCodeRuntimeShape, external("pw")).pipe( + Effect.provideService( + HttpClient.HttpClient, + replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), + ), + ); + assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); + assert.deepStrictEqual(paths, ["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/api/info", "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/global/health"]); + }), + ); + + it.effect("reports a rejected password instead of guessing a version", () => + Effect.gen(function* () { + for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { + const paths: Array = []; + const error = yield* probeOpenCodeRuntime( + {} as OpenCodeRuntimeShape, + external("wrong"), + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + replayOpenCodeServer(responses, "pw", paths), + ), + Effect.flip, + ); + assert.match(error.detail, /401 Unauthorized/); + assert.deepStrictEqual(paths, ["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/api/info"]); + } + }), + ); + + it.effect("remembers a probed runtime but never a failure", () => + Effect.gen(function* () { + const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; + let calls = 0; + const runtime: Pick = { + runOpenCodeCommand: () => { + const stdout = outputs[calls++]; + return stdout + ? Effect.succeed({ stdout, stderr: "", code: 0 }) + : Effect.fail(new OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" })); + }, + }; + const probe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime(runtime, { + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + replayOpenCodeServer(OPENCODE_2_RESPONSES, ""), + ), + ), + ); + + yield* Effect.flip(probe.get); + assert.strictEqual((yield* probe.get).generation, "v2"); + assert.strictEqual((yield* probe.get).generation, "v2"); + assert.strictEqual(calls, 2); + // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. + assert.strictEqual((yield* probe.refresh).generation, "v1"); + assert.strictEqual((yield* probe.get).generation, "v1"); + assert.strictEqual(calls, 3); + }), + ); +}); diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts new file mode 100644 index 000000000000..6e36db3e9a2b --- /dev/null +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -0,0 +1,171 @@ +/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ +import { parseSemver } from "@t3tools/shared/semver"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { + MINIMUM_OPENCODE_VERSION, + OpenCodeRuntimeError, + openCodeRuntimeErrorDetail, + type OpenCodeRuntimeShape, +} from "./opencodeRuntime.ts"; +import { parseGenericCliVersion } from "./providerSnapshot.ts"; + +export interface ProbedOpenCode { + readonly generation: "v1" | "v2"; + readonly version: string; +} + +// Temporary: the OpenCode 2 runtime replaces this refusal. +export const OPENCODE_2_UNSUPPORTED_MESSAGE = + "OpenCode 2 is not supported yet. Use OpenCode 1.x until T3 Code adds OpenCode 2 support."; + +const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; +const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; +const decodeApiInfo = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), +); +const decodeGlobalHealth = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), +); + +const probeError = (detail: string, cause?: unknown) => + new OpenCodeRuntimeError({ + operation: "probeOpenCodeVersion", + detail, + ...(cause === undefined ? {} : { cause }), + }); + +function probed(version: string | null | undefined): ProbedOpenCode | undefined { + const major = parseSemver(version ?? "")?.major; + if (!version || major === undefined) return undefined; + return { generation: major >= 2 ? "v2" : "v1", version }; +} + +/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ +export const classifyOpenCodeCliVersion = (output: string) => + probed(parseGenericCliVersion(output)); + +/** + * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a + * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with + * a 401 on either path, so a 401 says nothing about the version. + */ +function classifyOpenCodeProbeResponse( + path: "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/api/info" | "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/global/health", + response: { + readonly status: number; + readonly contentType: string | undefined; + readonly body: string; + }, +): ProbedOpenCode | "unauthorized" | undefined { + if (response.status === 401) return "unauthorized"; + const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); + if (response.status !== 200 || mediaType !== "application/json") return undefined; + const body = (path === "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/api/info" ? decodeApiInfo : decodeGlobalHealth)(response.body); + return probed(Option.getOrUndefined(body)?.version); +} + +const probeOpenCodeBinary = ( + runtime: Pick, + input: { readonly binaryPath: string; readonly environment?: NodeJS.ProcessEnv }, +) => + Effect.suspend(() => runtime.runOpenCodeCommand({ ...input, args: ["--version"] })).pipe( + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + probeError( + `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + ), + ), + }), + Effect.flatMap(({ stdout }) => { + const result = classifyOpenCodeCliVersion(stdout); + return result + ? Effect.succeed(result) + : Effect.fail( + probeError( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + ); + }), + Effect.withSpan("probeOpenCodeBinary"), + ); + +const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( + serverUrl: string, + serverPassword: string, +) { + const client = yield* HttpClient.HttpClient; + const baseUrl = serverUrl.trim().replace(/\/+$/, ""); + for (const path of ["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/api/info", "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/global/health"] as const) { + const request = HttpClientRequest.get(`${baseUrl}${path}`); + const result = yield* client + .execute( + serverPassword ? HttpClientRequest.basicAuth(request, "opencode", serverPassword) : request, + ) + .pipe( + Effect.flatMap((response) => + Effect.map(response.text, (body) => + classifyOpenCodeProbeResponse(path, { + status: response.status, + contentType: response.headers["content-type"], + body, + }), + ), + ), + Effect.mapError((cause) => + probeError(openCodeRuntimeErrorDetail(cause.cause ?? cause), cause), + ), + Effect.timeoutOrElse({ + duration: OPENCODE_SERVER_PROBE_TIMEOUT, + orElse: () => + Effect.fail(probeError("Timed out while checking the OpenCode server version.")), + }), + ); + if (result === "unauthorized") { + return yield* probeError("401 Unauthorized: the OpenCode server rejected the password."); + } + if (result !== undefined) return result; + } + return yield* probeError( + `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ); +}); + +/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ +export const probeOpenCodeRuntime = ( + runtime: Pick, + settings: { + readonly binaryPath: string; + readonly serverUrl: string; + readonly serverPassword: string; + }, + environment?: NodeJS.ProcessEnv, +) => + settings.serverUrl.trim().length > 0 + ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) + : probeOpenCodeBinary(runtime, { + binaryPath: settings.binaryPath, + ...(environment === undefined ? {} : { environment }), + }); + +/** + * One instance's runtime, remembered after the first successful probe. Settings changes rebuild + * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A + * failed probe is never remembered. + */ +export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => + Effect.map( + Cache.makeWith(() => probe, { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }), + (cache) => ({ get: Cache.get(cache, undefined), refresh: Cache.refresh(cache, undefined) }), + ); diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index fe3f868e4732..3254949ad3d9 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -87,6 +87,14 @@ describe("provider compatibility", () => { assert.strictEqual(advisory?.status, expected, `OpenCode ${version}`); assert.strictEqual(advisory?.recommendedRange, ">=2.0.18"); } + // The advisory rides beside the probe: a ready 1.x instance stays ready and selectable. + const ready = applyProviderCompatibility( + { ...provider, driver: opencode, version: "1.18.33", status: "ready", message: undefined }, + undefined, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + assert.strictEqual(ready.status, "ready"); + assert.strictEqual(ready.compatibilityAdvisory?.status, "graceful"); }); it("compares Cursor build dates without treating semver prereleases as stable", () => { diff --git a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts new file mode 100644 index 000000000000..c9d3f7fdc4f6 --- /dev/null +++ b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts @@ -0,0 +1,69 @@ +/** + * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an + * `HttpClient` so the version probe runs against real bytes. + */ +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +interface RecordedResponse { + readonly status: number; + readonly contentType?: string; + readonly body: string; +} + +// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the +// first lines; the classifier rejects them on content type alone. +const SPA_HTML = + '\n\n'; + +export const OPENCODE_2_RESPONSES = { + "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/api/info": { + status: 200, + contentType: "application/json", + body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}', + }, + "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/global/health": { status: 200, contentType: "text/html", body: SPA_HTML }, + unauthorized: { + status: 401, + contentType: "application/json", + body: '{"_tag":"UnauthorizedError","message":"Authentication required"}', + }, +} satisfies Record; + +export const OPENCODE_1_RESPONSES = { + "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/api/info": { status: 200, contentType: "text/html", body: SPA_HTML }, + "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/global/health": { + status: 200, + contentType: "application/json", + body: '{"healthy":true,"version":"1.18.32"}', + }, + // 1.x also answers a wrong password with an empty 401 on every path. + unauthorized: { status: 401, body: "" }, +} satisfies Record; + +/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ +export function replayOpenCodeServer( + responses: typeof OPENCODE_1_RESPONSES | typeof OPENCODE_2_RESPONSES, + password: string, + requestedPaths: Array = [], +) { + const expected = password ? `Basic ${btoa(`opencode:${password}`)}` : undefined; + return HttpClient.make((request) => { + const path = new URL(request.url).pathname; + requestedPaths.push(path); + const recorded: RecordedResponse = + (path === "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/api/info" || path === "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/global/health") && + request.headers.authorization === expected + ? responses[path] + : responses.unauthorized; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(recorded.body, { + status: recorded.status, + headers: recorded.contentType ? { "content-type": recorded.contentType } : {}, + }), + ), + ); + }); +} From f6db235e11ef9c6dc8ace97c64a99d9dde60f86f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:12 -0700 Subject: [PATCH 2/4] fix(server): keep OpenCode 2 off every 1.x path and harden the version probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the OpenCode runtime probe: - Send the server password as UTF-8 Basic auth, as the 1.x client does. `HttpClientRequest.basicAuth` uses Latin-1 `btoa`, which threw on `€` and sent the wrong bytes for `ä`, so such passwords broke 1.x servers. - Refuse OpenCode 2 in workspace inventory and text generation too. Both started or connected to a server themselves, so a 2.x instance still ran a 1.x client against it (and migrated its data directory). - Capability and selection reads use the last successful probe instead of waiting on one, so an unreachable server no longer adds 5 s to each. - Require `pid` in `/api/info`, as real 2.x servers send it, so other JSON is not taken for OpenCode 2. - Server probe errors use fixed text; the underlying failure, which can carry the configured URL or credentials, stays in `cause` only. - The probe reads `OpenCodeRuntime` from the environment. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider/Drivers/OpenCodeDriver.test.ts | 94 ++++++++++++ .../src/provider/Drivers/OpenCodeDriver.ts | 114 ++++++++++----- .../provider/Layers/OpenCodeProvider.test.ts | 3 +- .../src/provider/opencodeVersionProbe.test.ts | 125 +++++++++++----- .../src/provider/opencodeVersionProbe.ts | 135 +++++++++++------- .../testFixtures/opencodeProbeResponses.ts | 5 +- 6 files changed, 352 insertions(+), 124 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OpenCodeDriver.test.ts diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts new file mode 100644 index 000000000000..dfdf143dedce --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -0,0 +1,94 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId, type OpenCodeSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../opencodeRuntime.ts"; +import { OPENCODE_2_UNSUPPORTED_MESSAGE } from "../opencodeVersionProbe.ts"; +import { OpenCodeDriver } from "./OpenCodeDriver.ts"; + +const serverStarts: Array = []; +const reachedServer = (operation: string) => + Effect.sync(() => serverStarts.push(operation)).pipe( + Effect.andThen( + Effect.fail(new OpenCodeRuntimeError({ operation, detail: "reached a 1.x server path" })), + ), + ); +// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. +const openCode2Runtime = { + runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), + startOpenCodeServerProcess: () => reachedServer("start"), + connectToOpenCodeServer: () => reachedServer("connect"), +} as unknown as OpenCodeRuntimeShape; + +const layer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), + IdAllocator.layer, + ServerSettingsService.layerTest(), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + Layer.succeed(OpenCodeRuntime, openCode2Runtime), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const create = (config: Partial, http: HttpClient.HttpClient) => + OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-test"), + displayName: undefined, + environment: [], + enabled: true, + config: { ...OpenCodeDriver.defaultConfig(), ...config }, + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + +const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); + +it.layer(layer)("OpenCodeDriver runtime selection", (it) => { + it.effect("refuses OpenCode 2 on every path that would start a 1.x server", () => + Effect.gen(function* () { + serverStarts.length = 0; + const instance = yield* create({}, noHttp); + + const workspace = yield* Effect.flip(instance.snapshotForCwd!(process.cwd())); + assert.strictEqual( + (workspace.cause as { readonly detail?: string }).detail, + OPENCODE_2_UNSUPPORTED_MESSAGE, + ); + const title = yield* Effect.flip( + instance.textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "hello", + modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, + }), + ); + assert.strictEqual(title.detail, OPENCODE_2_UNSUPPORTED_MESSAGE); + assert.deepStrictEqual(serverStarts, []); + }).pipe(Effect.scoped), + ); + + it.effect("answers capability reads without waiting on an unreachable server", () => + Effect.gen(function* () { + const hang = HttpClient.make(() => Effect.never); + const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); + + // No probe has succeeded yet, and the server never answers: the 1.x default applies. + const capabilities = yield* instance.orchestrationAdapter + .getCapabilities() + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.isDefined(capabilities.pollUnsafe()); + yield* Fiber.join(capabilities); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 14e8a1359dd3..9acd22a496ea 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,9 +12,10 @@ * * @module provider/Drivers/OpenCodeDriver */ -import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -34,6 +35,7 @@ import { type ProviderAdapterV2Shape, } from "../../orchestration-v2/ProviderAdapter.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; import { @@ -91,50 +93,84 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }, }); +const openCode2Unsupported = () => + new OpenCodeRuntimeError({ + operation: "selectOpenCodeRuntime", + detail: OPENCODE_2_UNSUPPORTED_MESSAGE, + }); + +type OpenCodeRuntimeProbe = Effect.Success< + ReturnType> +>; + +/** + * Runs `use` only when the instance is not 2.x, since only the 1.x runtime exists so far. A failed + * probe keeps the 1.x path, whose own server checks report the failure. + */ +function onOpenCodeV1( + probed: Effect.Effect, + use: Effect.Effect, + refuse: (cause: OpenCodeRuntimeError) => E, +): Effect.Effect { + return probed.pipe( + Effect.orElseSucceed(() => undefined), + Effect.flatMap((result) => + result?.generation === "v2" ? Effect.fail(refuse(openCode2Unsupported())) : use, + ), + ); +} + /** - * Routes each adapter call to the runtime the instance's probe detected. Only the 1.x runtime - * exists so far, so a detected 2.x is refused instead of spoken to in the 1.x protocol. A failed - * probe keeps the 1.x path, whose own server checks report the failure, and is retried next call. + * Routes each adapter call to the runtime the instance's probe detected. Capability and selection + * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a + * slow server. Opening a session waits for a probe, so a 2.x is refused before it is spoken to. */ function selectOpenCodeRuntimeAdapter(input: { - readonly probe: Effect.Effect; + readonly probe: OpenCodeRuntimeProbe; readonly v1: ProviderAdapterV2Shape; }): ProviderAdapterV2Shape { - const unsupported = new OpenCodeRuntimeError({ - operation: "selectOpenCodeRuntime", - detail: OPENCODE_2_UNSUPPORTED_MESSAGE, - }); - const selectV1 = input.probe.pipe( - Effect.map((probed) => probed.generation === "v1"), - Effect.orElseSucceed(() => true), - ); - const onV1 = (use: Effect.Effect, refuse: E) => - Effect.flatMap(selectV1, (isV1) => (isV1 ? use : Effect.fail(refuse))); + const lastSuccess = Effect.map(input.probe.lastSuccess, Option.getOrUndefined); + const capabilitiesError = (cause: OpenCodeRuntimeError) => + new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause }); return { instanceId: input.v1.instanceId, driver: DRIVER_KIND, - getCapabilities: () => - onV1( - input.v1.getCapabilities(), - new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause: unsupported }), - ), + getCapabilities: () => onOpenCodeV1(lastSuccess, input.v1.getCapabilities(), capabilitiesError), planSelectionTransition: (transition) => - onV1( - input.v1.planSelectionTransition(transition), - new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause: unsupported }), - ), + onOpenCodeV1(lastSuccess, input.v1.planSelectionTransition(transition), capabilitiesError), openSession: (session) => - onV1( + onOpenCodeV1( + input.probe.get, input.v1.openSession(session), - new ProviderAdapterOpenSessionError({ - driver: DRIVER_KIND, - providerSessionId: session.providerSessionId, - cause: unsupported, - }), + (cause) => + new ProviderAdapterOpenSessionError({ + driver: DRIVER_KIND, + providerSessionId: session.providerSessionId, + cause, + }), ), }; } +/** Text generation starts or connects to a server per call, so a 2.x is refused first. */ +function selectOpenCodeRuntimeTextGeneration( + probe: OpenCodeRuntimeProbe, + v1: TextGeneration["Service"], +): TextGeneration["Service"] { + const refuse = (operation: string) => (cause: OpenCodeRuntimeError) => + new TextGenerationError({ operation, detail: cause.detail, cause }); + return { + generateCommitMessage: (input) => + onOpenCodeV1(probe.get, v1.generateCommitMessage(input), refuse("generateCommitMessage")), + generatePrContent: (input) => + onOpenCodeV1(probe.get, v1.generatePrContent(input), refuse("generatePrContent")), + generateBranchName: (input) => + onOpenCodeV1(probe.get, v1.generateBranchName(input), refuse("generateBranchName")), + generateThreadTitle: (input) => + onOpenCodeV1(probe.get, v1.generateThreadTitle(input), refuse("generateThreadTitle")), + }; +} + export type OpenCodeDriverEnv = | OpenCodeAdapterV2DriverEnv | BackgroundPolicy.BackgroundPolicy @@ -189,8 +225,9 @@ export const OpenCodeDriver: ProviderDriver ); const runtimeProbe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime(openCodeRuntime, effectiveConfig, processEnv).pipe( + probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), ), ); const openCodeV1Adapter = yield* OpenCodeAdapterV2Driver.create({ @@ -212,7 +249,7 @@ export const OpenCodeDriver: ProviderDriver ), ); const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ - probe: runtimeProbe.get, + probe: runtimeProbe, v1: openCodeV1Adapter, }); const serverOwner = yield* OpenCodeServerOwner.make({ @@ -223,8 +260,11 @@ export const OpenCodeDriver: ProviderDriver : {}), environment: processEnv, }); - const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + const textGeneration = selectOpenCodeRuntimeTextGeneration( + runtimeProbe, + yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ), ); const checkProvider = Effect.all( @@ -352,7 +392,11 @@ export const OpenCodeDriver: ProviderDriver ? snapshot.getSnapshot : Effect.all([ snapshot.getSnapshot, - loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + onOpenCodeV1( + runtimeProbe.get, + loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + (cause) => cause, + ), ]).pipe( Effect.map(([machineSnapshot, { skills, commands }]) => ({ ...machineSnapshot, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index f10cc8d33efe..2cf511ad84a4 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -340,8 +340,9 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), ...(environment ? { environment } : {}), }); - const probe = probeOpenCodeRuntime(OpenCodeRuntimeTestDouble, settings, environment).pipe( + const probe = probeOpenCodeRuntime(settings, environment).pipe( Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime, OpenCodeRuntimeTestDouble), ); return yield* checkOpenCodeProviderStatus(settings, cwd, probe).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts index 8429e29fe3ed..2a018f2e6ccf 100644 --- a/apps/server/src/provider/opencodeVersionProbe.test.ts +++ b/apps/server/src/provider/opencodeVersionProbe.test.ts @@ -1,8 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientError } from "effect/unstable/http"; -import { OpenCodeRuntimeError, type OpenCodeRuntimeShape } from "./opencodeRuntime.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "./opencodeRuntime.ts"; import { classifyOpenCodeCliVersion, makeOpenCodeRuntimeProbe, @@ -14,11 +18,16 @@ import { replayOpenCodeServer, } from "./testFixtures/opencodeProbeResponses.ts"; -const external = (serverPassword: string) => ({ - binaryPath: "opencode", - serverUrl: "http://127.0.0.1:4096/", - serverPassword, -}); +const noBinary = { + runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), +} as unknown as OpenCodeRuntimeShape; + +const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => + probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(OpenCodeRuntime, noBinary), + ); +const SERVER_URL = "http://127.0.0.1:4096/"; describe("OpenCode version probe", () => { it("classifies the recorded `opencode --version` output of both versions", () => { @@ -36,11 +45,10 @@ describe("OpenCode version probe", () => { it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => Effect.gen(function* () { const paths: Array = []; - const probed = yield* probeOpenCodeRuntime({} as OpenCodeRuntimeShape, external("pw")).pipe( - Effect.provideService( - HttpClient.HttpClient, - replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), - ), + const probed = yield* probeServer( + SERVER_URL, + "pw", + replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), ); assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); assert.deepStrictEqual(paths, ["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/api/info"]); @@ -50,11 +58,10 @@ describe("OpenCode version probe", () => { it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => Effect.gen(function* () { const paths: Array = []; - const probed = yield* probeOpenCodeRuntime({} as OpenCodeRuntimeShape, external("pw")).pipe( - Effect.provideService( - HttpClient.HttpClient, - replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), - ), + const probed = yield* probeServer( + SERVER_URL, + "pw", + replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), ); assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); assert.deepStrictEqual(paths, ["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/api/info", "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/global/health"]); @@ -65,15 +72,8 @@ describe("OpenCode version probe", () => { Effect.gen(function* () { for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { const paths: Array = []; - const error = yield* probeOpenCodeRuntime( - {} as OpenCodeRuntimeShape, - external("wrong"), - ).pipe( - Effect.provideService( - HttpClient.HttpClient, - replayOpenCodeServer(responses, "pw", paths), - ), - Effect.flip, + const error = yield* Effect.flip( + probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), ); assert.match(error.detail, /401 Unauthorized/); assert.deepStrictEqual(paths, ["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/api/info"]); @@ -81,32 +81,89 @@ describe("OpenCode version probe", () => { }), ); + it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => + Effect.gen(function* () { + for (const [responses, password, generation] of [ + [OPENCODE_1_RESPONSES, "pässwörd", "v1"], + [OPENCODE_2_RESPONSES, "pass€word", "v2"], + ] as const) { + const probed = yield* probeServer( + SERVER_URL, + password, + replayOpenCodeServer(responses, password), + ); + assert.strictEqual(probed.generation, generation); + } + }), + ); + + it.effect("does not take other JSON at /api/info for OpenCode 2", () => + Effect.gen(function* () { + const impostor = { + ...OPENCODE_1_RESPONSES, + "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/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, + "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/global/health": OPENCODE_1_RESPONSES["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/api/info"], + }; + const error = yield* Effect.flip( + probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), + ); + assert.match(error.detail, /did not identify itself as OpenCode/); + }), + ); + + it.effect("never puts the configured URL or its credentials in an error detail", () => + Effect.gen(function* () { + const secret = "userinfo-secret"; + const unreachable = HttpClient.make((request) => + Effect.die(`should not be reached for ${request.url}`), + ); + // A fetch failure that echoes the full request URL, as Node's does. + const refused = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: `connect ECONNREFUSED ${request.url}`, + }), + }), + ), + ); + for (const [serverUrl, http] of [ + [`127.0.0.1:4096?token=${secret}`, unreachable], + [`localhost:4096/${secret}`, unreachable], + [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], + ] as const) { + const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); + assert.notInclude(error.detail, secret, serverUrl); + assert.notInclude(error.detail, "10.0.0.1", serverUrl); + } + }), + ); + it.effect("remembers a probed runtime but never a failure", () => Effect.gen(function* () { const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; let calls = 0; - const runtime: Pick = { + const runtime = { runOpenCodeCommand: () => { const stdout = outputs[calls++]; return stdout ? Effect.succeed({ stdout, stderr: "", code: 0 }) : Effect.fail(new OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" })); }, - }; + } as unknown as OpenCodeRuntimeShape; const probe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime(runtime, { - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - }).pipe( + probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( + Effect.provideService(OpenCodeRuntime, runtime), Effect.provideService( HttpClient.HttpClient, - replayOpenCodeServer(OPENCODE_2_RESPONSES, ""), + HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), ), ), ); yield* Effect.flip(probe.get); + assert.isTrue((yield* probe.lastSuccess)._tag === "None"); assert.strictEqual((yield* probe.get).generation, "v2"); assert.strictEqual((yield* probe.get).generation, "v2"); assert.strictEqual(calls, 2); diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts index 6e36db3e9a2b..0d0e00184388 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -10,9 +10,8 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { MINIMUM_OPENCODE_VERSION, + OpenCodeRuntime, OpenCodeRuntimeError, - openCodeRuntimeErrorDetail, - type OpenCodeRuntimeShape, } from "./opencodeRuntime.ts"; import { parseGenericCliVersion } from "./providerSnapshot.ts"; @@ -27,20 +26,14 @@ export const OPENCODE_2_UNSUPPORTED_MESSAGE = const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; +// 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. const decodeApiInfo = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Struct({ version: Schema.String })), + Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), ); const decodeGlobalHealth = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), ); -const probeError = (detail: string, cause?: unknown) => - new OpenCodeRuntimeError({ - operation: "probeOpenCodeVersion", - detail, - ...(cause === undefined ? {} : { cause }), - }); - function probed(version: string | null | undefined): ProbedOpenCode | undefined { const major = parseSemver(version ?? "")?.major; if (!version || major === undefined) return undefined; @@ -67,48 +60,70 @@ function classifyOpenCodeProbeResponse( if (response.status === 401) return "unauthorized"; const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); if (response.status !== 200 || mediaType !== "application/json") return undefined; - const body = (path === "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/api/info" ? decodeApiInfo : decodeGlobalHealth)(response.body); - return probed(Option.getOrUndefined(body)?.version); + const version: Option.Option = + path === "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/api/info" + ? Option.map(decodeApiInfo(response.body), (info) => info.version) + : Option.map(decodeGlobalHealth(response.body), (health) => health.version); + return probed(Option.getOrUndefined(version)); } -const probeOpenCodeBinary = ( - runtime: Pick, - input: { readonly binaryPath: string; readonly environment?: NodeJS.ProcessEnv }, -) => - Effect.suspend(() => runtime.runOpenCodeCommand({ ...input, args: ["--version"] })).pipe( - Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - probeError( - `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, +const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( + binaryPath: string, + environment: NodeJS.ProcessEnv | undefined, +) { + const runtime = yield* OpenCodeRuntime; + const { stdout } = yield* runtime + .runOpenCodeCommand({ + binaryPath, + args: ["--version"], + ...(environment === undefined ? {} : { environment }), + }) + .pipe( + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), ), - ), - }), - Effect.flatMap(({ stdout }) => { - const result = classifyOpenCodeCliVersion(stdout); - return result - ? Effect.succeed(result) - : Effect.fail( - probeError( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - ); - }), - Effect.withSpan("probeOpenCodeBinary"), - ); + }), + ); + const result = classifyOpenCodeCliVersion(stdout); + if (result) return result; + return yield* new OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); +}); +// Server failures reach clients through the provider status, so their details are fixed text: +// the underlying error can carry the configured URL, its credentials, or the password header. const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( serverUrl: string, serverPassword: string, ) { const client = yield* HttpClient.HttpClient; const baseUrl = serverUrl.trim().replace(/\/+$/, ""); + const protocol = URL.parse(baseUrl)?.protocol; + if (protocol !== "http:" && protocol !== "https:") { + return yield* new OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "The OpenCode server URL is not a valid http:// or https:// URL.", + }); + } + // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. + const authorization = serverPassword + ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` + : undefined; for (const path of ["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/api/info", "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/global/health"] as const) { const request = HttpClientRequest.get(`${baseUrl}${path}`); const result = yield* client .execute( - serverPassword ? HttpClientRequest.basicAuth(request, "opencode", serverPassword) : request, + authorization + ? HttpClientRequest.setHeader(request, "authorization", authorization) + : request, ) .pipe( Effect.flatMap((response) => @@ -120,46 +135,56 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( }), ), ), - Effect.mapError((cause) => - probeError(openCodeRuntimeErrorDetail(cause.cause ?? cause), cause), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Couldn't reach the OpenCode server.", + cause, + }), ), Effect.timeoutOrElse({ duration: OPENCODE_SERVER_PROBE_TIMEOUT, orElse: () => - Effect.fail(probeError("Timed out while checking the OpenCode server version.")), + Effect.fail( + new OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Timed out while checking the OpenCode server version.", + }), + ), }), ); if (result === "unauthorized") { - return yield* probeError("401 Unauthorized: the OpenCode server rejected the password."); + return yield* new OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "401 Unauthorized: the OpenCode server rejected the password.", + }); } if (result !== undefined) return result; } - return yield* probeError( - `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ); + return yield* new OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); }); /** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ export const probeOpenCodeRuntime = ( - runtime: Pick, settings: { readonly binaryPath: string; readonly serverUrl: string; readonly serverPassword: string; }, environment?: NodeJS.ProcessEnv, -) => +): Effect.Effect => settings.serverUrl.trim().length > 0 ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) - : probeOpenCodeBinary(runtime, { - binaryPath: settings.binaryPath, - ...(environment === undefined ? {} : { environment }), - }); + : probeOpenCodeBinary(settings.binaryPath, environment); /** * One instance's runtime, remembered after the first successful probe. Settings changes rebuild * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A - * failed probe is never remembered. + * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. */ export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => Effect.map( @@ -167,5 +192,9 @@ export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), }), - (cache) => ({ get: Cache.get(cache, undefined), refresh: Cache.refresh(cache, undefined) }), + (cache) => ({ + get: Cache.get(cache, undefined), + refresh: Cache.refresh(cache, undefined), + lastSuccess: Cache.getSuccess(cache, undefined), + }), ); diff --git a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts index c9d3f7fdc4f6..84cf66e6715a 100644 --- a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts +++ b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts @@ -47,7 +47,10 @@ export function replayOpenCodeServer( password: string, requestedPaths: Array = [], ) { - const expected = password ? `Basic ${btoa(`opencode:${password}`)}` : undefined; + // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`). + const expected = password + ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}` + : undefined; return HttpClient.make((request) => { const path = new URL(request.url).pathname; requestedPaths.push(path); From 554f0b9ca725747aba19e9567ddc83b16d4c213f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:12 -0700 Subject: [PATCH 3/4] fix(server): keep a configured OpenCode URL's path and query when probing The probe appended `/api/info` to the raw server URL string, so a query put the path inside it (`/?x=1` became `/?x=1/api/info`) and a working server behind a routing proxy read as unavailable. The path now goes onto the parsed URL's pathname, keeping any prefix and the query. Also switch the probe to a namespace import of `opencodeRuntime.ts`, as its other non-test callers do. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/opencodeVersionProbe.test.ts | 43 ++++++++++++++----- .../src/provider/opencodeVersionProbe.ts | 42 +++++++++--------- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts index 2a018f2e6ccf..17b2ba201b27 100644 --- a/apps/server/src/provider/opencodeVersionProbe.test.ts +++ b/apps/server/src/provider/opencodeVersionProbe.test.ts @@ -1,12 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { HttpClient, HttpClientError } from "effect/unstable/http"; +import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; import { classifyOpenCodeCliVersion, makeOpenCodeRuntimeProbe, @@ -20,12 +16,12 @@ import { const noBinary = { runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), -} as unknown as OpenCodeRuntimeShape; +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(OpenCodeRuntime, noBinary), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), ); const SERVER_URL = "http://127.0.0.1:4096/"; @@ -68,6 +64,29 @@ describe("OpenCode version probe", () => { }), ); + it.effect("keeps a server URL's path prefix and query when probing", () => + Effect.gen(function* () { + const urls: Array = []; + const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); + // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. + const proxied = HttpClient.make((request, url) => { + urls.push(url.href); + return replay.execute( + HttpClientRequest.setUrl( + request, + `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, + ), + ); + }); + const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); + assert.strictEqual(probed.generation, "v1"); + assert.deepStrictEqual(urls, [ + "http://proxy:8080/opencode/api/info?route=oc", + "http://proxy:8080/opencode/global/health?route=oc", + ]); + }), + ); + it.effect("reports a rejected password instead of guessing a version", () => Effect.gen(function* () { for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { @@ -149,12 +168,14 @@ describe("OpenCode version probe", () => { const stdout = outputs[calls++]; return stdout ? Effect.succeed({ stdout, stderr: "", code: 0 }) - : Effect.fail(new OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" })); + : Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), + ); }, - } as unknown as OpenCodeRuntimeShape; + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; const probe = yield* makeOpenCodeRuntimeProbe( probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( - Effect.provideService(OpenCodeRuntime, runtime), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), Effect.provideService( HttpClient.HttpClient, HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts index 0d0e00184388..89f6c3839d43 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -8,11 +8,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { - MINIMUM_OPENCODE_VERSION, - OpenCodeRuntime, - OpenCodeRuntimeError, -} from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; import { parseGenericCliVersion } from "./providerSnapshot.ts"; export interface ProbedOpenCode { @@ -71,7 +67,7 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( binaryPath: string, environment: NodeJS.ProcessEnv | undefined, ) { - const runtime = yield* OpenCodeRuntime; + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const { stdout } = yield* runtime .runOpenCodeCommand({ binaryPath, @@ -83,7 +79,7 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( duration: OPENCODE_VERSION_PROBE_TIMEOUT, orElse: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, }), @@ -92,9 +88,9 @@ const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( ); const result = classifyOpenCodeCliVersion(stdout); if (result) return result; - return yield* new OpenCodeRuntimeError({ + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", - detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, }); }); @@ -105,10 +101,9 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( serverPassword: string, ) { const client = yield* HttpClient.HttpClient; - const baseUrl = serverUrl.trim().replace(/\/+$/, ""); - const protocol = URL.parse(baseUrl)?.protocol; - if (protocol !== "http:" && protocol !== "https:") { - return yield* new OpenCodeRuntimeError({ + const baseUrl = URL.parse(serverUrl.trim()); + if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "The OpenCode server URL is not a valid http:// or https:// URL.", }); @@ -118,7 +113,10 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` : undefined; for (const path of ["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/api/info", "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/global/health"] as const) { - const request = HttpClientRequest.get(`${baseUrl}${path}`); + // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; + const request = HttpClientRequest.get(url.href); const result = yield* client .execute( authorization @@ -137,7 +135,7 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( ), Effect.mapError( (cause) => - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "Couldn't reach the OpenCode server.", cause, @@ -147,7 +145,7 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( duration: OPENCODE_SERVER_PROBE_TIMEOUT, orElse: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "Timed out while checking the OpenCode server version.", }), @@ -155,16 +153,16 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( }), ); if (result === "unauthorized") { - return yield* new OpenCodeRuntimeError({ + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "401 Unauthorized: the OpenCode server rejected the password.", }); } if (result !== undefined) return result; } - return yield* new OpenCodeRuntimeError({ + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", - detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, }); }); @@ -176,7 +174,11 @@ export const probeOpenCodeRuntime = ( readonly serverPassword: string; }, environment?: NodeJS.ProcessEnv, -): Effect.Effect => +): Effect.Effect< + ProbedOpenCode, + OpenCodeRuntime.OpenCodeRuntimeError, + HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime +> => settings.serverUrl.trim().length > 0 ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) : probeOpenCodeBinary(settings.binaryPath, environment); From fa8d70028776b5709b682f379487f2eff15c9e26 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:51:36 -0700 Subject: [PATCH 4/4] refactor(server): import the OpenCode driver's Effect services as namespaces Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider/Drivers/OpenCodeDriver.test.ts | 30 ++++++++------- .../src/provider/Drivers/OpenCodeDriver.ts | 38 ++++++++++--------- .../provider/Layers/OpenCodeProvider.test.ts | 34 ++++++++--------- .../src/provider/Layers/OpenCodeProvider.ts | 38 ++++++++++--------- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index dfdf143dedce..d1742d2c0762 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -8,15 +8,11 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { OPENCODE_2_UNSUPPORTED_MESSAGE } from "../opencodeVersionProbe.ts"; import { OpenCodeDriver } from "./OpenCodeDriver.ts"; @@ -24,7 +20,12 @@ const serverStarts: Array = []; const reachedServer = (operation: string) => Effect.sync(() => serverStarts.push(operation)).pipe( Effect.andThen( - Effect.fail(new OpenCodeRuntimeError({ operation, detail: "reached a 1.x server path" })), + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation, + detail: "reached a 1.x server path", + }), + ), ), ); // Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. @@ -32,15 +33,18 @@ const openCode2Runtime = { runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), startOpenCodeServerProcess: () => reachedServer("start"), connectToOpenCodeServer: () => reachedServer("connect"), -} as unknown as OpenCodeRuntimeShape; +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; const layer = Layer.mergeAll( ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), IdAllocator.layer, - ServerSettingsService.layerTest(), + ServerSettings.layerTest(), Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), - Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), - Layer.succeed(OpenCodeRuntime, openCode2Runtime), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(NodeServices.layer)); const create = (config: Partial, http: HttpClient.HttpClient) => diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 9acd22a496ea..e43e3599b54f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -24,7 +24,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, @@ -34,7 +34,7 @@ import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape, } from "../../orchestration-v2/ProviderAdapter.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; @@ -45,7 +45,7 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "../Layers/OpenCodeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime, OpenCodeRuntimeError, loadOpenCodeCommands } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, OPENCODE_2_UNSUPPORTED_MESSAGE, @@ -94,13 +94,13 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }); const openCode2Unsupported = () => - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "selectOpenCodeRuntime", detail: OPENCODE_2_UNSUPPORTED_MESSAGE, }); type OpenCodeRuntimeProbe = Effect.Success< - ReturnType> + ReturnType> >; /** @@ -110,7 +110,7 @@ type OpenCodeRuntimeProbe = Effect.Success< function onOpenCodeV1( probed: Effect.Effect, use: Effect.Effect, - refuse: (cause: OpenCodeRuntimeError) => E, + refuse: (cause: OpenCodeRuntime.OpenCodeRuntimeError) => E, ): Effect.Effect { return probed.pipe( Effect.orElseSucceed(() => undefined), @@ -130,7 +130,7 @@ function selectOpenCodeRuntimeAdapter(input: { readonly v1: ProviderAdapterV2Shape; }): ProviderAdapterV2Shape { const lastSuccess = Effect.map(input.probe.lastSuccess, Option.getOrUndefined); - const capabilitiesError = (cause: OpenCodeRuntimeError) => + const capabilitiesError = (cause: OpenCodeRuntime.OpenCodeRuntimeError) => new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause }); return { instanceId: input.v1.instanceId, @@ -157,7 +157,7 @@ function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, v1: TextGeneration["Service"], ): TextGeneration["Service"] { - const refuse = (operation: string) => (cause: OpenCodeRuntimeError) => + const refuse = (operation: string) => (cause: OpenCodeRuntime.OpenCodeRuntimeError) => new TextGenerationError({ operation, detail: cause.detail, cause }); return { generateCommitMessage: (input) => @@ -178,10 +178,10 @@ export type OpenCodeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient - | OpenCodeRuntime + | OpenCodeRuntime.OpenCodeRuntime | Path.Path - | ServerConfig - | ServerSettingsService; + | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService; export const OpenCodeDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -196,10 +196,10 @@ export const OpenCodeDriver: ProviderDriver const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const openCodeRuntime = yield* OpenCodeRuntime; - const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; + const serverSettings = yield* ServerSettings.ServerSettingsService; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -227,7 +227,7 @@ export const OpenCodeDriver: ProviderDriver const runtimeProbe = yield* makeOpenCodeRuntimeProbe( probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(OpenCodeRuntime, openCodeRuntime), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ), ); const openCodeV1Adapter = yield* OpenCodeAdapterV2Driver.create({ @@ -288,7 +288,7 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(Path.Path, pathService), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - Effect.provideService(OpenCodeRuntime, openCodeRuntime), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ); // NOTE: the local branch intentionally uses the shared SDK server // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes @@ -298,11 +298,13 @@ export const OpenCodeDriver: ProviderDriver // empty skill list and poisons the workspace snapshot the `$` picker // reads. The SDK `app.skills` endpoint honors the per-request directory // and returns complete results regardless of size. - const loadWorkspaceInventory = (client: Parameters[0]) => + const loadWorkspaceInventory = ( + client: Parameters[0], + ) => Effect.all( { skills: openCodeRuntime.loadOpenCodeSkills(client), - commands: loadOpenCodeCommands(client).pipe( + commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => []), ), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 2cf511ad84a4..4659a28dc966 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -13,19 +13,13 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - resolveOpenCodeServerPassword, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as ServerConfig from "../../config.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; import { OPENCODE_2_UNSUPPORTED_MESSAGE, probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; import { @@ -202,7 +196,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ serverPassword, environment }) => Effect.gen(function* () { yield* Effect.addFinalizer(() => @@ -210,7 +204,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.closeCalls += 1; }), ); - const effectiveServerPassword = resolveOpenCodeServerPassword({ + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ external: false, ...(serverPassword !== undefined ? { serverPassword } : {}), ...(environment !== undefined ? { environment } : {}), @@ -228,7 +222,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { if (runtimeMock.state.connectionError) { - return yield* new OpenCodeRuntimeError({ + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "global.health", detail: runtimeMock.state.connectionError.message, cause: runtimeMock.state.connectionError, @@ -254,7 +248,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ? Effect.never : runtimeMock.state.runVersionError ? Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: runtimeMock.state.runVersionError.message, cause: runtimeMock.state.runVersionError, @@ -263,29 +257,31 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); - return {} as unknown as ReturnType; + return {} as unknown as ReturnType< + OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] + >; }, loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: runtimeMock.state.inventoryError.message, cause: runtimeMock.state.inventoryError, }), ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), loadInventoryFromCli: ({ cwd }) => { runtimeMock.state.inventoryCwd = cwd; return runtimeMock.state.inventoryError ? Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadInventoryFromCli", detail: runtimeMock.state.inventoryError.message, cause: runtimeMock.state.inventoryError, }), ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); + : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); }, loadOpenCodeSkills: () => Effect.succeed([]), loadSkillsFromCli: () => Effect.succeed([]), @@ -311,7 +307,7 @@ it("keeps native and MCP commands while preserving compaction and separate skill ); }); -const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( +const testLayer = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); @@ -342,7 +338,7 @@ const checkProvider = Effect.fn("checkProvider")(function* ( }); const probe = probeOpenCodeRuntime(settings, environment).pipe( Effect.provideService(HttpClient.HttpClient, server), - Effect.provideService(OpenCodeRuntime, OpenCodeRuntimeTestDouble), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), ); return yield* checkOpenCodeProviderStatus(settings, cwd, probe).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 77edffb6126a..2395130b5e18 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -19,13 +19,7 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - MINIMUM_OPENCODE_VERSION, - OpenCodeRuntime, - openCodeRuntimeErrorDetail, - type OpenCodeInventory, - type OpenCodeRuntimeError, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { OPENCODE_2_UNSUPPORTED_MESSAGE, type ProbedOpenCode } from "../opencodeVersionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; @@ -255,7 +249,9 @@ function openCodeCapabilitiesForModel(input: { }); } -function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { +function flattenOpenCodeModels( + input: OpenCodeRuntime.OpenCodeInventory, +): ReadonlyArray { const connected = new Set(input.providerList.connected); const models: Array = []; @@ -294,7 +290,7 @@ function trimOptional(value: string | null | undefined): string | undefined { } export function openCodeSkillsToServerProviderSkills( - input: OpenCodeInventory["skills"] | undefined, + input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, ): ReadonlyArray { const skills: ServerProviderSkill[] = []; for (const skill of input ?? []) { @@ -317,7 +313,7 @@ export function openCodeSkillsToServerProviderSkills( } export function openCodeCommandsToServerProviderSlashCommands( - input: OpenCodeInventory["commands"], + input: OpenCodeRuntime.OpenCodeInventory["commands"], ): ReadonlyArray { const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; const names = new Set([COMPACT_SLASH_COMMAND.name]); @@ -389,13 +385,13 @@ export const makePendingOpenCodeProvider = ( export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, - probeRuntime: Effect.Effect, + probeRuntime: Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, - OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner + OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner > { - const openCodeRuntime = yield* OpenCodeRuntime; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; @@ -448,7 +444,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const probedExit = yield* Effect.exit( probeRuntime.pipe( Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), ), ), ); @@ -471,7 +471,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu }); } let version: string | null = probed.version; - if (compareSemverVersions(probed.version, MINIMUM_OPENCODE_VERSION) < 0) { + if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, @@ -482,7 +482,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu version: probed.version, status: "error", auth: { status: "unknown" }, - message: `OpenCode v${probed.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, }, }); } @@ -516,7 +516,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const inventoryExit = yield* Effect.exit( inventoryEffect.pipe( Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + }), ), ), );