CodeNomad/scripts/test-opencode-setup-wsl.mjs
Pascal André 6a6367b560
chore(opencode): retire adaptations for runtimes and events OpenCode V2 no longer produces (#1034)
## Summary
This PR removes OpenCode compatibility code that nothing can reach any
more:
- Code that was already dead at the former 2.0.27 minimum, and is still
dead at the current 2.1.0 minimum (#1029).
- Handlers for events and data shapes that OpenCode V2 never produces.

Each item was checked against the upstream 2.0.27 and 2.1.0 sources
before removal.

Kept on purpose:
- Workarounds for upstream bugs or limits that are still present in
2.1.0.
- Code that reads conversations written by older runtimes: V1-migrated
tool names and metadata, `workspaceID` data, task summary titles,
`session-legacy` replay.
- Code that recognises and upgrades old CLIs and live older daemons: 1.x
replacement, beta recognition, 2.0.11 service discovery, 2.0.15 native
upgrade, `/api/status` and `/api/health` identification.

## Removed or changed
**Server**
- Negotiation: one "not modern → `canonical_api`" rule replaces the
pre-2.0.4 legacy schema recognition, and runs before #1029's
`native_contract` check.
- The transport no longer rewrites `/api/status`.
- Desktop skills no longer send the pre-2.0.4 `location` and `slash`
fields.
- The legacy `auth.json` usage fallback for daemons without
`credential.list` (pre-2.0.20) is removed, along with its UI reason and
the i18n key in 10 locales. A missing endpoint still fails safely.
- Workspace selectors are rejected up front, without a native resolution
request.
- CLI service probes try `/api/info` first. An admitted daemon no longer
costs two authenticated 404s on every check.

**UI**
- Remaining V1 `session.idle`/`session.status` branches: Missions store,
pending discovery (UI and server), `eventAffectsMessages`.
- `server.instance.disposed` and the now-unused `rehydrateInstance`.
- The V1 `worktree.ready` envelope (the handler now reads the V2 shape).
- V1 tool-state titles and the `success` status alias.
- Part `time.start` and `time.end`.
- Text stored as objects or segment arrays.
- `deriveMessageStatus`.
- The V1 `error.data.message` shape.
- `projectID` and `location` fallbacks on `session.created`.
- The replay-only `session.message.content.updated`.
- The unreachable `output.answers` fallback.

**Fixtures, Missions and docs**
- Legacy branches removed from the location, fork, families, pruning and
automation native fixtures. The WSL setup fixture now compares against
the reported version instead of 2.0.11.
- Unused 2.0.21 launch helpers removed.
- The Missions native and trajectory fixtures also run on the minimum
lane.
- The capability proof checks the reported version.
- Stale 2.0.2x labels now say "as of the 2.1.0 minimum".

**Follow-ups from the #1029 post-merge review**
- Test fixtures no longer send the ignored `recent: ""` compaction field
(18 sites).
- The manual compaction-isolation fixture accepts any published version
at or above the minimum.
- `CONTRIBUTING.md` wording updated.

114 files, +319 / −1117: source +90 / −487, tests/fixtures/CI +214 /
−604, i18n −10, docs +15 / −16.

## Validation (rebased on dev with #1029)
**Typechecks:** server and UI pass.

**Server tests**
- Touched areas: 480/480.
- Full server CI step: 2430 pass, 0 fail, 7 skipped, 1 cancelled. The
cancelled test is the `index.test.ts` real-process shutdown test; it hit
its 5 s timeout under load. It passes alone (4/4), and that file is
unchanged versus dev.

**UI unit tests**
- Touched files: 230/230.
- CI steps: 574/574, 200/200 and 519/519.

**Browser suites**, one at a time:
- Provider and service usage: provider-usage 13/13, service-usage 3/3.
- Missions: mission-visibility 3/3, mission-control-feedback 5/5,
mission-lifecycle-revision 10/10, mission-lifecycle-navigation 10/10,
mission-current-passage 1/1, mission-native-family 11/11.
- Transcript and rendering: compaction-responsiveness 5/5,
session-rendering 47/47, reasoning-collapse 3/3, render-cost 17/17,
tool-images 4/4, system-messages 10/10.
- Other: search-highlight 11 pass (1 skipped by its env gate),
panel-extensions 13/13.

**Native fixtures on an isolated 2.1.0 CLI:** location, session-fork,
automation, session-pruning, missions native, missions trajectory,
provider-usage, Missions capability proof and compaction-isolation all
pass. The WSL setup fixture was not run (it needs a WSL distro with a
Linux CLI).

## Left for later
- `RuntimeIdentity.discovery` is now write-only.
- `ownsLocation` has an unused `_client` parameter (about 30 callers).
- A test-only V1 translation in `auto-accept-manager.test.ts`.
- Out of scope: HTML-entity decoding (a product decision), the
`MutableHashMap` loop, the pruning-UI import-order workaround, unused
log-based Missions readers, and the unshipped managed-owner module with
its 2.0.24-pinned test.

Oversized source files touched: `stores/instances.ts` ~2,209,
`message-block.tsx` ~1,601, `workspaces/manager.ts` ~1,104,
`tool-call.css` ~1,047, `tool-call.tsx` ~865, `opencode-data.ts` ~865,
`api-types.ts` ~784, `session-events.ts` ~606,
`recurring-day-fixture.ts` ~535.
2026-10-20 19:19:47 +02:00

279 lines
17 KiB
JavaScript

// Real Windows -> WSL official lifecycle, authenticated native connection and
// plugin provisioning. The wrapper forces every command into fresh fixture state.
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { watch } from "node:fs"
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { tsImport } from "tsx/esm/api"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/service"
import { boundedFixtureOperation } from "./fixtures/wsl-fixture-bounds.mjs"
const deadlineAt = Date.now() + 180_000
const signal = AbortSignal.timeout(180_000)
const [distro, cli, mode = "aliased", check = ""] = process.argv.slice(2)
if (process.platform !== "win32" || !distro || !cli?.startsWith("/")) throw new Error("Pass a WSL distro and absolute Linux CLI path on Windows")
assert.ok(["native", "mounted", "aliased"].includes(mode), "Config mode must be native, mounted or aliased")
assert.ok(check === "" || check === "reload-safety", "Optional check must be reload-safety")
const quote = value => `'${value.replaceAll("'", `'"'"'`)}'`
function wsl(script, cleanup = false) {
if (!cleanup) signal.throwIfAborted()
return new Promise((resolve, reject) => {
const child = execFile("wsl.exe", ["--distribution", distro, "--exec", "/bin/bash", "-s"],
{ encoding: "utf8", timeout: cleanup ? 15_000 : Math.max(1, Math.min(60_000, deadlineAt - Date.now())),
...(!cleanup ? { signal } : {}), maxBuffer: 1024 * 1024, windowsHide: true },
(error, stdout, stderr) => error ? reject(new Error(`${error.message}\n${stderr}`)) : resolve(stdout.trim()))
child.stdin.end(`set -euo pipefail\n${script}\n`)
})
}
const parent = path.join(os.tmpdir(), "opencode")
await mkdir(parent, { recursive: true })
const mounted = await mkdtemp(path.join(parent, "codenomad-wsl-setup-"))
const mount = await wsl(`wslpath -au ${quote(mounted)}`)
const root = await wsl('mkdir -p "$HOME/.cache"\nmktemp -d "$HOME/.cache/codenomad-wsl-setup-XXXXXX"')
const password = randomUUID(), wrapper = `${root}/opencode-fixture`
const config = mode === "native" ? `${root}/config` : mode === "mounted" ? `${mount}/config` : `${root}/config-alias`
const unc = directory => `\\\\wsl.localhost\\${distro}${directory.replaceAll("/", "\\")}`
const configFile = mode === "native" ? unc(`${config}/opencode.json`) : path.join(mounted, "config/opencode.json")
// Stable CLI channels default to 49374 even with different XDG roots. Allocate
// an unused Windows port and verify it is unused in Linux before configuring it.
const reservation = createServer()
await new Promise((resolve, reject) => { reservation.once("error", reject); reservation.listen(0, "127.0.0.1", resolve) })
const port = reservation.address().port
await new Promise(resolve => reservation.close(resolve))
await wsl(`mkdir -p ${quote(`${mount}/config`)} ${quote(`${root}/config`)} ${quote(`${root}/home`)} ${quote(`${root}/repo`)}
${mode === "aliased" ? `ln -s ${quote(`${mount}/config`)} ${quote(config)}` : ""}
mkdir -m 700 ${quote(`${root}/runtime`)}
python3 -c ${quote(`import socket; s=socket.socket(); s.bind(('127.0.0.1', ${port})); s.close()`)}`)
const environment = {
PATH: "/usr/local/bin:/usr/bin:/bin", HOME: `${root}/home`, USERPROFILE: `${root}/home`,
XDG_DATA_HOME: `${root}/data`, XDG_CONFIG_HOME: `${root}/xdg-config`, XDG_STATE_HOME: `${root}/state`,
XDG_CACHE_HOME: `${root}/cache`, XDG_RUNTIME_DIR: `${root}/runtime`, OPENCODE_TEST_HOME: `${root}/home`, OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: `${root}/fixture.db`,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: [`${root}/probe-plugin`] }),
OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
}
let reloadProvider, reloadSafety, lifecycle, shared, automation, pruning, configWatcher
let started = false
const evidence = { root, mounted, mode, port }
async function until(predicate, message) {
const deadline = Math.min(deadlineAt, Date.now() + 30_000)
while (true) {
const options = { signal: AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))]) }
if (await boundedFixtureOperation(() => predicate(options), deadline, message, signal)) return
await delay(100, undefined, { signal })
}
}
async function startOrRestart(restart = false) {
signal.throwIfAborted()
const startDeadline = Math.min(deadlineAt, Date.now() + 30_000)
try { return await (restart ? lifecycle.restart(startDeadline) : lifecycle.ensure(startDeadline)) }
catch (error) {
if (!error.message.startsWith("Cannot reach the WSL OpenCode service from Windows")) throw error
// Forwarding may trail Linux readiness. Only rediscover our guarded service.
let endpoint
await until(async () => {
try { endpoint = await lifecycle.discover(Math.min(deadlineAt, Date.now() + 15_000)); return Boolean(endpoint) }
catch (retry) {
if (!retry.message.startsWith("Cannot reach the WSL OpenCode service from Windows")) throw retry
return false
}
}, "Fixture WSL localhost forwarding did not become ready")
return endpoint
}
}
async function runFixture() {
if (check === "reload-safety") {
const { startReloadSafetyProvider } = await import("./fixtures/wsl-reload-safety.mjs")
reloadProvider = await startReloadSafetyProvider({ distro, root, environment, unc, signal })
environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
plugins: [`${root}/probe-plugin`], model: "fixture/fixture",
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: `http://127.0.0.1:${reloadProvider.port}/v1`, apiKey: "fixture" }, models: { fixture: {} } } },
})
}
// Check the isolated registration and Linux process environment before any CLI
// command can probe or stop it. Never infer ownership from an endpoint or PID.
const guard = `import json, os, pathlib, socket
root = pathlib.Path(${JSON.stringify(root)})
registration = root / 'state/opencode/service.json'
if registration.exists():
info = json.loads(registration.read_text())
assert info['url'] == 'http://127.0.0.1:${port}', 'Unexpected fixture endpoint'
env = pathlib.Path('/proc/%s/environ' % info['pid']).read_bytes().split(b'\\0')
for key, value in json.loads(${JSON.stringify(JSON.stringify(environment))}).items():
assert (key + '=' + value).encode() in env, 'Fixture process ownership mismatch: ' + key
else:
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', ${port}))
s.close()
`
const script = `#!/bin/sh\nset -eu\n/usr/bin/python3 ${quote(`${root}/guard.py`)}\ncd ${quote(`${root}/repo`)}\nexec env -i ${Object.entries(environment).map(([key, value]) => `${key}=${quote(value)}`).join(" ")} ${quote(cli)} "$@"\n`
await wsl(`cat > ${quote(wrapper)} <<'CODENOMAD_FIXTURE'
${script}CODENOMAD_FIXTURE
cat > ${quote(`${root}/guard.py`)} <<'CODENOMAD_GUARD'
${guard}CODENOMAD_GUARD
chmod 700 ${quote(wrapper)}`)
await wsl(`mkdir ${quote(`${root}/probe-plugin`)}
cat > ${quote(`${root}/probe-plugin/index.ts`)} <<'CODENOMAD_PLUGIN'
import { appendFile } from 'node:fs/promises'
export default { id: 'wsl-fixture-probe', async setup(ctx) {
await appendFile(${JSON.stringify(`${root}/plugin-setup.log`)}, 'setup\\n')
await ctx.session.hook('http.request', event => event.request.headers.set('x-reload-kind', event.kind))
} }
CODENOMAD_PLUGIN`)
await wsl(`${quote(wrapper)} service set port ${port}
${quote(wrapper)} service set password ${quote(password)}`)
await writeFile(configFile, "{}\n")
const { WslOpenCodeService } = await tsImport("../packages/server/src/workspaces/wsl-opencode-service.ts", import.meta.url)
const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url)
const { resolveDesktopPluginPaths } = await tsImport("../packages/server/src/opencode/desktop-plugin-paths.ts", import.meta.url)
const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await tsImport("../packages/server/src/opencode/desktop-plugin-lifecycle.ts", import.meta.url)
lifecycle = new WslOpenCodeService({ distro, binary: wrapper, timeoutMs: 30_000 })
shared = new OpenCodeSharedService({ headers: Service.headers, makeClient: options => OpenCode.make({ ...options,
fetch: (input, init) => options.fetch(input, { ...init,
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000), ...(init?.signal ? [init.signal] : [])]),
}),
}) })
automation = new DesktopPluginLifecycle("automation")
pruning = new DesktopPluginLifecycle("session-pruning")
assert.equal(await lifecycle.discover(Math.min(deadlineAt, Date.now() + 15_000)), undefined, "Fresh fixture must never discover an existing service")
started = true
const endpoint = await startOrRestart()
assert.equal(endpoint.url, `http://127.0.0.1:${port}`)
const options = { kind: "lifecycle", identity: `wsl-fixture:${root}`, lifecycle }
const client = await shared.client(options, { deadlineAt })
const before = await client.server.info()
evidence.started = { endpoint: endpoint.url, pid: before.pid, version: before.version }
console.log(`Authenticated isolated ${distro} service: ${endpoint.url}, PID ${before.pid}; ${root}`)
// The selected isolated CLI, not a fixed release label, names the expected daemon.
assert.equal(before.version, (await wsl(`${quote(wrapper)} --version`)).split(/\s+/).at(-1).replace(/^v/, ""))
const connection = await shared.acquire()
const assertCurrent = () => { signal.throwIfAborted(); connection.assertCurrent() }
const paths = await resolveDesktopPluginPaths({ ...connection, assertCurrent }, { kind: "wsl", distro, binary: wrapper }, Math.min(deadlineAt, Date.now() + 15_000))
evidence.paths = paths
if (mode === "native") assert.ok(paths.config.startsWith(`\\\\wsl.localhost\\${distro}\\`))
else assert.equal(paths.config.toLowerCase(), path.dirname(configFile).toLowerCase(), "Mounted discovery roots, including symlink aliases, need Windows-native access paths")
assert.equal(await readFile(path.join(paths.config, "opencode.json"), "utf8"), "{}\n")
assert.ok(!paths.data.startsWith(`${paths.config}\\`), "Presence leases must stay outside the aliased/mounted config tree")
const location = { directory: `${root}/repo` }
await client.location.get({ location })
await until(async request => (await client.plugin.list({ location }, request)).data.some(plugin => plugin.id === "wsl-fixture-probe" && plugin.state.status === "active"), "Probe plugin did not activate")
assert.ok(!(await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad.automation"), "Location must be loaded before desktop plugins are installed")
if (reloadProvider) {
const { prepareReloadSafety } = await import("./fixtures/wsl-reload-safety.mjs")
reloadSafety = await prepareReloadSafety({ client, root, unc, provider: reloadProvider })
evidence.reloadSafety = { before: reloadSafety.before }
}
await prepareDesktopPluginPresence(paths, assertCurrent, { automation, pruning })
await delay(1_500)
evidence.beforeExplicitReload = (await client.plugin.list({ location })).data
.filter(plugin => plugin.id.startsWith("codenomad"))
.map(plugin => ({ id: plugin.id, status: plugin.state.status }))
// This explicitly tests the informed recovery action. It is never evidence
// of automatic watcher discovery; pre-reload native state is recorded above.
await client.location.reload({ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) })
if (reloadSafety) evidence.reloadSafety = await reloadSafety.observe()
await until(async request => {
const plugins = (await client.plugin.list({ location }, request)).data
evidence.plugins = plugins.filter(plugin => plugin.id.startsWith("codenomad") || plugin.id === "wsl-fixture-probe")
.map(plugin => ({ id: plugin.id, status: plugin.state.status }))
return ["codenomad.automation", "codenomad-session-pruning"].every(id => plugins.some(plugin => plugin.id === id && plugin.state.status === "active"))
}, "Late plugin discovery after native reload failed")
const changes = []
const watcher = mode === "native" ? undefined : watch(path.dirname(configFile), { recursive: true }, (event, filename) => changes.push({ event, filename }))
configWatcher = watcher
if (watcher) {
try {
const probe = path.join(path.dirname(configFile), ".watch-probe")
await writeFile(probe, "")
await rm(probe)
await until(async () => changes.some(change => change.filename === ".watch-probe"), "Windows config observer did not receive its control event")
} catch (error) { watcher.close(); throw error }
}
await delay(2_500)
const setupBefore = await readFile(unc(`${root}/plugin-setup.log`), "utf8")
const leasePaths = (await Promise.all(["automation", "session-pruning"].map(async feature => {
const directory = path.join(paths.data, feature, "presence")
return (await readdir(directory)).filter(name => name.endsWith(".lease")).map(name => path.join(directory, name))
}))).flat()
assert.equal(leasePaths.length, 2)
const leaseBefore = await Promise.all(leasePaths.map(async file => (await stat(file)).mtimeMs))
changes.length = 0
try {
// Windows ReadDirectoryChangesW cannot watch native WSL UNC directories.
// Observe Linux inotify there instead, including existing nested folders.
if (mode === "native") {
const events = await wsl(`python3 - ${quote(config)} ${quote(`${root}/runtime`)} <<'CODENOMAD_WATCH'
import ctypes, json, os, select, sys, time
libc = ctypes.CDLL(None, use_errno=True)
fd = libc.inotify_init1(os.O_NONBLOCK)
assert fd >= 0
# Prove this observer is live before measuring the real heartbeat interval.
control = libc.inotify_add_watch(fd, os.fsencode(sys.argv[2]), 0x00000fce)
assert control >= 0
probe = os.path.join(sys.argv[2], '.watch-probe')
open(probe, 'w').close()
os.unlink(probe)
assert select.select([fd], [], [], 2)[0]
os.read(fd, 1048576)
libc.inotify_rm_watch(fd, control)
os.read(fd, 1048576)
for directory, _, _ in os.walk(sys.argv[1]):
assert libc.inotify_add_watch(fd, os.fsencode(directory), 0x00000fce) >= 0
deadline = time.monotonic() + 5
events = []
while time.monotonic() < deadline:
if select.select([fd], [], [], max(0, deadline-time.monotonic()))[0]:
events.append(os.read(fd, 1048576).hex())
os.close(fd)
print(json.dumps(events))
CODENOMAD_WATCH`)
changes.push(...JSON.parse(events))
} else await delay(5_000)
} finally { watcher?.close() }
const leaseAfter = await Promise.all(leasePaths.map(async file => (await stat(file)).mtimeMs))
assert.ok(leaseAfter.every((time, index) => time > leaseBefore[index]), "Both backend leases must actually heartbeat")
assert.deepEqual(changes, [], "Heartbeats must leave the entire watched config tree unchanged")
assert.equal(await readFile(unc(`${root}/plugin-setup.log`), "utf8"), setupBefore, "Heartbeats must not rerun plugin setup")
assert.equal((await client.server.info()).pid, before.pid)
evidence.heartbeat = { durationMs: 5_000, leaseBefore, leaseAfter, changes, stableSetup: true }
evidence.nativeWatcherFailures = (await readFile(unc(`${root}/data/opencode/log/opencode.log`), "utf8"))
.split("\n").filter(line => line.includes('message="failed to subscribe"')).slice(-4)
await automation.stop(); await pruning.stop(); await shared.shutdown()
await startOrRestart(true)
const replacement = await shared.client(options, { deadlineAt })
evidence.restarted = { pid: (await replacement.server.info()).pid }
assert.notEqual(evidence.restarted.pid, before.pid)
assert.equal((await replacement.location.get({ location })).directory, location.directory)
evidence.passed = true
console.log(`PASS Windows→${distro} (${mode}): real service start/authentication/restart/reconnect, translated filesystem provisioning, late plugin discovery after explicit native reload and measured heartbeat stability; ${mounted}`)
}
try {
await boundedFixtureOperation(runFixture, deadlineAt, "WSL native fixture", signal)
} catch (error) {
evidence.error = error.message
throw error
} finally {
configWatcher?.close()
const cleanupDeadline = Date.now() + 20_000
const cleanups = [
["reload observer", () => reloadSafety?.dispose()], ["local provider", () => reloadProvider?.stop()],
["automation presence", () => automation?.stop()], ["pruning presence", () => pruning?.stop()],
["native client", () => shared?.shutdown()],
["isolated daemon", () => started ? wsl(`${quote(wrapper)} service stop`, true) : undefined],
]
const results = await Promise.allSettled(cleanups.map(([label, operation]) => boundedFixtureOperation(operation, cleanupDeadline, label)))
evidence.cleanup = results.map((result, index) => ({ operation: cleanups[index][0], status: result.status,
...(result.status === "rejected" ? { error: String(result.reason) } : {}) }))
await boundedFixtureOperation(() => writeFile(path.join(mounted, "evidence.json"), JSON.stringify(evidence, null, 2)), Date.now() + 5_000, "write evidence")
const failures = results.filter(result => result.status === "rejected").map(result => result.reason)
if (failures.length) throw new AggregateError(failures, "Isolated WSL fixture cleanup failed")
}