Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 8baa69b899 fix(client): validate promise service discovery 2026-08-12 20:02:18 -04:00
13 changed files with 85 additions and 75 deletions
-4
View File
@@ -189,14 +189,12 @@
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"semver": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
@@ -6469,8 +6467,6 @@
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
+1 -3
View File
@@ -103,9 +103,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: input.checkVersion
? (version: string) => Service.isServiceVersionCompatible(version, OPENCODE_VERSION)
: undefined,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
command: [...selfCommand(), "serve", "--service"],
}
})
+1 -5
View File
@@ -64,11 +64,7 @@ test("service options only require a matching version when requested", async ()
try {
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
const version = (await runPromise(ServiceConfig.options({ checkVersion: true }))).version
expect(version).toBeFunction()
if (typeof version !== "function") throw new Error("Expected a service version predicate")
expect(version(OPENCODE_VERSION)).toBe(true)
expect(version("999.0.0")).toBe(true)
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
+1 -3
View File
@@ -33,8 +33,7 @@
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"semver": "catalog:"
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.101"
@@ -49,7 +48,6 @@
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { isServiceVersionCompatible, matchesVersion } from "../service-version.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -325,4 +325,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, incumbent, ensure, stop, headers, isServiceVersionCompatible, Info }
export const Service = { discover, incumbent, ensure, stop, headers, Info }
+35 -7
View File
@@ -9,8 +9,8 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { isServiceVersionCompatible, matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -130,7 +130,15 @@ async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
try {
return JSON.parse(text) as Info
const value: unknown = JSON.parse(text)
if (typeof value !== "object" || value === null) return undefined
if (!("url" in value) || typeof value.url !== "string") return undefined
if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0)
return undefined
if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined
if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined
if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined
return value as Info
} catch {
return undefined
}
@@ -163,7 +171,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
})
.then(async (response) => ({
response,
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
body: (await response.json()) as unknown,
}))
.then(
(value) => ({ value }),
@@ -172,7 +180,18 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
const body = result.value.body
if (body !== undefined && "version" in body && "pid" in body) {
if (
typeof body === "object" &&
body !== null &&
"healthy" in body &&
body.healthy === true &&
"version" in body &&
typeof body.version === "string" &&
"pid" in body &&
typeof body.pid === "number" &&
Number.isInteger(body.pid) &&
body.pid > 0
) {
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
return {
@@ -186,7 +205,16 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
timedOut: false,
}
}
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
if (
!allowLegacy ||
typeof body !== "object" ||
body === null ||
!("healthy" in body) ||
body.healthy !== true ||
"version" in body ||
"pid" in body
)
return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
@@ -278,4 +306,4 @@ function delay(milliseconds: number) {
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, ensure, stop, headers, isServiceVersionCompatible }
export const Service = { discover, ensure, stop, headers }
-10
View File
@@ -1,5 +1,4 @@
import type { DiscoverOptions } from "./service.js"
import semver from "semver"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
@@ -7,12 +6,3 @@ export function matchesVersion(version: string | undefined, options: DiscoverOpt
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
/** Whether a service is at least as new as its client. */
export function isServiceVersionCompatible(serverVersion: string, clientVersion: string) {
if (serverVersion === clientVersion) return true
const server = serverVersion.replace(/^(0\.0\.0-.+)-(\d+(?:\.\d+)?)$/, "$1.$2")
const client = clientVersion.replace(/^(0\.0\.0-.+)-(\d+(?:\.\d+)?)$/, "$1.$2")
if (!semver.valid(server) || !semver.valid(client)) return true
return semver.gte(server, client)
}
-2
View File
@@ -1,5 +1,3 @@
export { isServiceVersionCompatible } from "./service-version.js"
/** Connection details for a local OpenCode service. */
export type Endpoint = {
/** Base URL of the service. */
-1
View File
@@ -31,7 +31,6 @@ let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
if (mode === "newer") version = "0.0.0-next-17272"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -38,6 +38,50 @@ test("discovers a compatible registered service", async () => {
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("rejects malformed registrations without probing or signaling", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const malformed = [
null,
[],
{},
{ url: "http://127.0.0.1:1" },
{ url: "http://127.0.0.1:1", pid: 0 },
{ url: "http://127.0.0.1:1", pid: -1 },
{ url: "http://127.0.0.1:1", pid: 1.5 },
{ url: "http://127.0.0.1:1", pid: "1" },
{ url: "http://127.0.0.1:1", pid: 1, id: 1 },
]
for (const value of malformed) {
await Bun.write(registration, JSON.stringify(value))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("rejects primitive and partial modern health responses", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const bodies = [
null,
1,
"healthy",
[],
{},
{ healthy: false, version: "test", pid: process.pid },
{ healthy: true, version: null, pid: process.pid },
{ healthy: true, version: "test", pid: "1" },
{ healthy: true, version: "test" },
{ healthy: true, pid: process.pid },
]
for (const body of bodies) {
using server = Bun.serve({ port: 0, fetch: () => Response.json(body) })
await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid }))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -1,13 +0,0 @@
import { expect, test } from "bun:test"
import { isServiceVersionCompatible } from "../src/service"
test("accepts the same or a newer service version", () => {
expect(isServiceVersionCompatible("0.0.0-next-17272", "0.0.0-next-17272")).toBe(true)
expect(isServiceVersionCompatible("0.0.0-next-17272", "0.0.0-next-17271")).toBe(true)
expect(isServiceVersionCompatible("0.0.0-next-17271", "0.0.0-next-17272")).toBe(false)
expect(isServiceVersionCompatible("0.0.0-next-15000", "0.0.0-next-9999")).toBe(true)
})
test("accepts incomparable development versions", () => {
expect(isServiceVersionCompatible("development-a", "development-b")).toBe(true)
})
-24
View File
@@ -68,30 +68,6 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null)
})
test("a stale client reuses a newer registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "newer")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => Service.isServiceVersionCompatible(version, "0.0.0-next-17271"),
command: [process.execPath, fixture, registration, "record-start"],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe(original.url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
expect(await Bun.file(registration + ".started").exists()).toBe(false)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+1 -1
View File
@@ -29,7 +29,7 @@ export async function startBackgroundCli(logger: Logger) {
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: (serverVersion) => Service.isServiceVersionCompatible(serverVersion, version),
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})