Compare commits

...
Author SHA1 Message Date
neriousy 2356e7bbad feat(server): gate external integration values by client API version
Clients now send x-opencode-api-version. Clients that send no header
(v2.0.24 and earlier) reject external integration methods, connections,
and credentials, so integration.list, integration.get, and credential.list
omit those values for them instead of failing the whole response.
2026-10-07 23:01:06 +00:00
11 changed files with 206 additions and 9 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
// Mirrors @opencode/protocol/api-version, which the dependency-free promise client cannot import.
export const API_VERSION_HEADER = "x-opencode-api-version"
export const API_VERSION = 2
+5 -3
View File
@@ -1,5 +1,6 @@
export * as OpenCode from "./client.js"
import { API_VERSION, API_VERSION_HEADER } from "../api-version.js"
import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
@@ -17,9 +18,10 @@ export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly b
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
Effect.map(CurrentHeaders, (headers) => {
const versioned = HttpClientRequest.setHeader(request, API_VERSION_HEADER, String(API_VERSION))
return headers ? HttpClientRequest.setHeaders(versioned, new Headers(headers)) : versioned
}),
),
),
)
+4 -1
View File
@@ -1,5 +1,6 @@
export * as OpenCode from "./client.js"
import { API_VERSION, API_VERSION_HEADER } from "../api-version.js"
import { SharedEvents } from "../shared-events.js"
import { OpenCode } from "./generated/index.js"
import type { ClientOptions } from "./generated/client.js"
@@ -8,7 +9,9 @@ import { makeRpc } from "./rpc.js"
export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const headers = new Headers(options.headers)
headers.set(API_VERSION_HEADER, String(API_VERSION))
const raw = OpenCode.make({ ...options, headers })
const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }))
return {
...raw,
@@ -37,3 +37,9 @@ test("shared DTO schemas construct and decode plain objects", () => {
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
})
test("client API version mirrors the protocol", async () => {
const client = await import("../src/api-version")
const protocol = await import("@opencode/protocol/api-version")
expect(client).toEqual(protocol)
})
+20
View File
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { Context, DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { API_VERSION, API_VERSION_HEADER } from "../src/api-version"
import {
AbsolutePath,
Agent,
@@ -42,6 +43,25 @@ test("server.info decodes the readiness response", async () => {
})
})
test("requests send the client's API version", async () => {
const headers: Array<string | undefined> = []
const httpClient = HttpClient.make((request) => {
headers.push(request.headers[API_VERSION_HEADER])
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ version: "current", pid: 123, urls: [], paths: { tmp: "/tmp/opencode" } }),
),
)
})
await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
yield* client.server.info()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(headers).toEqual([String(API_VERSION)])
})
test("vcs.base decodes nullable review-base metadata", async () => {
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = {
+17
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { API_VERSION, API_VERSION_HEADER } from "../src/api-version"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
@@ -1125,3 +1126,19 @@ const modelSwitchedEvent = {
model: { id: "claude", providerID: "anthropic" },
},
}
test("requests send the client's API version alongside configured headers", async () => {
let headers: Headers | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Basic secret", [API_VERSION_HEADER]: "1" },
fetch: async (input, init) => {
headers = new Headers(init?.headers)
return Response.json({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp" } })
},
})
await client.server.info()
expect(headers?.get(API_VERSION_HEADER)).toBe(String(API_VERSION))
expect(headers?.get("authorization")).toBe("Basic secret")
})
+16
View File
@@ -0,0 +1,16 @@
/**
* Clients send the API version they can decode in this header. The server omits response variants newer than
* that version, so a newer server does not break an older client that rejects unknown union members.
*/
export const API_VERSION_HEADER = "x-opencode-api-version"
/**
* Response-shape versions:
*
* - `1`: clients that send no header (v2.0.24 and earlier).
* - `2`: understands `external` integration methods, connections, and credentials.
*
* Bump this when a response can contain a value an older client cannot decode, and omit that value on the server
* for requests below the new version.
*/
export const API_VERSION = 2
+33
View File
@@ -0,0 +1,33 @@
export * as ApiVersion from "./api-version"
import type { Integration } from "@opencode/core/integration"
import { API_VERSION_HEADER } from "@opencode/protocol/api-version"
import type { Credential } from "@opencode/schema/credential"
import type { HttpServerRequest } from "effect/unstable/http"
// Responses omit values newer than the requesting client's API version. Remove a version's
// omissions once clients below it are no longer supported.
/** Returns the API version the requesting client can decode. Clients that send no header predate versioning. */
export function requested(request: HttpServerRequest.HttpServerRequest) {
const version = Number(request.headers[API_VERSION_HEADER])
return Number.isSafeInteger(version) && version > 0 ? version : 1
}
/** Version 1 clients reject `external` integration methods and connections. */
export function integration(info: Integration.Info, version: number): Integration.Info {
if (version >= 2) return info
return {
...info,
methods: info.methods.filter((method) => method.type !== "external"),
connections: info.connections.filter(
(connection) => connection.type !== "credential" || connection.method !== "external",
),
}
}
/** Version 1 clients reject `external` credential values. */
export function credentials<T extends { readonly value: Credential.Value }>(entries: T[], version: number) {
if (version >= 2) return entries
return entries.filter((entry) => entry.value.type !== "external")
}
+3 -2
View File
@@ -3,14 +3,15 @@ import { ConflictError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ApiVersion } from "../api-version"
export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) =>
handlers
.handle(
"credential.list",
Effect.fn(function* () {
Effect.fn(function* (ctx) {
const credential = yield* Credential.Service
return { data: entries(yield* credential.all()) }
return { data: ApiVersion.credentials(entries(yield* credential.all()), ApiVersion.requested(ctx.request)) }
}),
)
.handle(
+7 -3
View File
@@ -11,6 +11,7 @@ import {
} from "@opencode/protocol/errors"
import { response } from "../location"
import { WellKnown } from "@opencode/core/wellknown"
import { ApiVersion } from "../api-version"
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
effect.pipe(
@@ -29,10 +30,13 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
return handlers
.handle(
"integration.list",
Effect.fn(function* () {
Effect.fn(function* (ctx) {
yield* Plugin.awaitActivation
const service = yield* Integration.Service
return yield* response(service.list())
const version = ApiVersion.requested(ctx.request)
return yield* response(
service.list().pipe(Effect.map((list) => list.map((item) => ApiVersion.integration(item, version)))),
)
}),
)
.handle(
@@ -45,7 +49,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
return yield* response(Effect.succeed(integration))
return yield* response(Effect.succeed(ApiVersion.integration(integration, ApiVersion.requested(ctx.request))))
}),
)
.handle(
+92
View File
@@ -0,0 +1,92 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Plugin } from "@opencode/plugin/effect"
import { API_VERSION, API_VERSION_HEADER } from "@opencode/protocol/api-version"
import { Context, Effect, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
it.live(
"omits external integration methods, connections, and credentials for clients below API version 2",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-api-version-")
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: { directory: tmp.path, project: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
yield* Context.get(context, SdkPlugins.Service).register(
Plugin.define({
id: "external-fixture",
effect: (ctx) =>
ctx.integration.transform((editor) => {
editor.update("cloud", (integration) => {
integration.name = "Cloud"
})
editor.method.update({ integrationID: "cloud", method: { type: "key", label: "API key" } })
editor.method.update({
integrationID: "cloud",
method: { id: "profile", type: "external", label: "Profile" },
})
}),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const request = (route: string, input: { version?: number; body?: unknown } = {}) =>
Effect.promise(async (signal) => {
const url = new URL(route, "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
const headers = new Headers({ authorization: `Basic ${btoa("opencode:secret")}` })
if (input.version !== undefined) headers.set(API_VERSION_HEADER, String(input.version))
if (input.body !== undefined) headers.set("content-type", "application/json")
const response = await handler(
new Request(url, {
method: input.body === undefined ? "GET" : "POST",
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
signal,
}),
)
expect(response.status).toBeLessThan(300)
return response.json()
})
yield* request("/api/credential", {
version: API_VERSION,
body: { integrationID: "cloud", label: "Default profile", value: { type: "external", methodID: "profile" } },
})
yield* request("/api/credential", {
version: API_VERSION,
body: { integrationID: "cloud", label: "Key", value: { type: "key", key: "secret" }, activate: false },
})
const cloud = (body: { data: Array<{ id: string }> }) => body.data.find((item) => item.id === "cloud")
expect(cloud(yield* request("/api/integration", { version: API_VERSION }))).toMatchObject({
methods: [{ type: "key" }, { type: "external", id: "profile" }],
connections: expect.arrayContaining([expect.objectContaining({ method: "external" })]),
})
expect((yield* request("/api/credential", { version: API_VERSION })).data).toHaveLength(2)
// v2.0.24 and earlier send no version header; a malformed header is treated the same way.
for (const version of [undefined, 1, Number.NaN]) {
const legacy = cloud(yield* request("/api/integration", { version }))
expect(legacy).toMatchObject({ methods: [{ type: "key" }] })
expect(legacy).not.toMatchObject({ methods: expect.arrayContaining([{ type: "external" }]) })
expect(JSON.stringify(legacy)).not.toContain('"external"')
expect(JSON.stringify(yield* request("/api/integration/cloud", { version }))).not.toContain('"external"')
expect(yield* request("/api/credential", { version })).toMatchObject({
data: [{ integrationID: "cloud", value: { type: "key" } }],
})
}
}),
15_000,
)