From 1a9d9e92e8ccb8563b763b7a246aa88ea7787b10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 07:56:43 +0000 Subject: [PATCH] fix(cli): continue explicit --server after legacy health 404 pc_db4667ac9ad5 An installed server that still answers /api/health or /api/status returns 404 for /api/info. Explicit --server now warns and continues instead of exiting. --- .../cli/src/services/server-connection.ts | 72 ++++++++-- packages/cli/test/server-connection.test.ts | 133 +++++++++++++++++- 2 files changed, 195 insertions(+), 10 deletions(-) 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() + } +}