diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 57c3102993..5c643437db 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service" import { Effect, FileSystem, Option, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" +import semver from "semver" import { selfCommand } from "../util/process" // The CLI's service configuration file, plus the Service.EnsureOptions binding that @@ -104,10 +105,21 @@ export const options = Effect.fnUntraced(function* () { return { file, version: OPENCODE_VERSION, + canReplace: (version: string | undefined) => canReplaceVersion(version), command: [...selfCommand(), "serve", "--service"], } }) +export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) { + if (serverVersion === undefined) return true + // Preview versions end in `-[.]`. Convert the build + // to a numeric semver identifier so next-15000 sorts after next-9999. + const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1") + const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1") + if (!semver.valid(server) || !semver.valid(client)) return true + return semver.lt(server, client) +} + export const read = Effect.fn("cli.service-config.read")(function* () { const { fs, configFile, legacyConfigFile } = yield* paths if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 3e29d6e443..a1d3f97509 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -47,6 +47,15 @@ test("service filenames share release channels and identify preview channels", ( expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false) }) +test("only newer clients replace managed service versions", () => { + expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.4")).toBe(true) + expect(ServiceConfig.canReplaceVersion("1.2.4", "1.2.3")).toBe(false) + expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.3")).toBe(false) + expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true) + expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false) + expect(ServiceConfig.canReplaceVersion(undefined, "1.2.3")).toBe(true) +}) + test("service config migrates from the hashed channel filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-")) const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!) diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index f27b273620..751a94ae72 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -3,7 +3,13 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect" import { spawn, type ChildProcess } from "node:child_process" import { homedir } from "node:os" import { join } from "node:path" -import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js" +import { + VersionMismatchError, + type DiscoverOptions, + type Endpoint, + type EnsureOptions, + type StopOptions, +} from "../service.js" export * from "../service.js" /** Contents of the local service registration file. */ @@ -89,6 +95,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start")) if (compatible) return Option.none() + if (options.canReplace?.(service.version) === false) + return yield* Effect.fail(new VersionMismatchError(options.version, service.version)) yield* announce("version-mismatch", service.version) yield* kill(service, options).pipe(Effect.ignore) lastSpawn = 0 diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index c03f4fe551..06d6a1a3da 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -2,7 +2,14 @@ import { readFile } from "node:fs/promises" import { spawn, type ChildProcess } from "node:child_process" import { homedir } from "node:os" import { join } from "node:path" -import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js" +import { + VersionMismatchError, + type DiscoverOptions, + type Endpoint, + type Info, + type EnsureOptions, + type StopOptions, +} from "../service.js" import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js" export * from "../service.js" @@ -70,6 +77,8 @@ export async function ensure(options: EnsureOptions = {}): Promise { if (compatible && service.state === "ready") return service.endpoint if (compatible && service.state === "failed") throw new Error("Background service failed to start") if (!compatible) { + if (options.canReplace?.(service.version) === false) + throw new VersionMismatchError(options.version, service.version) announce("version-mismatch", service.version) await kill(service, options).catch(() => undefined) lastSpawn = 0 diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts index 96d54871f6..5e61da0136 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/service.ts @@ -28,10 +28,24 @@ export type EnsureReason = "missing" | "version-mismatch" export type EnsureOptions = DiscoverOptions & { /** Service command and arguments. Defaults to `opencode serve --service`. */ readonly command?: ReadonlyArray + /** Decide whether a version-mismatched service may be replaced. Defaults to true. */ + readonly canReplace?: (version: string | undefined) => boolean /** Called once before spawning a new service process. */ readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void } +/** A healthy service exists, but the caller's replacement policy protects it. */ +export class VersionMismatchError extends Error { + override readonly name = "VersionMismatchError" + + constructor( + readonly clientVersion: string | undefined, + readonly serverVersion: string | undefined, + ) { + super(`Client version ${clientVersion ?? "unknown"} cannot replace server version ${serverVersion ?? "unknown"}`) + } +} + /** Options used to stop the local OpenCode service. */ export type StopOptions = { /** Absolute registration file path. Defaults to the XDG state directory. */ diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts index 51744cf50e..7f1331ee91 100644 --- a/packages/client/test/promise-service.test.ts +++ b/packages/client/test/promise-service.test.ts @@ -70,6 +70,25 @@ test("reports a failed registered service", async () => { ) }) +test("does not replace a version rejected by the caller", async () => { + const registration = await setup("graceful") + const directory = await temp() + const contender = join(directory, "contender.json") + const info = await Bun.file(registration).json() + + await expect( + Service.ensure({ + file: registration, + version: "old", + canReplace: () => false, + command: [process.execPath, fixture, contender, "record-start"], + }), + ).rejects.toThrow("Client version old cannot replace server version test") + + expect(await Bun.file(contender + ".started").exists()).toBe(false) + expect(process.kill(info.pid, 0)).toBe(true) +}) + test("requests graceful stop of the exact service instance", async () => { const registration = await setup("graceful") const info = await Bun.file(registration).json() diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 519c2a46df..738a6e1e69 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -107,6 +107,28 @@ test("does not spawn contenders while an incompatible service rejects replacemen expect(existing.exitCode).toBe(null) }) +test("does not replace a version rejected by the caller", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const contender = join(directory, "contender.json") + const existing = spawn(registration, "graceful") + await waitForFile(registration) + + await expect( + run( + Service.ensure({ + file: registration, + version: "old", + canReplace: () => false, + command: [process.execPath, fixture, contender, "record-start"], + }), + ), + ).rejects.toThrow("Client version old cannot replace server version test") + + expect(await Bun.file(contender + ".started").exists()).toBe(false) + expect(existing.exitCode).toBe(null) +}) + test("a legacy health response is still replaced", async () => { const directory = await temp() const registration = join(directory, "service.json")