Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions packages/cli/src/services/server-connection.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
133 changes: 132 additions & 1 deletion packages/cli/test/server-connection.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) {
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<void>) {
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()
}
}
Loading