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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Package

CodeNomad server and UI pin `@opencode/client@2.0.4`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, falling back only on HTTP 404 to earlier V2 `/api/health` with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
CodeNomad server and UI pin `@opencode/client@2.0.4`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.status()` to the discovered route. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.

Cross-runtime adaptation lives in `packages/server/src/opencode/compatibility/`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Known published contracts select their serializer directly; unknown versions require authenticated bounded OpenAPI recognition before calls. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for the evidence matrix and maintained issue register.

Expand All @@ -17,7 +17,7 @@ Do not replace the shared network service with `@opencode-ai/sdk` unless CodeNom

| Area | Calls | CodeNomad caller |
|---|---|---|
| Service | CLI `service status/start/get password`; authenticated `/api/status`, then `/api/health` only on 404; `Service.headers` for probes and API calls | `packages/server/src/workspaces/opencode-service.ts`, `packages/server/src/workspaces/opencode-cli-service.ts`, `packages/server/src/workspaces/host-opencode-service.ts`, `packages/server/src/workspaces/wsl-opencode-service.ts` |
| Service | CLI `service status/start/get password`; authenticated `/api/status`, `/api/health`, `/api/info` in order, advancing only on 404; `Service.headers` for probes and API calls | `packages/server/src/workspaces/opencode-service.ts`, `packages/server/src/workspaces/opencode-cli-service.ts`, `packages/server/src/workspaces/host-opencode-service.ts`, `packages/server/src/workspaces/wsl-opencode-service.ts` |
| Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper |
| Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` |
| Sessions | `list/get/create/fork/remove/update/prompt/command/shell/interrupt` | UI session stores |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Contract

- Server and UI pin `@opencode/client@2.0.4`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, falling back only on HTTP 404 to earlier V2 `/api/health`. Both probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed status failures do not trigger fallback. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
- Server and UI pin `@opencode/client@2.0.4`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.status()` to the discovered route. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
- The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples.
- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy.
- That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically.
Expand Down
27 changes: 27 additions & 0 deletions dev-docs/OPENCODE_V2_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ The native fixture now resolves its temporary root with `realpath` **before** co

The corrected full native suite passes on Linux/WSL 2.0.4 with a symlinked TEMP root, and the Windows 2.0.4 rendered/native suite passes. Script syntax, workflow YAML parsing and whitespace checks pass; an independent gatekeeper review reports no actionable findings. This corrects the fixture namespace rather than preinstalling the plugin, restarting the daemon, or extending the timeout. Actual macOS confirmation requires the next CI run; the prior runner's raw watcher paths were not retained. Existing user daemons configured through symlinked config roots remain an upstream watcher limitation, not a production fix claimed by this fixture change.

### Server-info discovery follow-up (2026-09-17)

The installed 2.0.7 daemon returns 404 for both `/api/status` and `/api/health`,
and exposes authenticated `/api/info`. Lifecycle validation now tries the three
read routes in that order, advancing only on 404 and sharing the same credentials,
response-size bound and absolute deadline. Redirects are rejected. Earlier V2
services still stop at `/api/health`; no minimum release or new version exception
is introduced. Unknown versions still negotiate their actual OpenAPI contract.

The connection records `info` discovery so canonical `client.server.status()`
uses `/api/info` too. Translation is limited to GET and does not retry a failed
request with another route. A comparison of the 132 pinned-client HTTP method/path
pairs with the installed 2.0.7 schema found 131 unchanged pairs and this single
status/info difference. Experimental paths were already part of the merged modern
contract; this is not another experimental-route migration.

Host/WSL regressions cover the third probe, credentials, body validation and shared
deadline. Client regressions cover 2.0.0 health, 2.0.4 status, negotiated 2.0.7 info
and an unknown future version. The isolated location fixture exercises production
lifecycle validation and canonical `client.server.status()` before location tests.
On Windows, that fixture passes against real isolated 2.0.0 and 2.0.7 daemons:
2.0.0 takes the health route and passes the legacy identity/Form/Shell/PTY/SSE/
cursor/rollback cases; 2.0.7 takes the info route and passes modern location and
obsolete-selector checks. The 44 focused lifecycle/transport/negotiation tests
and server typecheck also pass.
Route presence and these targeted checks do not certify every runtime behavior.

### Validation boundaries

UI/server/Electron typechecks and production builds pass. The Windows Tauri release executable also builds (`npm run build --workspace @codenomad/tauri-app -- --no-bundle`). Electron and Tauri packaged-resource smoke checks pass. Full desktop interaction through Developer Mode could not run: the visible application reports Developer Mode inactive. Interactive TUI validation is not claimed. No shared daemon or user database is used by the native fixtures.
Expand Down
15 changes: 14 additions & 1 deletion packages/server/src/opencode/compatibility/contract-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Minimal structural excerpts from the published earlier V2 OpenAPI document.
// Minimal structural excerpts from the published V2 OpenAPI contracts.
// For test fixtures only; real-runtime tests also negotiate full native schemas.
export const legacyContractFixture = {
paths: {
Expand All @@ -12,3 +12,16 @@ export const legacyContractFixture = {
},
components: { schemas: { "Session.Inbox.User": { properties: { timeCreated: { type: "number" } } } } },
}

export const modernContractFixture = {
paths: {
"/api/session/{sessionID}": { patch: {} },
"/api/form": { get: {} },
"/api/session/{sessionID}/form/{formID}": { delete: {} },
"/api/experimental/session/{sessionID}/wait": { post: {} },
"/api/session/{sessionID}/fork": { post: { requestBody: { content: { "application/json": { schema: { properties: { before: {} } } } } } } },
"/api/session/{sessionID}/command": { post: { requestBody: { content: { "application/json": { schema: { properties: { name: {} } } } } } } },
"/api/session/{sessionID}/permission/{requestID}/reply": { post: { requestBody: { content: { "application/json": { schema: { properties: { decision: {} } } } } } } },
},
components: { schemas: { "Session.Inbox.User": { properties: { time: { type: "object" } } } } },
}
2 changes: 1 addition & 1 deletion packages/server/src/opencode/compatibility/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Endpoint } from "@opencode/client/service"
export interface RuntimeIdentity {
version: string
pid: number
discovery: "status" | "health"
discovery: "status" | "health" | "info"
/** Filled only by a successful, authenticated schema negotiation. */
contract?: { profile?: Exclude<ContractProfile, "unknown"> }
}
Expand Down
41 changes: 41 additions & 0 deletions packages/server/src/opencode/compatibility/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ import { OpenCode } from "@opencode/client"
import type { Endpoint } from "@opencode/client/service"
import { rememberRuntime } from "./runtime"
import { createRuntimeFetch } from "./transport"
import { modernContractFixture } from "./contract-fixture"

test("the pinned status client follows the discovered route across legacy, modern and info runtimes", async () => {
for (const [version, discovery, route] of [
["2.0.0", "health", "/api/health"],
["2.0.4", "status", "/api/status"],
["2.0.7", "info", "/api/info"],
["future-release", "info", "/api/info"],
] as const) {
const endpoint: Endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "opencode", password: "fixture" } }
rememberRuntime(endpoint, { version, pid: 123, discovery })
const seen: string[] = []
const fetch = createRuntimeFetch(endpoint, async (input, init) => {
const path = new URL(String(input)).pathname
seen.push(path)
assert.equal(init?.redirect, "error")
assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:fixture").toString("base64")}`)
if (path === "/openapi.json") return Response.json(modernContractFixture)
assert.equal(path, route)
return Response.json(discovery === "health" ? { healthy: true, version, pid: 123 } : { version, pid: 123, urls: [endpoint.url] })
})
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
assert.deepEqual(await client.server.status(), { version, pid: 123, urls: [endpoint.url] })
assert.deepEqual(seen, discovery === "info" ? ["/openapi.json", route] : [route])
}
})

