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..d1742d2c0762 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -0,0 +1,98 @@ +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 * as ServerConfig from "../../config.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.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"; + +const serverStarts: Array = []; +const reachedServer = (operation: string) => + Effect.sync(() => serverStarts.push(operation)).pipe( + Effect.andThen( + 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. +const openCode2Runtime = { + runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), + startOpenCodeServerProcess: () => reachedServer("start"), + connectToOpenCodeServer: () => reachedServer("connect"), +} as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; + +const layer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), + IdAllocator.layer, + ServerSettings.layerTest(), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Layer.succeed(OpenCodeRuntime.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 f5c6de2c161e..e43e3599b54f 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"; @@ -23,12 +24,18 @@ 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, } from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterCapabilitiesError, + ProviderAdapterOpenSessionError, + type ProviderAdapterV2Shape, +} from "../../orchestration-v2/ProviderAdapter.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"; import { @@ -38,7 +45,13 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "../Layers/OpenCodeProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime, loadOpenCodeCommands } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime 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 +93,84 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }, }); +const openCode2Unsupported = () => + new OpenCodeRuntime.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: OpenCodeRuntime.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. 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: OpenCodeRuntimeProbe; + readonly v1: ProviderAdapterV2Shape; +}): ProviderAdapterV2Shape { + const lastSuccess = Effect.map(input.probe.lastSuccess, Option.getOrUndefined); + const capabilitiesError = (cause: OpenCodeRuntime.OpenCodeRuntimeError) => + new ProviderAdapterCapabilitiesError({ driver: DRIVER_KIND, cause }); + return { + instanceId: input.v1.instanceId, + driver: DRIVER_KIND, + getCapabilities: () => onOpenCodeV1(lastSuccess, input.v1.getCapabilities(), capabilitiesError), + planSelectionTransition: (transition) => + onOpenCodeV1(lastSuccess, input.v1.planSelectionTransition(transition), capabilitiesError), + openSession: (session) => + onOpenCodeV1( + input.probe.get, + input.v1.openSession(session), + (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: OpenCodeRuntime.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 @@ -87,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, @@ -105,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, @@ -133,7 +224,13 @@ export const OpenCodeDriver: ProviderDriver ), ); - const orchestrationAdapter = yield* OpenCodeAdapterV2Driver.create({ + const runtimeProbe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + ), + ); + const openCodeV1Adapter = yield* OpenCodeAdapterV2Driver.create({ instanceId, displayName, accentColor, @@ -151,6 +248,10 @@ export const OpenCodeDriver: ProviderDriver }), ), ); + const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ + probe: runtimeProbe, + v1: openCodeV1Adapter, + }); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, directory: serverConfig.cwd, @@ -159,13 +260,20 @@ 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( { - provider: checkOpenCodeProviderStatus(effectiveConfig, serverConfig.cwd, processEnv), + provider: checkOpenCodeProviderStatus( + effectiveConfig, + serverConfig.cwd, + runtimeProbe.refresh, + ), usageLimits: readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, serverUrl: effectiveConfig.serverUrl, @@ -180,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 @@ -190,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(() => []), ), @@ -284,7 +394,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 55e159197e6b..4659a28dc966 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -13,20 +13,20 @@ 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 { + 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"; @@ -196,7 +196,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ serverPassword, environment }) => Effect.gen(function* () { yield* Effect.addFinalizer(() => @@ -204,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 } : {}), @@ -222,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, @@ -248,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, @@ -257,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([]), @@ -305,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), ); @@ -324,6 +326,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 +336,11 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), ...(environment ? { environment } : {}), }); - return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe( + const probe = probeOpenCodeRuntime(settings, environment).pipe( + Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ); + return yield* checkOpenCodeProviderStatus(settings, cwd, probe).pipe( Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ); }), @@ -522,6 +529,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 +604,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..2395130b5e18 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -16,16 +16,11 @@ import { buildServerProvider, COMPACT_SLASH_COMMAND, nonEmptyTrimmed, - parseGenericCliVersion, providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - MINIMUM_OPENCODE_VERSION, - OpenCodeRuntime, - openCodeRuntimeErrorDetail, - type OpenCodeInventory, -} 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"; @@ -33,7 +28,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; @@ -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]); @@ -381,18 +377,22 @@ 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, - OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner + OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner > { - const openCodeRuntime = yield* OpenCodeRuntime; + const openCodeRuntime = yield* OpenCodeRuntime.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 +441,50 @@ 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}.`, - }), - ), + const probedExit = yield* Effect.exit( + probeRuntime.pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ + cause, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), }), - ), - ); - 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.`, - }, - }); - } + ), + ), + ); + 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, OpenCodeRuntime.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${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); } const loadInventory = (server: { @@ -524,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), + }), ), ), ); diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/apps/server/src/provider/opencodeVersionProbe.test.ts new file mode 100644 index 000000000000..17b2ba201b27 --- /dev/null +++ b/apps/server/src/provider/opencodeVersionProbe.test.ts @@ -0,0 +1,197 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; + +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import { + classifyOpenCodeCliVersion, + makeOpenCodeRuntimeProbe, + probeOpenCodeRuntime, +} from "./opencodeVersionProbe.ts"; +import { + OPENCODE_1_RESPONSES, + OPENCODE_2_RESPONSES, + replayOpenCodeServer, +} from "./testFixtures/opencodeProbeResponses.ts"; + +const noBinary = { + runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), +} 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.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", () => { + 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* 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"]); + }), + ); + + 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* 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"]); + }), + ); + + 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]) { + const paths: Array = []; + 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"]); + } + }), + ); + + 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 = { + runOpenCodeCommand: () => { + const stdout = outputs[calls++]; + return stdout + ? Effect.succeed({ stdout, stderr: "", code: 0 }) + : Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), + ); + }, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; + const probe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + Effect.provideService( + HttpClient.HttpClient, + 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); + // 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..89f6c3839d43 --- /dev/null +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -0,0 +1,202 @@ +/** 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 * as OpenCodeRuntime 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"; +// 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, pid: Schema.Int })), +); +const decodeGlobalHealth = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), +); + +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 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 = Effect.fn("probeOpenCodeBinary")(function* ( + binaryPath: string, + environment: NodeJS.ProcessEnv | undefined, +) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const { stdout } = yield* runtime + .runOpenCodeCommand({ + binaryPath, + args: ["--version"], + ...(environment === undefined ? {} : { environment }), + }) + .pipe( + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), + ), + }), + ); + const result = classifyOpenCodeCliVersion(stdout); + if (result) return result; + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeBinary", + detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.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 = 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.", + }); + } + // 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) { + // 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 + ? HttpClientRequest.setHeader(request, "authorization", authorization) + : request, + ) + .pipe( + Effect.flatMap((response) => + Effect.map(response.text, (body) => + classifyOpenCodeProbeResponse(path, { + status: response.status, + contentType: response.headers["content-type"], + body, + }), + ), + ), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Couldn't reach the OpenCode server.", + cause, + }), + ), + Effect.timeoutOrElse({ + duration: OPENCODE_SERVER_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "Timed out while checking the OpenCode server version.", + }), + ), + }), + ); + if (result === "unauthorized") { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: "401 Unauthorized: the OpenCode server rejected the password.", + }); + } + if (result !== undefined) return result; + } + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "probeOpenCodeServer", + detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, + }); +}); + +/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ +export const probeOpenCodeRuntime = ( + settings: { + readonly binaryPath: string; + readonly serverUrl: string; + readonly serverPassword: string; + }, + environment?: NodeJS.ProcessEnv, +): Effect.Effect< + ProbedOpenCode, + OpenCodeRuntime.OpenCodeRuntimeError, + HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime +> => + settings.serverUrl.trim().length > 0 + ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) + : 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. `lastSuccess` never probes, for calls too hot to wait on one. + */ +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), + lastSuccess: Cache.getSuccess(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..84cf66e6715a --- /dev/null +++ b/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts @@ -0,0 +1,72 @@ +/** + * 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 = [], +) { + // 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); + 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 } : {}, + }), + ), + ); + }); +}