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
36 changes: 36 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
- authorize
- tests
- tests-tauri-windows
- tests-opencode-compatibility
- tests-tauri-macos
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml
Expand Down Expand Up @@ -219,6 +220,41 @@ jobs:
working-directory: packages/tauri-app/src-tauri
run: cargo test --locked -- --test-threads=1

# Exercise the selected-CLI contract on the reported macOS ARM64 platform as
# well as both other hosts, independently of unrelated server-suite failures.
tests-opencode-compatibility:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-26]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Test binary compatibility and shared lifecycle
run: >-
node --import tsx --test
packages/server/src/workspaces/__tests__/spawn.test.ts
packages/server/src/workspaces/__tests__/binary-probe.test.ts
"packages/server/src/workspaces/*opencode*.test.ts"
packages/server/src/server/routes/binary-validation.test.ts
"packages/server/src/opencode-update/*.test.ts"
packages/ui/src/lib/launch-errors.test.ts

# Exercise the window persistence regressions on the architecture reported in
# #676, independently of the Linux server gate. Packaging alone cannot test them.
tests-tauri-macos:
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,13 @@ export interface BinaryUpdateRequest {
makeDefault?: boolean
}

export const OPENCODE_V2_REQUIRED_ERROR_CODE = "opencode_v2_required" as const

export interface BinaryValidationResult {
valid: boolean
version?: string
error?: string
errorCode?: typeof OPENCODE_V2_REQUIRED_ERROR_CODE
}

export interface OpenCodeUpdateStatus {
Expand Down
41 changes: 41 additions & 0 deletions packages/server/src/server/routes/binary-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { it } from "node:test"
import Fastify from "fastify"
import pino from "pino"
import { registerSettingsRoutes } from "./settings"
import type { SettingsService } from "../../settings/service"
import { binaryProbeFixture, legacyHelp } from "../../workspaces/__tests__/binary-probe-fixture"

it("validates through HTTP without writing preferences or blocking other requests", async () => {
const app = Fastify()
registerSettingsRoutes(app, {
settings: new Proxy({} as SettingsService, { get() { throw new Error("Validation must not read or mutate settings") } }),
logger: pino({ level: "silent" }),
})
app.get("/ping", async () => ({ ok: true }))
await app.ready()
const compatible = binaryProbeFixture({ delayMs: 250 })
const legacy = binaryProbeFixture({ help: legacyHelp, stderr: true })
try {
let completed = false
const validating = app.inject({ method: "POST", url: "/api/storage/binaries/validate", payload: { path: compatible.binary } })
.then((response) => { completed = true; return response })
await delay(50)
assert.equal((await app.inject("/ping")).statusCode, 200)
assert.equal(completed, false, "HTTP must remain available while the CLI is slow")
const valid = await validating
assert.equal(valid.statusCode, 200)
assert.deepEqual(valid.json(), { valid: true })
const invalid = await app.inject({ method: "POST", url: "/api/storage/binaries/validate", payload: { path: legacy.binary } })
assert.equal(invalid.statusCode, 200)
assert.deepEqual(invalid.json(), { valid: false, errorCode: "opencode_v2_required" })
assert.deepEqual(compatible.calls(), [["--version"], ["service", "--help"]])
assert.deepEqual(legacy.calls(), [["--version"], ["service", "--help"]])
assert.equal((await app.inject({ method: "POST", url: "/api/storage/binaries/validate", payload: { path: 42 } })).statusCode, 400)
} finally {
await app.close()
compatible.dispose()
legacy.dispose()
}
})
8 changes: 4 additions & 4 deletions packages/server/src/server/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/spawn"
import type { BinaryValidationResult } from "../../api-types"
import { probeOpenCodeBinary } from "../../workspaces/spawn"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
Expand All @@ -14,9 +15,8 @@ const ValidateBinarySchema = z.object({
path: z.string(),
})

function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.error }
function validateBinaryPath(binaryPath: string): Promise<BinaryValidationResult> {
return probeOpenCodeBinary(binaryPath)
}

