diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts
index db0d07ce2b75..2427d915b691 100644
--- a/packages/cli/src/services/server-connection.ts
+++ b/packages/cli/src/services/server-connection.ts
@@ -1,7 +1,7 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client/promise"
import { OPENCODE_VERSION } from "../version"
-import { Effect, Redacted } from "effect"
+import { Cause, Effect, Exit, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
import { Standalone } from "./standalone"
@@ -28,14 +28,25 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
- const health = yield* Effect.tryPromise({
- try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
- catch: (cause) => connectError(endpoint, cause),
- })
- if (health.version !== OPENCODE_VERSION)
- process.stderr.write(
- `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
- )
+ const probed = yield* Effect.exit(
+ Effect.tryPromise({
+ try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
+ catch: (cause) => cause,
+ }),
+ )
+ if (Exit.isSuccess(probed)) {
+ if (probed.value.version !== OPENCODE_VERSION)
+ process.stderr.write(
+ `Warning: Server at ${endpoint.url} has version ${probed.value.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
+ )
+ return { endpoint } satisfies Resolved
+ }
+ // Explicit --server must not replace a live server. HTTP 404 means this client
+ // asked /api/info of a build that still answers on legacy health.
+ const cause = Cause.squash(probed.cause)
+ if (unexpectedStatus(cause) !== 404) return yield* Effect.fail(connectError(endpoint, cause))
+ const legacy = yield* legacyHealth(endpoint)
+ process.stderr.write(legacySkewWarning(endpoint.url, legacy))
return { endpoint } satisfies Resolved
}
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
@@ -83,6 +94,49 @@ const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mism
return yield* Service.ensure(options)
})
+const legacyPaths = ["/api/health", "/api/status"] as const
+
+const legacyHealth = Effect.fnUntraced(function* (endpoint: Endpoint) {
+ const headers = Service.headers(endpoint)
+ const signal = AbortSignal.timeout(5_000)
+ let answered: (typeof legacyPaths)[number] | undefined
+ for (const pathname of legacyPaths) {
+ const response = yield* Effect.promise(() =>
+ fetch(new URL(pathname, endpoint.url), { headers, signal }).catch(() => undefined),
+ )
+ if (!response?.ok) continue
+ const version = versionOf(yield* Effect.promise(() => response.json().catch(() => undefined)))
+ if (version !== undefined) return { pathname, version }
+ answered ??= pathname
+ }
+ if (answered === undefined) return undefined
+ return { pathname: answered }
+})
+
+function legacySkewWarning(
+ url: string,
+ legacy: { readonly pathname: (typeof legacyPaths)[number]; readonly version?: string } | undefined,
+) {
+ if (legacy?.version !== undefined)
+ return `Warning: Server at ${url} has version ${legacy.version} via ${legacy.pathname}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`
+ if (legacy !== undefined)
+ return `Warning: Server at ${url} answered ${legacy.pathname} without a version, and /api/info returned HTTP 404. This client is ${OPENCODE_VERSION}. Continuing anyway.\n`
+ return `Warning: Server at ${url} returned HTTP 404 for /api/info and has no legacy /api/health or /api/status response. This client is ${OPENCODE_VERSION}. Continuing anyway. Restart or upgrade that server if this client cannot connect.\n`
+}
+
+function unexpectedStatus(cause: unknown) {
+ if (!(cause instanceof ClientError) || cause.reason !== "UnexpectedStatus") return undefined
+ const status = cause.cause
+ if (typeof status !== "object" || status === null || !("status" in status)) return undefined
+ return typeof status.status === "number" ? status.status : undefined
+}
+
+function versionOf(body: unknown) {
+ if (typeof body !== "object" || body === null || !("version" in body)) return undefined
+ if (typeof body.version !== "string" || body.version.length === 0) return undefined
+ return body.version
+}
+
function connectError(endpoint: Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts
index 4174fb72b2b0..e0d770621a27 100644
--- a/packages/cli/test/server-connection.test.ts
+++ b/packages/cli/test/server-connection.test.ts
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode/util/global"
import { OPENCODE_VERSION } from "../src/version"
-import { expect, test } from "bun:test"
+import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -69,3 +69,134 @@ test("service options only require a matching version when requested", async ()
await fs.rm(root, { recursive: true, force: true })
}
})
+
+test("explicit server falls back to legacy health on /api/info 404 and continues", async () => {
+ const requests: string[] = []
+ using server = Bun.serve({
+ port: 0,
+ fetch(request) {
+ const pathname = new URL(request.url).pathname
+ requests.push(pathname)
+ if (pathname === "/api/health") return Response.json({ version: "0.0.0-legacy" })
+ return new Response("missing", { status: 404 })
+ },
+ })
+ const stderr = await captureStderr(async () => {
+ const resolved = await runExplicit(ServerConnection.resolve({ server: server.url.toString() }))
+ expect(resolved.endpoint.url).toBe(server.url.toString())
+ expect(resolved.service).toBeUndefined()
+ })
+ expect(requests).toEqual(["/api/info", "/api/health"])
+ expect(stderr).toContain("0.0.0-legacy")
+ expect(stderr).toContain("/api/health")
+ expect(stderr).toContain("Continuing anyway")
+})
+
+test("explicit server reads /api/status when legacy /api/health is missing", async () => {
+ using server = Bun.serve({
+ port: 0,
+ fetch(request) {
+ const pathname = new URL(request.url).pathname
+ if (pathname === "/api/status") return Response.json({ version: "0.0.0-status", pid: 1 })
+ return new Response("missing", { status: 404 })
+ },
+ })
+ const stderr = await captureStderr(async () => {
+ const resolved = await runExplicit(ServerConnection.resolve({ server: server.url.toString() }))
+ expect(resolved.endpoint.url).toBe(server.url.toString())
+ })
+ expect(stderr).toContain("0.0.0-status")
+ expect(stderr).toContain("/api/status")
+ expect(stderr).toContain("Continuing anyway")
+})
+
+test("explicit server continues when /api/info and legacy health both 404", async () => {
+ using server = Bun.serve({
+ port: 0,
+ fetch: () => new Response("missing", { status: 404 }),
+ })
+ const stderr = await captureStderr(async () => {
+ const resolved = await runExplicit(ServerConnection.resolve({ server: server.url.toString() }))
+ expect(resolved.endpoint.url).toBe(server.url.toString())
+ })
+ expect(stderr).toContain("HTTP 404")
+ expect(stderr).toContain("Restart or upgrade")
+ expect(stderr).toContain("Continuing anyway")
+})
+
+test("explicit server warns on version skew without probing legacy health", async () => {
+ const requests: string[] = []
+ using server = Bun.serve({
+ port: 0,
+ fetch(request) {
+ requests.push(new URL(request.url).pathname)
+ return Response.json({ version: "other", pid: 1, urls: [], paths: { tmp: "/tmp" } })
+ },
+ })
+ const stderr = await captureStderr(async () => {
+ const resolved = await runExplicit(ServerConnection.resolve({ server: server.url.toString() }))
+ expect(resolved.service).toBeUndefined()
+ })
+ expect(requests).toEqual(["/api/info"])
+ expect(stderr).toContain("version other")
+ expect(stderr).toContain("Continuing anyway")
+})
+
+test("explicit server continues when legacy health omits a version", async () => {
+ using server = Bun.serve({
+ port: 0,
+ fetch(request) {
+ if (new URL(request.url).pathname === "/api/health") return Response.json({ healthy: true })
+ return new Response("missing", { status: 404 })
+ },
+ })
+ const stderr = await captureStderr(async () => {
+ await runExplicit(ServerConnection.resolve({ server: server.url.toString() }))
+ })
+ expect(stderr).toContain("/api/health")
+ expect(stderr).toContain("without a version")
+ expect(stderr).toContain("Continuing anyway")
+})
+
+test("explicit server still fails when /api/info is unauthorized", async () => {
+ using server = Bun.serve({
+ port: 0,
+ fetch: () => Response.json({ _tag: "UnauthorizedError", message: "Unauthorized" }, { status: 401 }),
+ })
+ await expect(runExplicit(ServerConnection.resolve({ server: server.url.toString() }))).rejects.toThrow(
+ "requires a password",
+ )
+})
+
+test("explicit server still fails when /api/info returns 503", async () => {
+ using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
+ await expect(runExplicit(ServerConnection.resolve({ server: server.url.toString() }))).rejects.toThrow(
+ "did not provide a compatible V2 health response",
+ )
+})
+
+async function runExplicit(effect: Effect.Effect) {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-explicit-server-"))
+ const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
+ try {
+ return await Effect.runPromise(
+ effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped),
+ )
+ } finally {
+ await fs.rm(root, { recursive: true, force: true })
+ }
+}
+
+async function captureStderr(run: () => Promise) {
+ const stderr: string[] = []
+ const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
+ stderr.push(String(chunk))
+ return true
+ })
+ try {
+ await run()
+ return stderr.join("")
+ } finally {
+ stderrWrite.mockRestore()
+ }
+}