test("info translation is GET-only and does not probe alternate routes on failure", async () => {
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.7", pid: 123, discovery: "info" })
const seen: string[] = []
const fetch = createRuntimeFetch(endpoint, async (input, init) => {
const path = new URL(String(input)).pathname
seen.push(`${init?.method ?? "GET"} ${path}`)
return path === "/openapi.json" ? Response.json(modernContractFixture) : new Response(null, { status: 404 })
})
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
await assert.rejects(client.server.status())
await fetch(`${endpoint.url}/api/status`, { method: "POST" })
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/status"])
})

test("the pinned client uses the earlier wire contract and retains admission/list timestamps", async () => {
const seen: Array<{ method: string; path: string; body: unknown }> = []
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/opencode/compatibility/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
// Credentialed native requests must never follow a redirect to another host.
const options: RequestInit = { method: request.method, headers, signal: AbortSignal.any([request.signal, lifetime]), redirect: "error" }
const originalPath = url.pathname
if (originalPath === "/api/status" && request.method === "GET" && identity?.discovery === "info") {
url.pathname = "/api/info"
}
if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) {
const text = request.body ? await request.text() : undefined
let body: unknown = text ? JSON.parse(text) : undefined
Expand Down
60 changes: 59 additions & 1 deletion packages/server/src/workspaces/host-opencode-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it } from "node:test"
import { HostOpenCodeService, hostOpenCodeServiceIdentity } from "./host-opencode-service"
import type { OpenCodeCliServiceDependencies, ServiceExecOptions } from "./opencode-cli-service"
import { OPENCODE_V2_REQUIRED_ERROR_CODE } from "../api-types"
import { runtimeIdentity } from "../opencode/compatibility/runtime"

const url = "http://127.0.0.1:4321"

Expand Down Expand Up @@ -79,7 +80,7 @@ describe("HostOpenCodeService", () => {
requests.push(String(input))
assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:password").toString("base64")}`)
if (requests.length === 1) return new Response(new ReadableStream({ cancel() { cancelled = true } }), { status: 404 })
return Response.json({ healthy: true, version: "2.0.3", pid: 123 })
return Response.json({ healthy: true, version: "2.0.0", pid: 123 })
},
})
assert.equal((await service[operation]())?.url, url)
Expand All @@ -88,6 +89,63 @@ describe("HostOpenCodeService", () => {
}
})