export function enforceSpeechCredentialPairing(body: unknown, currentSpeech?: unknown): unknown {
Expand Down
53 changes: 53 additions & 0 deletions packages/server/src/workspaces/__tests__/binary-probe-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"

export const serviceHelp = `DESCRIPTION
Manage the background server
USAGE
opencode2 service <subcommand> [flags]
SUBCOMMANDS
start Start the background server
status Show background server status
get Get service configuration
`

export const legacyHelp = `Commands:
opencode completion generate shell completion script
opencode [project] start opencode tui [default]
opencode attach <url> attach to a running opencode server
`

// Actual subprocess fixture: exercises host shell/shim quoting and never starts
// a real daemon or reads user configuration. Every invocation is recorded.
export function binaryProbeFixture(options: {
help?: string
delayMs?: number
helpExit?: number
stderr?: boolean
} = {}) {
const directory = mkdtempSync(path.join(tmpdir(), "codenomad-binary-é ' space-"))
const script = path.join(directory, "probe.cjs")
const log = path.join(directory, "calls.jsonl")
const binary = path.join(directory, process.platform === "win32" ? "opencode.cmd" : "opencode")
writeFileSync(script, `
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + '\\n');
setTimeout(() => {
const version = args.join(' ') === '--version';
if (!version && args.join(' ') !== 'service --help') process.exit(99);
process[${JSON.stringify(options.stderr ? "stderr" : "stdout")}].write(version ? 'custom-build\\n' : ${JSON.stringify(options.help ?? serviceHelp)});
process.exitCode = version ? 0 : ${options.helpExit ?? 0};
}, ${options.delayMs ?? 0});
`)
const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`
writeFileSync(binary, process.platform === "win32"
? `@echo off\r\n"${process.execPath}" "%~dp0probe.cjs" %*\r\n`
: `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 })
return {
binary,
calls: (): string[][] => readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)),
dispose: () => rmSync(directory, { recursive: true, force: true }),
}
}
81 changes: 81 additions & 0 deletions packages/server/src/workspaces/__tests__/binary-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { describe, it } from "node:test"
import { probeBinaryVersion, probeOpenCodeBinary } from "../spawn"
import { OPENCODE_V2_REQUIRED_ERROR_CODE } from "../../api-types"
import { binaryProbeFixture, legacyHelp, serviceHelp } from "./binary-probe-fixture"

describe("bounded read-only binary validation", () => {
it("keeps the event loop available during real slow version and help subprocesses", async () => {
const fixture = binaryProbeFixture({ delayMs: 250 })
try {
let completed = false
const result = probeOpenCodeBinary(fixture.binary).then((value) => { completed = true; return value })
await delay(50)
assert.equal(completed, false, "the probes must not block timers or other requests")
assert.deepEqual(await result, { valid: true })
assert.deepEqual(fixture.calls(), [["--version"], ["service", "--help"]])
} finally {
fixture.dispose()
}
})

it("rejects real legacy shims on stdout/stderr with both exit-zero and exit-one help", async () => {
for (const stderr of [false, true]) for (const helpExit of [0, 1]) {
const fixture = binaryProbeFixture({ help: legacyHelp, stderr, helpExit })
try {
assert.deepEqual(await probeOpenCodeBinary(fixture.binary), { valid: false, errorCode: OPENCODE_V2_REQUIRED_ERROR_CODE })
assert.deepEqual(fixture.calls(), [["--version"], ["service", "--help"]])
} finally {
fixture.dispose()
}
}
})

it("uses finite bounds for both probes without constraining custom version labels", async () => {
for (const version of ["custom-build\n", "opencode2 v0.0.0-beta-19192\n", "99.1.0\n", ""]) {
const calls: string[][] = []
const result = await probeOpenCodeBinary(process.execPath, async (spec, timeout) => {
assert.equal(timeout, 5_000)
calls.push(spec.args)
return { status: 0, stderr: spec.args[0] === "--version" ? version : serviceHelp }
})
assert.equal(result.valid, true)
assert.deepEqual(calls, [["--version"], ["service", "--help"]])
}
})

it("preserves missing-file, permission, timeout, output-limit and nonzero diagnostics", async () => {
assert.deepEqual(await probeOpenCodeBinary(""), { valid: false, error: "Missing binary path" })
for (const stage of ["--version", "service"]) for (const code of ["ENOENT", "EACCES", "ETIMEDOUT", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]) {
const calls: string[][] = []
const result = await probeOpenCodeBinary(process.execPath, (spec) => {
calls.push(spec.args)
return spec.args[0] === stage
? { status: null, error: new Error(code), stdout: legacyHelp }
: { status: 0, stdout: "2.0.0\n" }
})
assert.equal(result.valid, false)
assert.equal(result.error, code)
assert.equal(result.errorCode, undefined, "partial help must not mask an execution error")
assert.equal(calls.length, stage === "--version" ? 1 : 2)
}
const failed = await probeOpenCodeBinary(process.execPath, (spec) => spec.args[0] === "--version"
? { status: 0, stdout: "2.0.0\n" } : { status: 7, stderr: "Permission denied" })
assert.equal(failed.valid, false)
assert.match(failed.error ?? "", /code 7: Permission denied/)
assert.equal(failed.errorCode, undefined)
const rejected = await probeOpenCodeBinary(process.execPath, async () => { throw new Error("launch failed") })
assert.deepEqual(rejected, { valid: false, error: "launch failed" })
})

it("retains the version-only updater probe contract without probing service", () => {
const calls: string[][] = []
const result = probeBinaryVersion(process.execPath, (spec) => {
calls.push(spec.args)
return { status: 0, stderr: "1.18.25\n" }
})
assert.deepEqual(result, { valid: true, version: "1.18.25", reported: "1.18.25" })
assert.deepEqual(calls, [["--version"]])
})
})
45 changes: 45 additions & 0 deletions packages/server/src/workspaces/__tests__/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ import {
buildServiceLaunchSpec,
buildWindowsSpawnSpec,
parseWslUncPath,
probeOpenCodeBinary,
resolveWslHostDirectory,
resolveWslServiceDirectory,
resolveWslWorkingDirectory,
} from "../spawn"
import { OPENCODE_V2_REQUIRED_ERROR_CODE } from "../../api-types"