it("discovers server.info by route presence for any version on discovery and startup", async () => {
for (const operation of ["discover", "ensure"] as const) {
for (const version of ["2.0.7", "future-release"]) {
const requests: string[] = []
let cancelled = 0
const service = createService([], {}, {
execFile: async (_file, args) => ({ stdout: args.at(-1) === "password" ? "password\n" : `${url}\n`, stderr: "" }),
fetch: async (input, init) => {
requests.push(String(input))
assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:password").toString("base64")}`)
assert.equal(init?.redirect, "error")
return String(input).endsWith("/api/info")
? Response.json({ version, pid: 123, urls: [url], paths: { tmp: "/tmp/opencode" } })
: new Response(new ReadableStream({ cancel() { cancelled++ } }), { status: 404 })
},
})
const endpoint = await service[operation]()
assert.equal(runtimeIdentity(endpoint!)?.discovery, "info")
assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`, `${url}/api/info`])
assert.equal(cancelled, 2)
}
}
})

it("validates info responses and preserves the final probe deadline", async (context) => {
for (const response of [
() => new Response(null, { status: 401 }),
() => new Response(null, { status: 404 }),
() => new Response(null, { status: 503 }),
() => new Response("invalid JSON"),
() => Response.json({ version: "2.0.7", pid: 123 }),
() => Response.json({ version: "2.0.7", pid: -1, urls: [url] }),
() => new Response(" ".repeat(64 * 1024 + 1)),
]) {
const requests: string[] = []
const service = createService([], {}, {
fetch: async (input) => {
requests.push(String(input))
return requests.length < 3 ? new Response(null, { status: 404 }) : response()
},
})
await assert.rejects(service.ensure())
assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`, `${url}/api/info`])
}
let now = 1000
context.mock.method(Date, "now", () => now)
const requests: string[] = []
const service = createService([], {}, {
fetch: async (input) => {
requests.push(String(input))
return new Response(new ReadableStream({ cancel() { if (requests.length === 2) now = 1500 } }), { status: 404 })
},
})
await assert.rejects(service.ensure(1500), /timed out/)
assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`])
})

it("does not downgrade on authentication, server, transport or malformed status failures", async () => {
for (const response of [
() => new Response(null, { status: 401 }),
Expand Down
14 changes: 11 additions & 3 deletions packages/server/src/workspaces/opencode-cli-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle {
}

private async validateStatus(endpoint: Endpoint, deadlineAt: number): Promise<void> {
let kind: "status" | "health" = "status"
let kind: "status" | "health" | "info" = "status"
let response = await this.fetchServiceStatus(endpoint, kind, deadlineAt)
// Earlier V2 runtimes expose health instead of status. Negotiate only on
// route absence, using the same authenticated endpoint and deadline.
Expand All @@ -130,6 +130,13 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle {
kind = "health"
response = await this.fetchServiceStatus(endpoint, kind, deadlineAt)
}
// The current service exposes server.info. Discover by route presence,
// never by a release-number allowlist, and only retry this read on 404.
if (response.status === 404) {
await this.withDeadline(response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), deadlineAt, "health response")
kind = "info"
response = await this.fetchServiceStatus(endpoint, kind, deadlineAt)
}
if (response.status === 401) {
throw new Error(`${this.options.label} OpenCode service authentication failed at ${endpoint.url} (HTTP 401)`)
}
Expand All @@ -148,18 +155,19 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle {
} catch {
throw new Error(`${this.options.label} OpenCode service returned an invalid ${kind} response at ${endpoint.url}`)
}
if (!(kind === "status" ? isServiceStatusResponse(payload) : isServiceHealthResponse(payload))) {
if (!(kind === "health" ? isServiceHealthResponse(payload) : isServiceStatusResponse(payload))) {
throw new Error(`${this.options.label} OpenCode service returned an invalid ${kind} response at ${endpoint.url}`)
}
const { version, pid } = payload as { version: string; pid: number }
rememberRuntime(endpoint, { version, pid, discovery: kind })
}

private async fetchServiceStatus(endpoint: Endpoint, kind: "status" | "health", deadlineAt: number): Promise<Response> {
private async fetchServiceStatus(endpoint: Endpoint, kind: "status" | "health" | "info", deadlineAt: number): Promise<Response> {
try {
const timeout = this.remaining(deadlineAt, `${kind} validation`)
return await this.withDeadline(this.dependencies.fetch(new URL(`/api/${kind}`, endpoint.url), {
headers: Service.headers(endpoint),
redirect: "error",
signal: AbortSignal.timeout(timeout),
}), deadlineAt, `${kind} validation`)
} catch {
Expand Down
Loading
Loading