const legacyOpenCodeHelp = `\x1b[31mCommands:\x1b[0m
opencode completion generate shell completion script
opencode [project] start opencode tui [default]
opencode attach <url> attach to a running opencode server
`

describe("parseWslUncPath", () => {
it("parses WSL UNC paths into distro and linux path", () => {
Expand Down Expand Up @@ -260,6 +268,43 @@ describe("buildServiceLaunchSpec", () => {
})
})

describe("probeOpenCodeBinary", () => {
it("rejects a V1 binary whose root help exits successfully without a service command", async () => {
const calls: string[][] = []
const result = await probeOpenCodeBinary(process.execPath, (spec) => {
calls.push(spec.args)
return spec.args[0] === "--version"
? { status: 0, stdout: "1.18.25\n", stderr: "" }
: { status: 0, stdout: "", stderr: legacyOpenCodeHelp }
})

assert.deepEqual(calls, [["--version"], ["service", "--help"]])
assert.deepEqual(result, {
valid: false,
version: "1.18.25",
errorCode: OPENCODE_V2_REQUIRED_ERROR_CODE,
})
})

it("accepts a binary that supports the service lifecycle", async () => {
const result = await probeOpenCodeBinary(process.execPath, (spec) => spec.args[0] === "--version"
? { status: 0, stdout: "opencode2 v0.0.0-beta-18999\n", stderr: "" }
: { status: 0, stdout: "USAGE\n opencode2 service <subcommand> [flags]\nSUBCOMMANDS\n start Start server\n status Server status\n get Get configuration\n", stderr: "" })

assert.deepEqual(result, { valid: true, version: "0.0.0-beta-18999" })
})

it("does not accept an empty, unrelated, or daemon-status response as capability evidence", async () => {
for (const stdout of ["", "stopped\n", "http://127.0.0.1:1234\n", "arbitrary wrapper help\n"]) {
const result = await probeOpenCodeBinary(process.execPath, (spec) => ({
status: 0, stdout: spec.args[0] === "--version" ? "2.0.0\n" : stdout,
}))
assert.equal(result.valid, false, JSON.stringify(stdout))
assert.equal(result.errorCode, undefined, "unknown output must not be mislabeled as V1")
}
})
})

function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
28 changes: 28 additions & 0 deletions packages/server/src/workspaces/host-opencode-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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"

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

Expand Down Expand Up @@ -90,6 +91,33 @@ describe("HostOpenCodeService", () => {
assert.match(identity, /:env:[a-f0-9]{64}$/)
assert.equal(identity.includes(secret), false)
})

it("reports an actionable compatibility error for an OpenCode V1 binary", async () => {
const service = createService([], {}, {
execFile: async () => {
throw Object.assign(new Error("Command failed"), {
code: 1,
stdout: "",
stderr: `Commands:\n opencode completion\n opencode [project] start opencode tui [default]\n`,
})
},
})

await assert.rejects(service.discover(), (error: Error) => {
assert.match(error.message, new RegExp(OPENCODE_V2_REQUIRED_ERROR_CODE))
assert.doesNotMatch(error.message, /Commands:/)
return true
})
})

it("recognizes legacy help even when a wrapper exits zero on stdout or stderr", async () => {
for (const stream of ["stdout", "stderr"]) {
const service = createService([], {}, {
execFile: async () => ({ stdout: "", stderr: "", [stream]: "Commands:\n opencode completion\n opencode [project]\n" }),
})
await assert.rejects(service.discover(), new RegExp(`^Error: ${OPENCODE_V2_REQUIRED_ERROR_CODE}:`))
}
})
})

function createService(
Expand Down
Loading
Loading