feat(opencode): enforce stable runtime support and add managed setup

Require stable OpenCode >=2.0.11 <3.0.0 from authenticated daemon metadata before functional clients and plugin provisioning. Keep bounded contract recognition, connection-generation fencing and native ownership checks while retiring old request, response and event conversions.

Unify missing and outdated runtime recovery with Preferences. Install exact stable releases through bundled Node/npm into a verified versioned user prefix, preserve custom and WSL selections, and separate installation from explicit shared-service restart through the native-parent starter. Fence stale setup responses, retain pending folder opening and never replay prompts.

Certify isolated 2.0.3-to-2.0.11 history migration with executable hashes, untouched seed storage, paginated messages, forks, compaction checkpoints, location moves and pending inbox data. Add minimum/latest cross-platform migration and installation gates, packaged npm closure checks, real Solid recovery regressions and supported-contract authority tests.

Validated server/UI types, focused unit and reducer suites, browser captures, actual packaging-prepared Node 24.20.0 installation without system Node, and native location, automation and pruning fixtures. Document packaged-desktop, WSL, broader historical-state and remote-CI evidence as remaining release gates.
This commit is contained in:
Pascal André 2026-09-20 16:09:36 +02:00
parent 3e14312908
commit 7869e609b3
No known key found for this signature in database
61 changed files with 1486 additions and 768 deletions

View file

@ -400,6 +400,28 @@ jobs:
"packages/server/src/opencode-update/*.test.ts"
packages/ui/src/lib/launch-errors.test.ts
- name: Test first installation without system Node
run: node scripts/test-opencode-install-native.mjs
- name: Certify native history migration at the minimum and latest stable
shell: pwsh
run: |
$platform = node -p "process.platform === 'win32' ? 'windows' : process.platform"
$arch = node -p "process.arch"
$extension = node -p "process.platform === 'win32' ? '.exe' : ''"
$seed = Join-Path $env:RUNNER_TEMP 'opencode-migration-seed'
npm install --prefix $seed '@opencode/cli@2.0.3' --ignore-scripts --no-audit --no-fund
if ($LASTEXITCODE) { exit $LASTEXITCODE }
$oldCli = Join-Path $seed "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
foreach ($version in @('2.0.11', 'latest')) {
$target = Join-Path $env:RUNNER_TEMP "opencode-migration-$version"
npm install --prefix $target "@opencode/cli@$version" --ignore-scripts --no-audit --no-fund
if ($LASTEXITCODE) { exit $LASTEXITCODE }
$cli = Join-Path $target "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
node scripts/test-opencode-history-migration.mjs $oldCli $cli
if ($LASTEXITCODE) { exit $LASTEXITCODE }
}
# 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:

View file

@ -16,6 +16,8 @@ description: |
## Native OpenCode V2 Baseline
- PR #696 sets the release runtime floor to stable `>=2.0.11 <3.0.0` in `opencode/runtime-support.ts`, independently of contract recognition. Admission uses authenticated daemon metadata before functional clients/plugin provisioning. Missing/outdated runtimes share setup with bundled Node/npm and a versioned user prefix; shared-daemon restart is explicit and uses the existing native-parent launch bridge. Legacy request/response/event conversions are retired; historical identity/authority checks remain. See `dev-docs/OPENCODE_V2_POST_BETA.md` for migration evidence and release gates.
- Server and UI pin the official `@opencode/client@2.0.11` together; the pruning plugin pins `@opencode/plugin@2.0.11`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. Qualify against the latest published stable runtime. Each CodeNomad release sets its minimum supported OpenCode version to the latest stable available at that CodeNomad release; the minimum stays fixed for that release. Record results in PR/CI logs and update `dev-docs/OPENCODE_V2_COMPATIBILITY.md` in place rather than adding per-version reports. The runtime CLI is managed independently and startup has no exact version gate.
- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations.
- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The narrow integrations are the bundled `codenomad.automation` plugin and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md`, `dev-docs/BROWSER_AUTOMATION.md` and `dev-docs/SESSION_PRUNING_RPC.md`. All automation tools follow backend presence without a Developer Mode gate, sharing the authenticated native transport and execution-time session/window fences.

View file

@ -4,7 +4,7 @@
CodeNomad server and UI pin `@opencode/client@2.0.11`. 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.info()` to the discovered route. Older services do not expose `paths.tmp`; consumers may use only the metadata actually provided. 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.
Runtime admission lives in `packages/server/src/opencode/runtime-support.ts`: stable `>=2.0.11 <3.0.0`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Admission precedes functional clients/plugin provisioning; unlisted supported versions additionally require authenticated bounded OpenAPI recognition. Legacy request/response/event translations are retired. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_POST_BETA.md` for setup and migration evidence.
- Promise client: `import { OpenCode } from "@opencode/client"`
- Service authentication headers: `import { Service } from "@opencode/client/service"`
@ -30,7 +30,7 @@ Native methods return decoded Promise values. Follow the installed declarations
Native Forms own pending interruption state. Global Forms use `sessionID: "global"` and `x-opencode-directory: encodeURIComponent(directory)`; ordinary session Forms derive location from the session. Question tool output rendering is independent of pending Forms.
Earlier V2 location identity must survive modern generated-client field selection. Use `locationRequestOptions` (server) / `requestLocationOptions` (UI) for the explicit private context channel; the proxy authorizes the complete pair, translates its directory, and the selected transport serializes the appropriate legacy slots. Modern public APIs still reject workspace selectors. Session move/rollback uses `moveSessionToLocation`, not a cast adding fields to the modern method input.
Historical location identity must not silently disappear through generated-client field selection. `locationRequestOptions` (server) / `requestLocationOptions` (UI) retain an explicit private context channel so the proxy can reject obsolete selectors rather than accidentally authorizing directory-only requests. Supported public APIs reject workspace selectors. Session move/rollback uses `moveSessionToLocation`, not a cast adding fields to the generated method input.
Stable mutations use `permission.reply({ decision })`, `session.command({ name })`, `session.interrupt({ resume })`, `session.fork({ before? })`, `session.inbox.update({ delivery })` and `session.message.get(...)`. Credential removal is global and takes only `credentialID`. There is no plugin activation-wait endpoint; catalog reads and `plugin.updated` supply native state.

View file

@ -7,8 +7,8 @@
- 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.
- Proxy authorization and forwarding share one acquired connection. A stale generation must be rejected at actual HTTP dispatch, including after asynchronous body preparation; late streams cannot invalidate a replacement connection.
- Adapt legacy HTTP inbox timestamps before the native Solid reducer. Native `session.inbox.enqueued` is a different shape: its timestamp belongs to the event metadata and must not be treated as an HTTP inbox record.
- Compatibility never retries a write using another contract after a 400/404/transport failure. Unknown version numbers are recognized through authenticated OpenAPI structure rather than an exact runtime-version gate.
- Require stable `>=2.0.11 <3.0.0` from authenticated daemon metadata before clients or plugin provisioning; unlisted supported versions additionally require bounded OpenAPI recognition. Legacy HTTP inbox and event conversions are retired. Native `session.inbox.enqueued` still has a distinct shape: its timestamp belongs to event metadata, not an HTTP inbox record.
- Never retry a write using another contract after a 400/404/transport failure. Keep the setup/recovery path distinct from functional transport and never replay prompts.
## Location Is Authority

View file

@ -10,7 +10,7 @@ OpenCode owns standard state/database. Allowed configured environment variables
Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory. Its `project` field supplies project metadata. Public locations have no workspace selector. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts.
The paragraph above describes the canonical modern wire contract. Preserve any native legacy `workspaceID` internally and authorize it through the connection-scoped adapter; directory-only serialization must not erase identity from cache keys, pending Forms, imported history or move rollback. See `opencode/compatibility/location.ts` for the explicit context channel that survives generated serialization.
The supported wire contract requires stable `>=2.0.11 <3.0.0`; old request/response/event translations are retired. Preserve historical `workspaceID` validation internally: obsolete selectors must be rejected rather than erased from imports, cursors, pending Forms or move rollback. Native 2.0.3→2.0.11 migration collapses workspace selectors to local directory scope while preserving session IDs. See `dev-docs/OPENCODE_V2_POST_BETA.md`.
The instance proxy is method/path allowlisted, rejects unowned paths, `directory`, `location.directory`, and `location[directory]` values, and verifies session location before forwarding. Keep this check at the server trust boundary; new upstream routes require explicit review.

View file

@ -26,6 +26,8 @@
## Coding Principles
- OpenCode runtime admission is fixed per release in `opencode/runtime-support.ts` (currently stable `>=2.0.11 <3.0.0`). Validate authenticated daemon metadata before client use/plugin provisioning. Shared setup handles missing/outdated installations with bundled Node/npm and a versioned user prefix; a running daemon restart is a separate explicit action. Preserve historical identity/ownership checks even after retiring old wire adapters. See `dev-docs/OPENCODE_V2_POST_BETA.md` and its isolated installation/migration fixtures.
- Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`.
- One bundled `codenomad.automation` V2 plugin uses native discovery and backend presence. All browser/developer tools are available without a Developer Mode toggle; native instrumentation starts with the desktop host. Loading, tool availability and execution targeting are separate: retain the authenticated bridge and session/window fences. Tauri preview children receive no application capabilities; primary-renderer reload must dispose them and final-window checks must count native windows. See `dev-docs/BROWSER_AUTOMATION.md`.

View file

@ -10,7 +10,13 @@ Server and UI pin the official `@opencode/client@2.0.11`; the bundled pruning pl
The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md).
The current cross-version issue register, published API-change timeline and connection-scoped compatibility architecture are maintained in [`dev-docs/OPENCODE_V2_COMPATIBILITY.md`](dev-docs/OPENCODE_V2_COMPATIBILITY.md). The server integration module adapts earlier V2 requests/responses for both the guarded UI proxy and direct server callers; native regression runs cross the pinned client with beta-19271, 2.0.3, 2.0.4 and 2.0.5. Discovery success alone does not certify an older runtime's conversation, event or pruning contract.
The current cross-version issue register and historical API-change audit are maintained in [`dev-docs/OPENCODE_V2_COMPATIBILITY.md`](dev-docs/OPENCODE_V2_COMPATIBILITY.md). This release requires stable OpenCode **>=2.0.11 <3.0.0**, checked against authenticated daemon metadata before client use or plugin provisioning. Earlier wire adapters are retired; their regression results below are historical evidence rather than live support.
### Installation and required updates (PR #696)
Missing and outdated OpenCode use the same setup screen in startup/recovery and Preferences. Default host installation uses CodeNomad's bundled Node/npm, including when system Node is absent, into `~/.local/share/codenomad/opencode`. Custom/WSL binaries retain explicit execution-host instructions. Updating files does not update an already-running daemon: the screen offers a separate explicit shared-service restart, then revalidates authority. Pending folder opening can resume; prompts are never replayed.
The isolated 2.0.3→2.0.11 migration fixture preserves session IDs, complete paginated message content, forks, compaction checkpoints, moved sessions and a pending inbox record. OpenCode itself collapses old workspace selectors to local directory scope. Historical ownership checks remain; CodeNomad does not rewrite the database. See the [transition register](dev-docs/OPENCODE_V2_POST_BETA.md) for validation commands and outstanding packaged/platform release gates.
## Native V2 Adoption

View file

@ -14,6 +14,16 @@ compatibility code and historical-data handling do not imply support for older
runtimes. Keep detailed results in the change's PR and CI logs, not in per-version
reports. Update this reference in place.
PR #696 implements a fixed minimum of **2.0.11** for this development release:
stable `>=2.0.11 <3.0.0`, with separate authenticated contract recognition for
unlisted releases. Missing/outdated runtimes share installation and recovery UI;
the bundled Node/npm installer and explicit daemon restart are distinct actions.
Legacy request/response/event translations have been retired. Historical
identity, import/cursor, cancellation and connection-authority checks remain.
The isolated 2.0.3→2.0.11 seed confirms native workspace-selector collapse while
preserving session IDs and complete history. See the
[transition register](OPENCODE_V2_POST_BETA.md) for actual coverage and release gates.
The audit and implementation record below is historical context, not a runtime
qualification matrix to maintain.
@ -22,7 +32,7 @@ qualification matrix to maintain.
## Decision summary
Future retirement of the earlier-runtime adapter and the shared installation/required-update experience are tracked in the [OpenCode V2 stable-runtime transition plan](OPENCODE_V2_POST_BETA.md). That maintained plan preserves this register as the current support/evidence baseline and selects no new minimum runtime yet.
The [OpenCode V2 stable-runtime transition register](OPENCODE_V2_POST_BETA.md) supersedes the earlier live-support set and records implementation and acceptance evidence. The sections below preserve the original #695 audit.
At `bcfe4d24`, CodeNomad had a working modern-client path and backward-compatible service discovery, **not a complete backward-compatible V2 integration**. The follow-up change keeps that discovery fix and addresses the remaining issues through one connection-scoped integration module and one cross-runtime acceptance matrix.

View file

@ -1,171 +1,121 @@
# OpenCode V2 stable-runtime transition and installation plan
# OpenCode V2 stable-runtime transition
**Status:** maintained implementation plan; this documentation increment is mergeable independently of the implementation gates. Runtime support is unchanged.
**Starting point:** [PR #695](https://github.com/NeuralNomadsAI/CodeNomad/pull/695), merged into `dev` at `aa51cbb9` on 2026-09-16.
**Related register:** [current compatibility contracts and evidence](OPENCODE_V2_COMPATIBILITY.md).
**Implementation:** PR #696, based on #695 and the client/plugin 2.0.11 alignment
in #728. This register describes code and local evidence; remote CI and packaged
desktop acceptance are separate release gates.
## Objective
## Release contract
Track the transition to published stable OpenCode V2 contracts, a shared installation/update experience, and deliberate retirement of earlier wire contracts. Land this plan, then maintain it through focused implementation PRs. Keep the eventual support-policy change and adapter deletion reviewable together.
- This development release fixes `MINIMUM_OPENCODE_VERSION` at **2.0.11**.
npm `@opencode/cli@latest` resolved to 2.0.11 on 2026-09-20.
- Supported versions are stable `>=2.0.11 <3.0.0`. Prereleases, malformed
versions, older releases and future majors are refused with
`opencode_update_required` / HTTP 426.
- Client and plugin dependencies remain separately pinned at 2.0.11.
- Authenticated daemon metadata, not the selected executable's version, controls
connection admission. Admission precedes functional clients and plugin
provisioning. Replacement daemons are checked again, even at the same URL.
- Unlisted supported releases must also pass bounded authenticated OpenAPI
recognition. A retired or unrecognized contract never receives speculative
writes. The minimum is never fetched from npm at application startup.
Three versions must remain distinct:
## Setup and recovery
| Version | Current baseline | Transition decision |
| --- | --- | --- |
| CodeNomad dependencies | Exact `@opencode/client@2.0.4` and `@opencode/plugin@2.0.4` | Select reviewed dependency versions and update their lockfile together. |
| Connected OpenCode daemon | Native acceptance on beta-19271, 2.0.3, 2.0.4 and 2.0.5 | Choose an explicit minimum runtime and supported release range. |
| CodeNomad release | Support established by #695 | Announce the release that adopts the new minimum. |
The global setup dialog and Preferences share `OpenCodeUpdateCard` and the same
store. Missing and below-minimum installations use one screen with state-specific
actions, installed/running/minimum versions, retry and executable selection.
Dismissal leaves a recovery entry; optional updates do not force the dialog.
Connection changes, foreground entry and unsupported proxy responses refresh
recovery state. Stale responses cannot overwrite a changed executable or a
completed action. Only a pending folder open may resume; prompts are not replayed.
OpenCode V2 is available on the stable `latest` channel; beta exit is no longer a pending gate. Upstream's [availability announcement commit](https://github.com/anomalyco/opencode/commit/7b7a67080e) and the published `@opencode/cli`, `@opencode/client` and `@opencode/plugin` 2.0.10 packages (checked on 2026-09-19) establish that publication status. They do not establish API immutability or certify every runtime behavior.
Default host installations use bundled Node and the npm distributed in its pinned
official archive. Both Electron and Tauri resources include npm's complete
dependency/license closure. npm installs an exact policy-compatible release into
a staging directory under `~/.local/share/codenomad/opencode`, verifies the real
executable, then publishes a versioned installation and atomic `current` marker.
No administrator rights, system Node, global npm install or application-resource
writes are required. Execution is bounded to five minutes and 1 MiB output.
Installation failures retain the previous marker. Concurrent requests coalesce;
another backend's same-version installation is accepted only after verification.
No minimum or retirement date is selected yet: choose these from reviewed contracts and historical-data acceptance. A newer CLI installed on disk also does not prove the already-running daemon was upgraded. [PR #715](https://github.com/NeuralNomadsAI/CodeNomad/pull/715) already moved the updater to the stable `@opencode/cli` package; first installation and the shared setup screen below remain implementation work.
Explicit custom binaries remain selected. WSL and custom installations receive
execution-host instructions rather than a Windows-side Linux installation.
Remote setup runs on the CodeNomad server, not the browser machine.
## Permanent interface
Installation and service activation are separate. An old running daemon requires
the explicit **Restart shared service** action, whose copy explains interruption
of other clients' active work. Activation uses official `service stop` and the
existing native-parent `service start` bridge, authenticates the daemon, validates
its contract and replaces local authority. Ordinary backend shutdown never stops
OpenCode. Unknown/newer daemons cannot be downgraded by this action.
Keep `OpenCodeSharedService.acquire()` as the shared seam for the guarded browser proxy and direct server callers, including Yolo. It binds authenticated endpoint/daemon identity, client, transport, connection lifetime and invalidation. Retiring one adapter must not move contract decisions into UI stores or individual callers.
## Historical migration
The future single-contract implementation must preserve:
`scripts/test-opencode-history-migration.mjs OLD_CLI TARGET_CLI` creates synthetic
2.0.3 storage, closes it, copies its DB/WAL and lets the target runtime perform its
own migration. It retains seed exports, untouched seed storage, CLI versions,
executable SHA-256 hashes and logs in a fresh temporary fixture. It never connects
to the shared service or rewrites native tables.
- Authentication, loopback/origin checks, credential-safe redirects, bounded discovery and absolute deadlines.
- Dispatch-generation checks after asynchronous body preparation; retired streams cannot publish or invalidate a replacement connection.
- Independent caller cancellation and connection-scoped negotiation/validation.
- Native error envelopes, especially streamed 401 responses; no mutation retry under another contract after failure.
- Location/session/path ownership, the proxy allowlist and worktree mutation fences.
- Exact project/destination directory identity, distinct from containing-worktree identity, with exact session rollback.
- Native `cursor.next`, scoped Forms and validated encoded global Form headers.
- Separate background `shell.*` and interactive `pty.*` behavior.
- Authoritative cache reconciliation, historical message access, and intentional handling of native idle control records.
- Pruning's fresh storage-identity challenge, synchronous SQLite execution-claim fence and explicit-request-only deletion.
- A standalone pruning package with complete local imports, plus bundled-plugin presence/disposal and late discovery.
Local Windows acceptance for **2.0.3 → 2.0.11** covers:
The directory name `compatibility/` is not a deletion instruction. Some of its implementation provides these permanent guarantees.
- Three source location identities, including two workspace identities at one
directory, with independently addressable session IDs and forks.
- 215 messages per seeded conversation: synthetic history, reasoning, text,
completed tool output and a pre-compaction checkpoint. Export equality and
native message pagination preserve complete history.
- Native session pagination, a moved worktree session and a pending inbox record.
- **Native identity rule:** the target migrates historical `workspaceID` values
to the local directory scope while preserving session IDs and content. The
fixture reports this explicitly; CodeNomad does not manufacture aliases.
## Retirement inventory
The existing native pruning fixture independently verifies current Forms,
Shell/PTY scope, permission/Yolo replies, model payload, fork independence,
pre-compaction pruning, execution-claim races, plugin disposal and daemon restart.
This is not a claim that every old pending Form or provider configuration has
been tested across the storage migration.
Paths below are relative to the repository root. This is a working deletion map, not authorization to remove code before its evidence is available.
## Retirement disposition
| Area / entry points | Retirement candidate | Evidence required / behavior retained |
| --- | --- | --- |
| `packages/server/src/opencode/compatibility/runtime.ts` | Audited legacy release list and legacy profile classification | Keep authenticated daemon metadata. Introduce a support decision separately from wire-contract recognition. |
| `packages/server/src/opencode/compatibility/negotiate.ts` | Recognition of the retired contract family | Decide how future/unlisted releases are recognized. A version above the minimum is not blanket certification of future majors or snapshots. |
| `packages/server/src/opencode/compatibility/requests.ts` | Legacy route, method and payload translations | Verify all consumed operations against the selected minimum and latest validated runtime before deletion. |
| `packages/server/src/opencode/compatibility/transport.ts` | Legacy status-envelope and HTTP inbox timestamp conversions | Preserve authentication, lifetime/cancellation, origin checks, diagnostics and modern forwarding. Native SSE inbox timestamps remain a distinct schema. |
| `packages/server/src/opencode/compatibility/events.ts` | Legacy permission-event renaming | Prove supported runtimes emit the canonical event; preserve durable metadata and reconnect reconciliation. |
| `packages/server/src/workspaces/opencode-cli-service.ts` | Older discovery routes | Current discovery tries authenticated `/api/status`, `/api/health`, then `/api/info`, advancing only on 404. Retire a route only against the selected supported contracts; retain authentication, response bounds and shared deadlines. |
| `packages/server/src/opencode/compatibility/location.ts` and `proxy-locations.ts` | Legacy wire selectors and private-header reconstruction | Prove historical location identity remains representable and authorized. Keep import/cursor validation and global Forms scope. |
| `packages/ui/src/stores/request-locations.ts` and its callers | Legacy request options/private context | Review Forms, metadata/provider credentials, session creation/moves and Shell calls together. Do not collapse cache keys while distinct identities remain observable. |
| `packages/ui/src/stores/instance-invalidation.ts` and `session-pruning-events.ts` | Legacy-only catalog/content event branches | Keep current authoritative refresh, pruning events and in-flight invalidation coverage. |
| `packages/server/src/opencode/session-pruning/location.ts`, `service.ts`, `preview-store.ts` | No automatic deletion | Identity/storage validation concerns historical data, not only old HTTP clients. Preserve exact membership and package closure. |
| `.github/workflows/pr-build.yml` and native fixtures | Positive runtime-support jobs for retired releases | Replace with minimum + latest validated runtime coverage; retain migration seeds and former authority/race regressions where meaningful. |
Keep the original compatibility audit as historical evidence. Record which predicates/tests were removed, retained or replaced in the implementation PR rather than overwriting the old results.
## Work packages
### P0 — Establish and maintain the transition register (this increment)
- [x] Base the draft on the merged compatibility implementation.
- [x] Distinguish dependency versions, daemon support and the CodeNomad release decision.
- [x] Map retirement candidates and permanent guarantees to current files.
- [x] Define historical-data acceptance, support-policy decisions and release gates below.
- [x] Record stable publication and specify one setup experience for missing and below-minimum OpenCode, including the bundled-Node installation proposal.
This increment adds documentation only. All implementation and native-validation checkboxes below are deliberately open.
### P1 — Make support policy an explicit connection decision
- [ ] Separate "recognized contract" from "supported runtime" at the shared connection seam, preserving today's accepted releases during preparation.
- [ ] Define the future minimum, supported release range and treatment of development/unlisted versions from publication evidence.
- [ ] Evaluate the actual authenticated daemon identity before functional calls or mutations, for both proxy and direct callers; re-evaluate on connection replacement.
- [ ] Surface a structured unsupported-runtime error with actual/required versions and an upgrade action explained in every UI locale. Authentication/transport errors retain their own classification.
- [ ] Use the shared setup screen in P1a for missing binaries and below-minimum runtimes; gate affected connections using backend policy rather than a UI-only version comparison.
- [ ] Verify below-minimum refusal, unknown-contract refusal, same-port replacement, independent cancellation and absence of speculative calls/retries.
- [ ] Review plugin provisioning order so rejecting an unsupported runtime does not bypass the existing explicit-request-only pruning rule or daemon ownership policy.
Do not add an inactive universal capability framework or a second client interface. The first implementation should keep the existing profile resolver and consumer seam, with the smallest support-policy decision needed by real callers. CodeNomad does not silently upgrade, restart or stop the shared daemon.
### P1a — Shared installation and required-update experience
**Proposed behavior, not yet implemented.** Use one setup screen with state-specific copy and actions, reachable from startup, connection recovery and Preferences. A persistent connection notice opens this same screen when a running application detects an unsupported replacement daemon; deduplicate it per execution target and connection generation. Block affected OpenCode operations, while keeping settings and unrelated supported connections accessible.
| Detected state | Screen and action |
| Area | Implementation |
| --- | --- |
| OpenCode command missing | Explain the selected host or WSL distribution; offer **Install and start OpenCode**, choose an existing executable, and retry detection. |
| Installed CLI or authenticated daemon below CodeNomad's minimum | Show installed CLI version, running daemon version when available, minimum required version and proposed target; offer **Update OpenCode** using the same installer. |
| Supported daemon, newer optional release available | Non-blocking update notice using the same screen; being behind `latest` is not itself unsupported. |
| Installed CLI is current but the running daemon is too old | Explain that installation succeeded but the shared service still needs an explicit restart; do not report connection readiness yet. |
| Authentication, transport or unrecognized-contract failure | Preserve that diagnosis and provide retry/details; do not mislabel it as a missing installation or assume upgrading fixes it. |
| Audited beta/old-stable live-support list | Removed; fixed policy and current-contract admission replace it. |
| Legacy routes, methods and payload translation | `compatibility/requests.ts` removed. |
| Legacy HTTP inbox/status conversions | Removed from transport. |
| Legacy permission/step event normalization | Removed; admitted runtimes emit native canonical events. |
| Authentication, cancellation, origin and connection generations | Retained and regression-tested at the existing shared seam. |
| Older discovery routes | Retained for authenticated diagnosis of an outdated running daemon, not functional live support. |
| Historical location/context, import/cursor and pruning checks | Retained. Obsolete public selectors remain rejected; these are authority checks, not permission to run old daemons. |
| Legacy schema recognition | Retained to diagnose and reject a retired contract; no legacy serializer remains. |
- [ ] Reuse `packages/server/src/opencode-update/service.ts` for installation and upgrade. Its package-manager command already performs an install, but status/upgrade currently require a successful existing-binary probe. Add a genuine missing state, not a fabricated version or an install attempt after any probe error.
- [ ] Resolve the stable channel to an exact policy-compatible target before installation; re-resolve the executable and verify its actual version afterward. Coordinate concurrent requests per installation target and bound execution time/output. Preserve explicitly selected custom executables and their update instructions.
- [ ] For desktop hosts without a system Node/npm, use the bundled Node to execute a bundled, pinned npm CLI. `scripts/prepare-node-runtime.cjs` currently copies only the Node executable, so npm and its dependencies/licensing must be included and packaging verified. Install into a persistent user-writable prefix outside application resources; explicitly resolve the resulting executable and child-process PATH without requiring a terminal or administrator rights.
- [ ] Keep host and WSL execution targets explicit. Windows' bundled Node cannot install a native Linux CLI inside WSL; use a verified Linux runtime/package manager in the selected distribution or show its installation instructions. Remote connections require installation on the execution host, not on the browser's computer.
- [ ] After **Install and start**, use the existing official `service status` / `service start` lifecycle and native-parent launch bridge, then authenticate and validate the actual daemon contract before resuming the pending workspace open. Do not spawn a private `serve` process or assume installer exit means readiness.
- [ ] Treat updating files and restarting an existing shared daemon as separate outcomes. Explain that restart affects other OpenCode clients and active work; perform it only through an explicit user action and the official service lifecycle. Reacquire connection authority afterward; never replay a prompt or mutation automatically.
- [ ] Localize all copy, show progress and actionable failures, and retain retry/manual-path choices in the same screen. Preserve the user's pending project selection and drafts through recovery.
- [ ] Verify absent CLI with/without system Node/npm, below-minimum CLI and daemon, stale daemon after upgrade, optional updates, registry/install failures, custom paths, repeated clicks, and host/WSL targeting. Use isolated installation prefixes and synthetic services; test the rendered screen and packaged Electron/Tauri launches without changing a user's global CLI or shared daemon.
## Validation and release gates
Deliver P1/P1a in focused implementation PRs and update this register with their evidence. The setup experience must be available before raising the runtime floor in P4.
Implemented checks include server/UI typechecks, server regressions, real Solid
browser setup tests and rendered captures, isolated bundled-Node installation,
native migration, native locations/worktrees, automation heartbeat/provisioning
and pruning acceptance. CI adds first installation and old-seed migration at
the fixed minimum and resolved latest stable on Windows, Linux and macOS ARM64.
The existing cross-platform native pruning gate remains in place.
### P2 — Certify historical data on the replacement runtime
Open release gates, not completed local claims:
- [ ] Add reproducible synthetic-history seeds using the already-audited legacy runtimes; retain exact executable/package versions and integrity metadata.
- [ ] Keep an untouched seed copy, then let the official target runtime migrate an isolated copy through its normal startup path. Do not manually rewrite OpenCode tables to manufacture compatibility.
- [ ] Validate this matrix through the real shared connection, guarded proxy, generated client and reducer:
- Green remote CI for the final diff and independent review.
- Packaged Electron/Tauri startup, update/restart and reconnect interaction on
release artifacts. Native desktop automation was unavailable in this session.
- Windows→WSL traversal and mounted/aliased configuration-root watcher coverage.
- Broader old-runtime seeds and old pending Forms/provider-state migration.
| Synthetic legacy history | Required result on target runtime |
| --- | --- |
| Root and worktree sessions, including two identities at the same directory | Every session remains distinguishable/accessible, or an explicit native migration rule is documented and tested; ambiguous identity collapse blocks retirement. |
| More than 200 messages and multiple native session pages | Full history remains reachable through native pagination; obsolete cursor state is discarded and reread without rewriting history. |
| Moved sessions and location-switched history | Current ownership and historical locations are preserved; exact evacuation/rollback and foreign import/cursor refusal still pass. |
| Tool/reasoning content, forks and pre-compaction history | Content and fork independence survive migration/restart; pruning affects only explicit selections and preserves model-context semantics. |
| Native pending inbox and control records | Reopened state converges from authoritative reads/events; idle outcomes do not become fabricated assistant text. |
| Global/session Forms, Shell/PTY scope and provider metadata | Location-sensitive operations retain their documented authority and current native lifecycle behavior. |
Do not remove the remaining historical authority checks based solely on this
synthetic seed. Keep the compatibility audit in
[OPENCODE_V2_COMPATIBILITY.md](OPENCODE_V2_COMPATIBILITY.md) as historical evidence.
Old-runtime execution for generating migration fixtures is distinct from promising live support for those runtimes. Keep that purpose explicit when reducing the CI matrix.
## Decision history
### P3 — Simplify behind the existing interface
- [ ] Remove legacy request/response/event translations once P1 and P2 establish the supported replacement contract and data behavior.
- [ ] Remove the discovery fallback only with matching minimum-runtime evidence.
- [ ] Simplify private location context and UI callers only where the migration matrix proves it unnecessary; retain historical identity validation where the target still exposes it.
- [ ] Preserve and rerun the original authority, connection-generation, no-retry, import/cursor, rollback and pruning regressions against the supported contract.
- [ ] Review imports from both the bundled and separately packed pruning plugin; no dependency may escape the standalone archive.
### P4 — Publish the new support floor
- [x] Record upstream stable availability (see Objective).
- [ ] Select exact target contract/dependency versions from acceptance evidence.
- [ ] Choose and announce the CodeNomad release/minimum OpenCode runtime, including the distinction between updating the CLI and the running daemon.
- [ ] Run minimum + latest validated native acceptance, including packed/bundled pruning and rendered controls, on Windows, macOS and Linux.
- [ ] Verify Electron and Tauri startup/reconnect/refusal behavior, plus host-to-WSL traversal for supported WSL configurations.
- [ ] Resolve or explicitly scope the existing mounted-configuration late-discovery limitation and aliased config-root watcher behavior; canonical temporary fixtures alone do not certify those user environments.
- [ ] Update release notes, `MIGRATION_V2.md`, architecture references and `AGENTS.md` together with the implementation.
- [ ] Complete independent gatekeeper review, fix/retest findings, and obtain green CI for the final diff.
## Working in parallel
Merge this documentation increment as the maintained transition register. Track P1–P4 in focused implementation PRs, updating this document's progress and evidence links with each increment. Open implementation checkboxes are not blockers for the documentation PR; they remain gates for changing runtime support and retiring adapters.
For each increment, record:
1. Base commit and the work package advanced.
2. Any change to the currently supported runtime set (normally none during preparation).
3. Contract/publication evidence, exact native artifacts and isolated migration results.
4. Which invariants/tests replace retired compatibility tests.
5. Remaining decisions or failed checks, without converting local evidence into a remote-CI claim.
The eventual retirement change is ready when the support-policy, historical-data and release gates are complete. Until then, the merged #695 compatibility behavior remains the product baseline.
## Decision log
| Date | Decision / open question | Disposition |
| --- | --- | --- |
| 2026-09-16 | Start from merged #695 rather than a parallel rewrite. | Adopted; preparation branch based on `aa51cbb9`. |
| 2026-09-16 | Runtime 2.0.5 and client/plugin 2.0.4 are different version axes. | Preserve that distinction in the draft and release communication. |
| 2026-09-16 | Remove legacy wire support separately from historical-data identity handling. | Adopted; P2 gates identity simplification. |
| 2026-09-16 | Which upstream release marks beta exit, and which CodeNomad release raises the minimum? | Open; no version/date selected. |
| 2026-09-16 | What supported range and future-version recognition replace two-profile negotiation? | Open; decide during P1 from the actual publication contract. |
| 2026-09-20 | Stable V2 publication is established; stop waiting for beta exit. | Supersedes the beta-exit question above; the CodeNomad minimum/release decision remains open. |
| 2026-09-20 | Land and maintain the plan independently of its implementation. | Documentation completion is distinct from P1–P4 acceptance. |
| 2026-09-20 | Missing and too-old OpenCode share a setup screen and installer. | Specified in P1a, including required-update notices, bundled Node/npm, execution-host targeting and daemon revalidation; implementation pending. |
- 2026-09-16: #695 established connection-scoped compatibility; #696 began as a
documentation-only retirement plan without selecting a floor.
- 2026-09-20: stable V2 publication is established. The user requested the actual
implementation in #696. The plan-only status is superseded by the code above.
- 2026-09-20: minimum/client/plugin baseline 2.0.11; setup precedes admission
recovery, updates never silently restart the externally owned daemon, and
native historical identity validation remains independent of live support.

View file

@ -373,11 +373,19 @@ export interface BinaryValidationResult {
}
export interface OpenCodeUpdateStatus {
currentVersion: string
currentVersion: string | null
latestVersion: string | null
updateAvailable: boolean | null
canUpgrade: boolean
checkError?: "update_check_failed"
minimumVersion: string
state: "missing" | "update_required" | "ready" | "error"
binaryPath: string
daemonVersion?: string
serviceState?: "stopped" | "ready" | "restart_required" | "error"
serviceError?: string
target: "host" | "wsl"
canRestart: boolean
}
export interface OpenCodeUpdateResponse {

View file

@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { installManagedOpenCode, readManagedExecutable } from "./managed-installation"
test("failed install never changes the selected version; verified install publishes atomically", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-installer-"))
try {
let correct = false
const options = { root, node: process.execPath, npm: "fixture-npm.js",
execute: async (file: string, args: string[], env: NodeJS.ProcessEnv) => {
assert.equal(file, process.execPath)
assert.ok(args.includes("@opencode/cli@2.0.11"))
assert.ok(Object.entries(env).some(([key, value]) => key.toLowerCase() === "path" && value?.startsWith(path.dirname(process.execPath))))
const staging = args[args.indexOf("--prefix") + 1]!
await mkdir(path.join(staging, "node_modules/@opencode/cli/bin"), { recursive: true })
await writeFile(path.join(staging, "node_modules/@opencode/cli/bin/opencode.exe"), "fixture")
},
probe: (file: string) => ({ valid: correct && file.includes(".install-"), version: correct && file.includes(".install-") ? "2.0.11" : undefined }),
}
await assert.rejects(installManagedOpenCode("2.0.11", options), /verification failed/)
assert.equal(readManagedExecutable(root), undefined)
correct = true
const binary = await installManagedOpenCode("2.0.11", options)
assert.equal(readManagedExecutable(root), binary)
await assert.rejects(installManagedOpenCode("2.0.10", options), /opencode_update_required/)
assert.equal(readManagedExecutable(root), binary)
} finally { await rm(root, { recursive: true, force: true }) }
})

View file

@ -0,0 +1,89 @@
import { execFile } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { randomUUID } from "node:crypto"
import { assertSupportedOpenCode } from "../opencode/runtime-support"
import { probeBinaryVersion } from "../workspaces/spawn"
// Never place changing installations in OpenCode's recursively watched config.
export function managedInstallRoot(): string {
return path.join(os.homedir(), ".local", "share", "codenomad", "opencode")
}
export function managedExecutable(root: string, version: string): string {
return path.join(root, version, "node_modules", "@opencode", "cli", "bin", "opencode.exe")
}
export function readManagedExecutable(root = managedInstallRoot()): string | undefined {
try {
const version = readFileSync(path.join(root, "current"), "utf8").trim()
if (!/^\d+\.\d+\.\d+$/.test(version)) return undefined
const binary = managedExecutable(root, version)
return existsSync(binary) ? binary : undefined
} catch { return undefined }
}
/** npm from the official Node archive, beside the backend runtime on both hosts. */
export function bundledNpm(execPath = process.execPath): string | undefined {
const directory = path.dirname(execPath)
const candidates = [path.join(directory, "node_modules", "npm", "bin", "npm-cli.js"),
path.join(directory, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")]
return candidates.find(candidate => existsSync(candidate))
}
export function executeInstaller(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
return new Promise((resolve, reject) => {
execFile(file, args, { env, windowsHide: true, timeout: 300_000, maxBuffer: 1024 * 1024 }, error => {
// Do not return npm's environment/config diagnostics to the browser.
if (error) reject(new Error(`OpenCode installation failed (${error.killed ? "timeout" : error.code ?? "execution"})`))
else resolve()
})
})
}
export async function installManagedOpenCode(version: string, options: {
root?: string; node?: string; npm?: string
execute?: typeof executeInstaller
probe?: typeof probeBinaryVersion
env?: NodeJS.ProcessEnv
} = {}): Promise<string> {
assertSupportedOpenCode(version)
const root = options.root ?? managedInstallRoot()
const node = options.node ?? process.execPath
const npm = options.npm ?? bundledNpm(node)
if (!npm) throw new Error("npm is unavailable beside the CodeNomad Node runtime")
const destination = path.join(root, version)
const staging = path.join(root, `.install-${randomUUID()}`)
const probe = options.probe ?? probeBinaryVersion
await mkdir(root, { recursive: true })
try {
// Never overwrite the executable used by a running Windows daemon.
const existing = probe(managedExecutable(root, version))
if (!existing.valid || existing.version !== version) {
await mkdir(staging)
const env = { ...(options.env ?? process.env) }
const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"
env[pathKey] = `${path.dirname(node)}${path.delimiter}${env[pathKey] ?? ""}`
await (options.execute ?? executeInstaller)(node, [npm, "install", "--prefix", staging,
"--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", `@opencode/cli@${version}`], env)
const stagedBinary = path.join(staging, "node_modules", "@opencode", "cli", "bin", "opencode.exe")
const result = probe(stagedBinary)
if (!result.valid || result.version !== version) throw new Error("Installed OpenCode version verification failed")
await rename(staging, destination).catch(error => {
// A second backend can finish the same exact version first. Accept only
// a verified winner; never remove or overwrite an existing executable.
const installed = probe(managedExecutable(root, version))
if (!installed.valid || installed.version !== version) throw error
})
}
const marker = path.join(root, `.current-${randomUUID()}`)
await writeFile(marker, version, "utf8")
try { await rename(marker, path.join(root, "current")) }
finally { await rm(marker, { force: true }) }
return managedExecutable(root, version)
} finally {
await rm(staging, { recursive: true, force: true })
}
}

View file

@ -1,217 +1,175 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
OpenCodeUpdateError,
OpenCodeUpdateService,
TARGET_OPENCODE_CHANNEL,
buildOpenCodeUpgradeCommand,
compareOpenCodeVersionStrings,
detectOpenCodePackageManager,
resolveLatestOpenCodeVersion,
type OpenCodeUpdateServiceDeps,
} from "./service"
import { OpenCodeUpdateService, OpenCodeUpdateError, buildOpenCodeUpgradeCommand,
resolveLatestOpenCodeVersion, detectOpenCodePackageManager, compareOpenCodeVersionStrings,
type OpenCodeUpdateServiceDeps } from "./service"
import { MINIMUM_OPENCODE_VERSION as minimum } from "../opencode/runtime-support"
import { rememberRuntime } from "../opencode/compatibility/runtime"
import type { Endpoint } from "@opencode/client/service"
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
let currentVersion = "0.0.0-beta-19271"
const latestVersion = "2.0.10"
function deps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
let version = "0.0.0-beta-19271"
return {
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
probeBinary: () => ({ valid: true, version: currentVersion }),
resolveLatestVersion: async () => latestVersion,
resolveBinary: () => ({ path: "opencode2", label: "OpenCode" }),
probeBinary: () => ({ valid: true, version }),
resolveLatestVersion: async () => minimum,
canUpgradeBinary: () => true,
upgradeBinary: async (_binary, target) => {
currentVersion = latestVersion
return { success: true, version: target }
},
upgradeBinary: async (_binary, target) => { version = target; return { success: true, version: target } },
...overrides,
}
}
test("offers the current stable release to an installation on the old beta package", async () => {
const service = new OpenCodeUpdateService(createDeps())
assert.deepEqual(await service.getStatus(), {
currentVersion: "0.0.0-beta-19271",
latestVersion: "2.0.10",
updateAvailable: true,
canUpgrade: true,
})
test("missing installs and old releases use one updater; probe failures are not absence", async () => {
for (const initial of [undefined, "0.0.0-beta-19271", "2.0.10"]) {
let version = initial
const service = new OpenCodeUpdateService(deps({
probeBinary: () => version ? { valid: true, version } : { valid: false, missing: true },
upgradeBinary: async (_binary, target) => { version = target; return { success: true, version: target } },
}))
const status = await service.getStatus()
assert.equal(status.state, initial ? "update_required" : "missing")
assert.equal(status.minimumVersion, minimum)
assert.equal(status.canUpgrade, true)
assert.deepEqual(await service.upgrade(), { success: true, version: minimum })
assert.equal((await service.getStatus()).state, "ready")
}
const invalid = new OpenCodeUpdateService(deps({ probeBinary: () => ({ valid: false, error: "EACCES" }) }))
assert.equal((await invalid.getStatus()).state, "error")
assert.equal((await invalid.getStatus()).canUpgrade, false)
await assert.rejects(invalid.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "binary_unavailable")
})
test("keeps the update visible for a custom binary", async () => {
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
assert.deepEqual(await service.getStatus(), {
currentVersion: "0.0.0-beta-19271",
latestVersion: "2.0.10",
updateAvailable: true,
canUpgrade: false,
})
test("offline registry retains local required-update diagnosis", async () => {
const service = new OpenCodeUpdateService(deps({ resolveLatestVersion: async () => { throw new Error("offline") } }))
const status = await service.getStatus()
assert.equal(status.state, "update_required")
assert.equal(status.checkError, "update_check_failed")
assert.equal(status.canUpgrade, false)
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "update_check_failed")
})
test("upgrades the managed OpenCode binary to the advertised version", async () => {
const calls: Array<{ path: string; target: string }> = []
let currentVersion = "0.0.0-beta-19271"
const latestVersion = "2.0.10"
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
resolveLatestVersion: async () => latestVersion,
upgradeBinary: async (binary, target) => {
calls.push({ path: binary.path, target })
currentVersion = latestVersion
return { success: true, version: target }
},
test("custom and WSL executables remain user managed", async () => {
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: "\\\\wsl.localhost\\Ubuntu\\usr\\bin\\opencode", label: "WSL" }),
canUpgradeBinary: () => false,
}))
assert.deepEqual(await service.upgrade(), { success: true, version: latestVersion })
assert.deepEqual(calls, [{ path: "opencode", target: latestVersion }])
const status = await service.getStatus()
assert.equal(status.target, "wsl")
assert.equal(status.canUpgrade, false)
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary")
})
test("rejects success when the configured binary was not updated", async () => {
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: "0.0.0-beta-1" }),
test("installation re-resolves the executable and rejects false success", async () => {
let installed = false
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: installed ? "managed" : "opencode2", label: "OpenCode" }),
probeBinary: path => path === "managed" ? { valid: true, version: minimum } : { valid: false, missing: true },
upgradeBinary: async (_binary, target) => { installed = true; return { success: true, version: target } },
}))
assert.equal((await service.upgrade()).version, minimum)
const wrong = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: "2.0.10" }),
upgradeBinary: async (_binary, target) => ({ success: true, version: target }),
}))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
)
await assert.rejects(wrong.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed")
})
test("rejects a different release installed while the update command is running", async () => {
let currentVersion = "2.0.9"
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
upgradeBinary: async (_binary, target) => {
currentVersion = "2.0.11"
return { success: true, version: target }
},
}))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
)
})
test("joins concurrent upgrades for the same binary", async () => {
let currentVersion = "2.0.9"
const latestVersion = "2.0.10"
test("coalesces overlapping installations and never downgrades a newer version", async () => {
let upgrades = 0
let finishUpgrade: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
finishUpgrade = resolve
})
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
resolveLatestVersion: async () => latestVersion,
upgradeBinary: async (_binary, target) => {
upgrades += 1
await gate
currentVersion = latestVersion
return { success: true, version: target }
},
let version = "2.0.10"
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version }),
upgradeBinary: async (_binary, target) => { upgrades++; await gate; version = target; return { success: true, version: target } },
}))
const first = service.upgrade()
const second = service.upgrade()
finishUpgrade?.()
assert.deepEqual(await Promise.all([first, second]), [
{ success: true, version: latestVersion },
{ success: true, version: latestVersion },
])
const first = service.upgrade(), second = service.upgrade()
release()
await Promise.all([first, second])
assert.equal(upgrades, 1)
version = "2.0.999"
assert.equal((await service.getStatus()).updateAvailable, false)
assert.equal((await service.upgrade()).version, version)
assert.equal(upgrades, 1)
})
test("rejects an upgrade for a custom binary", async () => {
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary",
)
})
test("reports registry failures as update check failures", async () => {
const service = new OpenCodeUpdateService(createDeps({
resolveLatestVersion: async () => {
throw new Error("registry unavailable")
},
test("stale daemon needs explicit restart; status and installation never restart it", async () => {
let daemonVersion = "2.0.10", restarts = 0, reconnects = 0
const endpoint = () => {
const value: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(value, { version: daemonVersion, pid: 123, discovery: "info" })
return value
}
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: minimum }),
lifecycle: async () => ({ discover: async () => endpoint(), ensure: async () => endpoint(),
restart: async () => { restarts++; daemonVersion = minimum; return endpoint() } }),
reconnect: async () => { reconnects++ },
}))
await assert.rejects(
() => service.getStatus(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "update_check_failed",
)
assert.equal((await service.getStatus()).serviceState, "restart_required")
assert.equal(restarts, 0)
await assert.rejects(service.start(), /opencode_update_required/)
assert.equal(restarts, 0)
assert.equal(reconnects, 0)
assert.equal((await service.start(true)).serviceState, "ready")
assert.equal(restarts, 1)
assert.equal(reconnects, 1)
})
test("resolves stable latest from the current package even when a newer beta is available", async () => {
const version = await resolveLatestOpenCodeVersion(async (url, init) => {
test("resolves exact stable package versions and rejects malformed/prerelease tags", async () => {
assert.equal(await resolveLatestOpenCodeVersion(async (url) => {
assert.equal(url, "https://registry.npmjs.org/-/package/%40opencode%2Fcli/dist-tags")
assert.deepEqual(init?.headers, { Accept: "application/json" })
return new Response(JSON.stringify({ latest: "2.0.10", beta: "2.1.0-beta.1" }))
})
assert.equal(version, "2.0.10")
})
test("rejects malformed registry dist-tags data", async () => {
await assert.rejects(
() => resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify({ "dist-tags": { beta: "beta" } }))),
/did not resolve to a valid version/,
)
})
test("builds official V2 package-manager update commands", () => {
assert.equal(TARGET_OPENCODE_CHANNEL, "latest")
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "npm"), {
command: "npm",
args: ["install", "-g", "@opencode/cli@2.0.10"],
})
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "pnpm"), {
command: "pnpm",
args: ["add", "-g", "--allow-build=@opencode/cli", "@opencode/cli@2.0.10"],
})
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "bun"), {
command: "bun",
args: ["install", "-g", "--trust", "@opencode/cli@2.0.10"],
})
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "yarn"), {
command: "yarn",
args: ["global", "add", "@opencode/cli@2.0.10"],
})
})
test("does not downgrade installations newer than the stable release", async () => {
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: "2.0.11" }),
upgradeBinary: async () => { assert.fail("a newer installation must not be overwritten") },
}))
assert.equal((await service.getStatus()).updateAvailable, false)
assert.deepEqual(await service.upgrade(), { success: true, version: "2.0.11" })
})
test("rejects a missing or prerelease latest tag instead of falling back to beta", async () => {
for (const tags of [{ beta: "0.0.0-beta-19271" }, { latest: "2.1.0-beta.1" }]) {
await assert.rejects(
resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify(tags))),
/did not resolve to a valid version/,
)
return new Response(JSON.stringify({ latest: minimum, beta: "2.1.0-beta.1" }))
}), minimum)
for (const tags of [{ beta: "0.0.0-beta-19271" }, { latest: "2.1.0-beta.1" }, { latest: "2.0.11;whoami" }]) {
await assert.rejects(resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify(tags))), /valid version/)
}
})
test("detects the package manager from the binary path or launch environment", () => {
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
assert.equal(detectOpenCodePackageManager("/usr/local/bin/opencode2", { npm_config_user_agent: "yarn/1.22" }), "yarn")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", {}), "npm")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", { npm_config_user_agent: "pnpm/10" }), "npm")
assert.equal(detectOpenCodePackageManager("/home/ubuntu/bin/opencode2", {}), "npm")
test("activation coalesces repeated clicks and refuses a changed selection before reconnect", async () => {
let selected = "original", restarts = 0, reconnects = 0
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const endpoint = (version: string) => {
const value: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(value, { version, pid: 123, discovery: "info" })
return value
}
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: selected, label: selected }),
probeBinary: () => ({ valid: true, version: minimum }),
lifecycle: async () => ({ discover: async () => endpoint("2.0.10"), ensure: async () => endpoint(minimum),
restart: async () => { restarts++; await gate; return endpoint(minimum) } }),
reconnect: async () => { reconnects++ },
}))
const first = service.start(true), second = service.start(true)
assert.equal(first, second)
selected = "replacement"
release()
await assert.rejects(first, /selection changed/)
assert.equal(restarts, 1)
assert.equal(reconnects, 0)
})
test("compares monotonically numbered V2 beta builds numerically", () => {
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-9999", "0.0.0-beta-10000") < 0, true)
test("an unsupported latest or newer daemon cannot trigger a downgrade or restart", async () => {
const value: Endpoint = { url: "http://127.0.0.1:9876" }
rememberRuntime(value, { version: "3.0.0", pid: 123, discovery: "info" })
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version: minimum }),
resolveLatestVersion: async () => "3.0.0",
lifecycle: async () => ({ discover: async () => value, ensure: async () => value,
restart: async () => { assert.fail("must not restart a newer runtime") } }),
}))
assert.equal((await service.getStatus()).canUpgrade, false)
assert.equal((await service.getStatus()).canRestart, false)
await assert.rejects(service.upgrade(), /opencode_update_required/)
await assert.rejects(service.start(true), /not an older runtime/)
})
test("legacy package-manager helpers retain V2 commands and beta comparison", () => {
assert.deepEqual(buildOpenCodeUpgradeCommand(minimum, "npm"), { command: "npm", args: ["install", "-g", `@opencode/cli@${minimum}`] })
assert.deepEqual(buildOpenCodeUpgradeCommand(minimum, "pnpm"), { command: "pnpm", args: ["add", "-g", "--allow-build=@opencode/cli", `@opencode/cli@${minimum}`] })
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
})

View file

@ -4,23 +4,31 @@ import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types"
import type { SettingsService } from "../settings/service"
import { BinaryResolver, type ResolvedBinary } from "../settings/binaries"
import type { WorkspaceManager } from "../workspaces/manager"
import { probeBinaryVersion } from "../workspaces/spawn"
import { probeBinaryVersion, probeBinaryVersionAsync } from "../workspaces/spawn"
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor"
import { legacyOpenCodeRemoval } from "./legacy-package"
import { assertSupportedOpenCode, MINIMUM_OPENCODE_VERSION, supportsOpenCodeVersion } from "../opencode/runtime-support"
import { runtimeIdentity } from "../opencode/compatibility/runtime"
import { installManagedOpenCode } from "./managed-installation"
import type { OpenCodeServiceLifecycle } from "../workspaces/opencode-service"
import { parseWslUncPath } from "../workspaces/spawn"
const OPENCODE_PACKAGE_NAME = "@opencode/cli"
const OPENCODE_REGISTRY_URL = "https://registry.npmjs.org/-/package/%40opencode%2Fcli/dist-tags"
export const TARGET_OPENCODE_CHANNEL = "latest"
const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
const inFlightActivations = new Map<string, Promise<OpenCodeUpdateStatus>>()
type UpgradeResult = { success: true; version: string } | { success: false; error: string }
export interface OpenCodeUpdateServiceDeps {
resolveBinary: () => ResolvedBinary
probeBinary: typeof probeBinaryVersion
probeBinary: (path: string) => ReturnType<typeof probeBinaryVersion> | Promise<ReturnType<typeof probeBinaryVersion>>
resolveLatestVersion: () => Promise<string>
canUpgradeBinary: (binary: ResolvedBinary) => boolean
upgradeBinary: (binary: ResolvedBinary, target: string) => Promise<UpgradeResult>
lifecycle?: (binary: ResolvedBinary) => Promise<OpenCodeServiceLifecycle>
reconnect?: (binary: ResolvedBinary) => Promise<void>
}
export class OpenCodeUpdateError extends Error {
@ -43,17 +51,70 @@ export class OpenCodeUpdateService {
async getStatus(): Promise<OpenCodeUpdateStatus> {
const binary = this.deps.resolveBinary()
const currentVersion = this.readCurrentVersion(binary.path)
const latestVersion = await this.readLatestVersion()
const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0
const canUpgrade = this.deps.canUpgradeBinary(binary)
return {
currentVersion,
latestVersion,
updateAvailable,
canUpgrade: updateAvailable && canUpgrade,
let currentVersion: string | null = null
let missing = false
let invalid = false
const probe = await this.deps.probeBinary(binary.path)
try { currentVersion = await this.readCurrentVersion(binary.path, probe) }
catch { missing = probe.missing === true; invalid = !missing }
let latestVersion: string | null = null
try { latestVersion = await this.readLatestVersion() } catch { /* Local admission remains available offline. */ }
const state = invalid ? "error" : missing ? "missing"
: currentVersion && supportsOpenCodeVersion(currentVersion) ? "ready" : "update_required"
const updateAvailable = latestVersion ? !currentVersion || compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 : null
const status: OpenCodeUpdateStatus = {
currentVersion, latestVersion, updateAvailable,
canUpgrade: !invalid && Boolean(updateAvailable) && Boolean(latestVersion && supportsOpenCodeVersion(latestVersion)) && this.deps.canUpgradeBinary(binary),
minimumVersion: MINIMUM_OPENCODE_VERSION, state, binaryPath: binary.path,
target: parseWslUncPath(binary.path) ? "wsl" : "host", canRestart: false,
...(!latestVersion ? { checkError: "update_check_failed" as const } : {}),
}
if (!missing && !invalid && this.deps.lifecycle) {
try {
const lifecycle = await this.deps.lifecycle(binary)
const endpoint = await lifecycle.discover()
const identity = endpoint && runtimeIdentity(endpoint)
status.daemonVersion = identity?.version
status.serviceState = !endpoint ? "stopped" : identity && supportsOpenCodeVersion(identity.version) ? "ready"
: identity && currentVersion && /^(?:0|1|2)\./.test(identity.version)
&& compareOpenCodeVersionStrings(currentVersion, identity.version) > 0 ? "restart_required" : "error"
status.canRestart = Boolean(lifecycle.restart) && state === "ready" && status.serviceState === "restart_required"
} catch { status.serviceState = "error"; status.serviceError = "service_check_failed" }
}
return status
}
start(restart = false): Promise<OpenCodeUpdateStatus> {
const binary = this.deps.resolveBinary()
const existing = inFlightActivations.get(binary.path)
if (existing) return existing
const pending = this.activate(binary, restart).finally(() => {
if (inFlightActivations.get(binary.path) === pending) inFlightActivations.delete(binary.path)
})
inFlightActivations.set(binary.path, pending)
return pending
}
private async activate(binary: ResolvedBinary, restart: boolean): Promise<OpenCodeUpdateStatus> {
const installedVersion = await this.readCurrentVersion(binary.path)
assertSupportedOpenCode(installedVersion)
const lifecycle = await this.deps.lifecycle?.(binary)
if (!lifecycle) throw new Error("OpenCode service lifecycle unavailable")
const previous = await lifecycle.discover()
if (restart && previous) {
const identity = runtimeIdentity(previous)
if (!identity || !/^(?:0|1|2)\./.test(identity.version)
|| compareOpenCodeVersionStrings(installedVersion, identity.version) <= 0) {
throw new Error("The shared daemon is not an older runtime eligible for this update")
}
}
const endpoint = restart && previous ? await lifecycle.restart?.() : previous ?? await lifecycle.ensure()
const identity = endpoint && runtimeIdentity(endpoint)
if (!identity) throw new Error("OpenCode did not report an authenticated runtime version")
assertSupportedOpenCode(identity.version)
if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation")
await this.deps.reconnect?.(binary)
return this.getStatus()
}
upgrade(): Promise<OpenCodeUpdateResponse> {
@ -69,10 +130,12 @@ export class OpenCodeUpdateService {
}
private async performUpgrade(binary: ResolvedBinary): Promise<OpenCodeUpdateResponse> {
const currentVersion = this.readCurrentVersion(binary.path)
const probe = await this.deps.probeBinary(binary.path)
const currentVersion = probe.missing ? null : await this.readCurrentVersion(binary.path, probe)
const latestVersion = await this.readLatestVersion()
assertSupportedOpenCode(latestVersion)
if (compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
return { success: true, version: currentVersion }
}
@ -88,7 +151,7 @@ export class OpenCodeUpdateService {
if (!result.success) {
throw new OpenCodeUpdateError("upgrade_failed", result.error)
}
const installedVersion = this.readCurrentVersion(binary.path)
const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path)
if (installedVersion !== latestVersion) {
throw new OpenCodeUpdateError(
"upgrade_verification_failed",
@ -105,11 +168,11 @@ export class OpenCodeUpdateService {
}
}
private readCurrentVersion(binaryPath: string): string {
private async readCurrentVersion(binaryPath: string, probe?: ReturnType<typeof probeBinaryVersion>): Promise<string> {
if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) {
throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid")
}
const result = this.deps.probeBinary(binaryPath)
const result = probe ?? await this.deps.probeBinary(binaryPath)
const version = stripTagPrefix(result.version)
if (!result.valid || !version) {
throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version")
@ -244,9 +307,17 @@ export function createOpenCodeUpdateService(
const binary = binaryResolver.resolveDefault()
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
},
probeBinary: probeBinaryVersion,
probeBinary: probeBinaryVersionAsync,
resolveLatestVersion: resolveLatestOpenCodeVersion,
canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2",
upgradeBinary: installOpenCodeCli,
canUpgradeBinary: () => {
const configured = settings.getOwner("config", "server").opencodeBinary
return !configured || configured === "opencode" || configured === "opencode2"
},
upgradeBinary: async (_binary, version) => {
await installManagedOpenCode(version)
return { success: true, version }
},
lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle),
reconnect: binary => workspaceManager.reconnectAfterSetup(binary.path),
})
}

View file

@ -1,15 +0,0 @@
import type { OpenCodeEvent } from "@opencode/client"
export function normalizeRuntimeEvent(event: OpenCodeEvent): OpenCodeEvent {
// Native metadata/durable identity is retained. Catalog/content changes stay
// invalidation intents; no synthetic upstream durable records are invented.
if ((event.type as string) === "session.permissions.updated") {
return { ...event, type: "session.permissions" } as OpenCodeEvent
}
if (event.type === "session.step.started" && event.data.started === undefined) {
// Before 2.0.7 the native reducer used the durable event's creation time.
// Keep that meaning for old daemons; preserve precise start times when sent.
return { ...event, data: { ...event.data, started: event.created } }
}
return event
}

View file

@ -53,7 +53,7 @@ test("global Forms and catalog requests preserve explicit identity without forwa
assert.throws(() => applyLocationContext(url, "GET", undefined, new Headers(options.headers), "modern"), /Unsupported/)
})
test("real generated list, RPC, eviction and credential calls use their distinct legacy wire locations", async () => {
test("retired runtimes cannot forward historical selectors through generated calls", async () => {
const endpoint = { url: "http://localhost:1234" }
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
const location = { directory: "/repo", workspaceID: "wrk_one" }
@ -74,17 +74,17 @@ test("real generated list, RPC, eviction and credential calls use their distinct
return url.pathname.startsWith("/api/rpc/") ? Response.json({ output: true }) : new Response(null, { status: 204 })
}) })
const options = locationRequestOptions(location)
await client.session.list({ directory: location.directory }, options)
await client.rpc.call({ rpcID: "fixture", method: "check", input: {}, location: { directory: location.directory } }, options)
await client.debug.location.evict({ location: { directory: location.directory } }, options)
await client.credential.remove({ credentialID: "credential" }, options)
assert.equal(calls.length, 4)
await assert.rejects(client.session.list({ directory: location.directory }, options))
await assert.rejects(client.rpc.call({ rpcID: "fixture", method: "check", input: {}, location: { directory: location.directory } }, options))
await assert.rejects(client.debug.location.evict({ location: { directory: location.directory } }, options))
await assert.rejects(client.credential.remove({ credentialID: "credential" }, options))
assert.equal(calls.length, 0)
await assert.rejects(client.session.list({ cursor: "native-cursor" }, options))
assert.equal(calls.length, 4, "conflicting cursor context never dispatches")
assert.equal(calls.length, 0, "conflicting cursor context never dispatches")
})
test("directory-only credential context is native-scoped on legacy and global on modern", async () => {
for (const version of ["2.0.3", "2.0.4"]) {
test("directory-only credential context remains global on the supported runtime", async () => {
for (const version of ["2.0.11"]) {
const endpoint = { url: "http://localhost:1234" }
rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" })
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => {
@ -109,8 +109,8 @@ test("worktree refresh retains canonical project requests and adapts legacy dire
assert.equal(modernUrl.search, "")
})
test("generated worktree methods retain their payload while adapting the legacy location scope", async () => {
for (const version of ["2.0.3", "2.0.4"]) {
test("generated worktree methods retain canonical project payloads", async () => {
for (const version of ["2.0.11"]) {
const endpoint = { url: "http://localhost:4321", auth: { type: "basic" as const, username: "fixture", password: "fixture" } }
rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" })
const requests: Array<{ method: string; url: URL; body: any }> = []

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict"
import { test } from "node:test"
import { createRuntimeFetch } from "./transport"
import { rememberRuntime } from "./runtime"
import { legacyContractFixture } from "./contract-fixture"
import { modernContractFixture } from "./contract-fixture"
test("unknown contracts perform authenticated bounded read negotiation before any mutation", async () => {
for (const kind of ["unauthorized", "unrecognized", "oversized"] as const) {
@ -30,7 +30,7 @@ test("unknown contracts perform authenticated bounded read negotiation before an
test("shared negotiation has independent subscriber cancellation and never retries a mutation", async () => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" })
rememberRuntime(endpoint, { version: "2.0.100", pid: 1, discovery: "info" })
let complete!: (response: Response) => void
let negotiationSignal: AbortSignal | null | undefined
const calls: string[] = []
@ -41,7 +41,7 @@ test("shared negotiation has independent subscriber cancellation and never retri
negotiationSignal = init?.signal
return new Promise<Response>(resolve => { complete = resolve })
}
assert.equal(pathname, "/api/session/s/rename")
assert.equal(pathname, "/api/session/s")
return new Response(null, { status: 204 })
})
const first = new AbortController(), third = new AbortController()
@ -53,7 +53,7 @@ test("shared negotiation has independent subscriber cancellation and never retri
await assert.rejects(a, { name: "AbortError" })
await assert.rejects(c, { name: "AbortError" })
assert.equal(negotiationSignal?.aborted, false)
complete(Response.json(legacyContractFixture))
complete(Response.json(modernContractFixture))
assert.equal((await b).status, 204)
assert.deepEqual(calls, ["/openapi.json", "/api/session/s/rename"])
assert.deepEqual(calls, ["/openapi.json", "/api/session/s"])
})

View file

@ -1,49 +0,0 @@
// Only translate consumed, reviewed operations. The external proxy allowlist is
// checked before reaching this module; translation never expands that allowlist.
export function legacyRequest(url: URL, method: string, body: Record<string, unknown> | undefined) {
const original = url.pathname
if (original === "/api/status" && method === "GET") url.pathname = "/api/health"
if (/^\/api\/experimental\/session\/(?:stats|import|[^/]+\/(?:export|wait|instructions\/entries(?:\/[^/]+)?))$/.test(original)
|| /^\/api\/experimental\/mcp\/[^/]+\/(?:connect|disconnect)$/.test(original)) {
url.pathname = original.replace("/experimental", "")
}
if (original === "/api/form" && method === "GET") url.pathname = "/api/form/request"
if (/^\/api\/session\/[^/]+$/.test(original) && method === "PATCH") {
if (!body || typeof body.title !== "string" || Object.keys(body).some(key => key !== "title")) {
throw new Error("Earlier OpenCode V2 supports title-only session updates")
}
url.pathname += "/rename"
method = "POST"
}
if (/^\/api\/session\/[^/]+\/fork$/.test(original) && method === "POST") {
if (body?.before !== undefined && typeof body.before !== "string") throw new Error("Invalid fork boundary")
body = { boundary: body?.before === undefined ? { type: "through" } : { type: "before", messageID: body.before } }
}
if (/^\/api\/session\/[^/]+\/command$/.test(original) && method === "POST" && body) {
const { name, ...rest } = body
body = { ...rest, command: name }
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(original) && method === "POST" && body) {
const { decision, ...rest } = body
body = { ...rest, reply: decision }
}
if (/^\/api\/session\/[^/]+\/interrupt$/.test(original) && method === "POST" && url.searchParams.has("resume")) {
url.searchParams.set("continue", url.searchParams.get("resume")!)
url.searchParams.delete("resume")
}
if (/^\/api\/session\/[^/]+\/inbox\/[^/]+$/.test(original) && method === "PATCH") {
if (body?.delivery !== "steer" && body?.delivery !== "queue") throw new Error("Invalid inbox delivery")
url.pathname += `/${body.delivery}`
method = "POST"
body = undefined
}
if (/^\/api\/session\/[^/]+\/revert$/.test(original) && method === "DELETE") {
url.pathname += "/clear"
method = "POST"
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+$/.test(original) && method === "DELETE") {
url.pathname += "/cancel"
method = "POST"
}
return { url, method, body }
}

View file

@ -22,12 +22,6 @@ export function contractProfile(identity: RuntimeIdentity | undefined): Contract
// Custom embedded/test lifecycles that don't negotiate use the pinned contract.
if (!identity) return "modern"
if (identity.contract?.profile) return identity.contract.profile
if (/^2\.0\.[0-3]$/.test(identity.version)) return "legacy"
if (/^2\.0\.[45]$/.test(identity.version)) return "modern"
const beta = /^0\.0\.0-beta-(\d+)$/.exec(identity.version)?.[1]
const audited = new Set([18866, 18955, 18965, 18985, 18992, 18999, 19059, 19086,
19124, 19129, 19133, 19135, 19151, 19157, 19187, 19192, 19215, 19213,
19228, 19234, 19242, 19266, 19271, 19275, 19278, 19283, 19288, 19289,
19296, 19365, 19378, 19381, 19398, 19419, 19422, 19425, 19500, 19507])
return beta && audited.has(Number(beta)) ? "legacy" : "unknown"
if (identity.version === "2.0.11") return "modern"
return "unknown"
}

View file

@ -1,121 +1,73 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { once } from "node:events"
import { test } from "node:test"
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 info 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"],
["2.0.11", "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 })
test("current and future supported releases preserve info, authentication and bounded negotiation", async () => {
for (const version of ["2.0.11", "2.0.100"]) {
const endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "opencode", password: "fixture" } }
rememberRuntime(endpoint, { version, pid: 123, discovery: "info" })
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], ...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
return Response.json(path === "/openapi.json" ? modernContractFixture : { version, pid: 123, urls: [endpoint.url], paths: { tmp: "/native/tmp" } })
})
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
assert.deepEqual(await client.server.info(), { version, pid: 123, urls: [endpoint.url],
...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
assert.deepEqual(seen, discovery === "info" ? ["/openapi.json", route] : [route])
assert.equal((await client.server.info()).paths.tmp, "/native/tmp")
assert.deepEqual(seen, version === "2.0.11" ? ["/api/info"] : ["/openapi.json", "/api/info"])
}
})
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.info())
await fetch(`${endpoint.url}/api/info`, { method: "POST" })
await fetch(`${endpoint.url}/api/status`, { method: "POST" })
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/info", "POST /api/status"])
test("retired runtime contracts never dispatch even through the direct transport", async () => {
for (const version of ["2.0.0", "2.0.3", "2.0.4", "2.0.10", "0.0.0-beta-19271", "3.0.0"]) {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version, pid: 1, discovery: "health" })
const fetch = createRuntimeFetch(endpoint, async () => { assert.fail("unsupported runtime must not receive calls") })
await assert.rejects(fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' }), /opencode_update_required/)
}
})
test("the pinned client uses the earlier wire contract and retains admission/list timestamps", async () => {
const seen: Array<{ method: string; path: string; body: unknown }> = []
const item = { id: "inbox-fixture", sessionID: "s", type: "user", payload: { text: "fixture" }, delivery: "queue", timeCreated: 123 }
const server = createServer(async (req, res) => {
let raw = ""
for await (const data of req) raw += data
assert.equal(req.headers.authorization, `Basic ${Buffer.from("opencode:fixture").toString("base64")}`)
seen.push({ method: req.method!, path: req.url!, body: raw ? JSON.parse(raw) : undefined })
res.setHeader("content-type", "application/json")
if (req.url === "/api/session/s/prompt") return res.end(JSON.stringify({ data: item }))
if (req.url === "/api/session/s/inbox") return res.end(JSON.stringify({ data: [item] }))
if (req.url === "/api/session/s/fork") return res.end(JSON.stringify({ data: { id: "fork" } }))
if (req.url?.startsWith("/api/session/s/interrupt")) return res.end("{}")
res.writeHead(204).end()
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const endpoint: Endpoint = { url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } }
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint) })
try {
assert.equal((await client.session.prompt({ sessionID: "s", text: "fixture" })).time.created, 123)
assert.equal((await client.session.inbox.list({ sessionID: "s" }))[0].time.created, 123)
await client.permission.reply({ sessionID: "s", requestID: "p", decision: "once" })
await client.session.command({ sessionID: "s", name: "test", text: "args", delivery: "queue" })
await client.session.fork({ sessionID: "s", before: "m" })
await client.session.interrupt({ sessionID: "s", resume: true })
await client.session.inbox.update({ sessionID: "s", inboxID: "i", delivery: "steer" })
await client.session.form.cancel({ sessionID: "s", formID: "f" })
await client.session.instructions.entry.remove({ sessionID: "s", key: "voice" })
assert.deepEqual(seen.slice(2), [
{ method: "POST", path: "/api/session/s/permission/p/reply", body: { reply: "once" } },
{ method: "POST", path: "/api/session/s/command", body: { command: "test", text: "args", delivery: "queue" } },
{ method: "POST", path: "/api/session/s/fork", body: { boundary: { type: "before", messageID: "m" } } },
{ method: "POST", path: "/api/session/s/interrupt?continue=true", body: undefined },
{ method: "POST", path: "/api/session/s/inbox/i/steer", body: undefined },
{ method: "POST", path: "/api/session/s/form/f/cancel", body: undefined },
{ method: "DELETE", path: "/api/session/s/instructions/entries/voice", body: undefined },
])
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
test("canonical mutations and inbox timestamps are forwarded unchanged", async () => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
const calls: Array<{ path: string; method: string; body: unknown }> = []
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => {
const request = new Request(input, init)
calls.push({ path: new URL(request.url).pathname, method: request.method, body: request.body ? await request.json() : undefined })
if (request.url.endsWith("/prompt")) return Response.json({ data: { time: { created: 123 } } })
return new Response(null, { status: 204 })
}) })
assert.equal((await client.session.prompt({ sessionID: "s", text: "fixture" })).time.created, 123)
await client.permission.reply({ sessionID: "s", requestID: "p", decision: "once" })
await client.session.command({ sessionID: "s", name: "test", text: "args", delivery: "queue" })
await client.session.form.cancel({ sessionID: "s", formID: "f" })
assert.deepEqual(calls.slice(1), [
{ method: "POST", path: "/api/session/s/permission/p/reply", body: { decision: "once" } },
{ method: "POST", path: "/api/session/s/command", body: { name: "test", text: "args", delivery: "queue" } },
{ method: "DELETE", path: "/api/session/s/form/f", body: undefined },
])
})
test("errors never retry a mutation, unsupported fields are rejected, and abort/auth are preserved", async () => {
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.3", pid: 2, discovery: "health" })
test("errors never retry mutations; native error envelopes, abort and origin fencing survive", async () => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.11", pid: 2, discovery: "info" })
let calls = 0
for (const status of [400, 401, 404, 500]) {
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status }) }) })
await assert.rejects(client.session.update({ sessionID: "s", title: "title" }))
const envelope = { _tag: "NativeError", message: "fixture" }
const fetch = createRuntimeFetch(endpoint, async () => { calls++; return Response.json(envelope, { status }) })
const response = await fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' })
assert.equal(response.status, status)
assert.deepEqual(await response.json(), envelope)
}
assert.equal(calls, 4)
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status: 204 }) }) })
await assert.rejects(client.session.update({ sessionID: "s", title: "title", permissions: [] }), (error: Error) => {
assert.match(String((error as Error & { cause?: unknown }).cause), /title-only/)
return true
})
assert.equal(calls, 4)
const controller = new AbortController()
controller.abort()
const transport = createRuntimeFetch(endpoint, async (_url, init) => {
assert.equal(init?.redirect, "error")
init?.signal?.throwIfAborted()
throw new Error("unexpected live request")
})
await assert.rejects(transport(new Request(`${endpoint.url}/api/session/s/wait`, { method: "POST", signal: controller.signal })), { name: "AbortError" })
await assert.rejects(transport("http://127.0.0.1:4322/api/session"), /origin mismatch/)
const fetch = createRuntimeFetch(endpoint, async () => { assert.fail("must not dispatch") })
await assert.rejects(fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", signal: controller.signal }), { name: "AbortError" })
await assert.rejects(fetch("http://127.0.0.1:4322/api/session"), /origin mismatch/)
})

View file

@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode/client/service"
import { contractProfile, runtimeIdentity } from "./runtime"
import { legacyRequest } from "./requests"
import { assertSupportedOpenCode } from "../runtime-support"
import { negotiateRuntime } from "./negotiate"
import { applyLocationContext, LOCATION_CONTEXT_HEADER } from "./location"
@ -8,16 +8,6 @@ function object(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function normalizeInbox(value: unknown): unknown {
if (!object(value)) throw new Error("Invalid native inbox item")
if (object(value.time) && typeof value.time.created === "number") return value
if (typeof value.timeCreated !== "number" || !Number.isFinite(value.timeCreated)) {
throw new Error("Invalid native inbox creation time")
}
const { timeCreated, ...rest } = value
return { ...rest, time: { created: timeCreated } }
}
// Both the server's generated client and the authorized browser proxy use this
// transport. No error classification here retries a mutation.
export function createRuntimeFetch(endpoint: Endpoint, fetcher: typeof fetch = globalThis.fetch): typeof fetch {
@ -31,8 +21,11 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
const resolveProfile = async (signal?: AbortSignal) => {
signal?.throwIfAborted()
lifetime.throwIfAborted()
if (identity) assertSupportedOpenCode(identity.version)
if (profile === "legacy") throw new Error("Unsupported OpenCode runtime contract")
if (profile !== "unknown") return profile
negotiation ??= negotiateRuntime(endpoint, fetcher, lifetime).then(value => {
if (value !== "modern") throw new Error("Unsupported OpenCode runtime contract")
profile = value
return value
}).catch(error => { negotiation = undefined; throw error })
@ -52,17 +45,15 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
if (serverInfo) {
// Discovery already selected one authenticated route. The current client
// uses info; an older renderer may still request status. Never probe here.
const discovery = identity?.discovery ?? (profile === "legacy" ? "health" : "info")
const discovery = identity?.discovery ?? "info"
url.pathname = `/api/${discovery}`
}
if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) {
if (headers.has(LOCATION_CONTEXT_HEADER)) {
const text = request.body ? await request.text() : undefined
let body: unknown = text ? JSON.parse(text) : undefined
body = applyLocationContext(url, request.method, body, headers, profile)
if (body !== undefined && !object(body)) throw new Error("Invalid OpenCode request body")
const translated = profile === "legacy" ? legacyRequest(url, request.method, body) : { method: request.method, body }
options.method = translated.method
options.body = translated.body === undefined ? undefined : JSON.stringify(translated.body)
options.body = body === undefined ? undefined : JSON.stringify(body)
headers.delete("content-length")
if (options.body === undefined) headers.delete("content-type")
} else if (request.body) {
@ -76,21 +67,7 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
status: response.status, profile, method: request.method, path: originalPath,
}, { status: response.status })
}
if (serverInfo && response.ok && url.pathname === "/api/health") {
const health = await response.json() as { version: string; pid: number }
// Older services do not expose paths.tmp. Do not invent a filesystem path;
// our service consumers use only the authenticated version/PID/endpoint.
return Response.json({ version: health.version, pid: health.pid, urls: [endpoint.url] })
}
if (profile !== "legacy" || !response.ok || !/^\/api\/session\/[^/]+\/(?:inbox|prompt|synthetic|compact)$/.test(originalPath)
|| response.status === 204) return response
const value: unknown = await response.json()
if (!object(value)) throw new Error("Invalid native inbox response")
const normalized = { ...value, data: Array.isArray(value.data) ? value.data.map(normalizeInbox) : normalizeInbox(value.data) }
const responseHeaders = new Headers(response.headers)
responseHeaders.delete("content-length")
responseHeaders.delete("content-encoding")
return new Response(JSON.stringify(normalized), { status: response.status, headers: responseHeaders })
return response
}
return { fetch: adaptedFetch, profile: resolveProfile }
}

View file

@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { Endpoint } from "@opencode/client/service"
import { supportsOpenCodeVersion, UnsupportedOpenCodeError } from "./runtime-support"
import { rememberRuntime } from "./compatibility/runtime"
import { OpenCodeSharedService } from "../workspaces/opencode-service"
test("fixed release minimum is independent of publication and contract recognition", () => {
for (const version of ["2.0.11", "2.0.12", "2.1.0"]) assert.equal(supportsOpenCodeVersion(version), true)
for (const version of ["2.0.10", "2.0.0", "0.0.0-beta-19507", "2.0.11-dev.1", "3.0.0", "garbage"]) assert.equal(supportsOpenCodeVersion(version), false)
})
test("refuses old authenticated daemons before client calls or plugin preparation", async () => {
let constructed = 0, prepared = 0
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.10", pid: 1, discovery: "info" })
const service = new OpenCodeSharedService({ headers: () => ({ authorization: "fixture" }), makeClient: () => { constructed++; throw new Error("not reached") } })
const options = { kind: "lifecycle" as const, identity: "fixture",
lifecycle: { discover: async () => endpoint, ensure: async () => { throw new Error("must not start") } },
prepareDesktopPlugins: async () => { prepared++; return true },
}
await assert.rejects(service.client(options), (error: unknown) => error instanceof UnsupportedOpenCodeError
&& error.actualVersion === "2.0.10" && error.minimumVersion === "2.0.11")
assert.equal(constructed, 0)
assert.equal(prepared, 0)
})

View file

@ -0,0 +1,25 @@
/** Fixed for this CodeNomad release; never derive admission from npm at runtime. */
export const MINIMUM_OPENCODE_VERSION = "2.0.11"
export const OPENCODE_UPDATE_REQUIRED = "opencode_update_required"
export function supportsOpenCodeVersion(version: string): boolean {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
if (!match) return false
const [major, minor, patch] = match.slice(1).map(Number)
const [minimumMajor, minimumMinor, minimumPatch] = MINIMUM_OPENCODE_VERSION.split(".").map(Number)
return major === minimumMajor && (minor! > minimumMinor! || (minor === minimumMinor && patch! >= minimumPatch!))
}
export class UnsupportedOpenCodeError extends Error {
readonly code = OPENCODE_UPDATE_REQUIRED
readonly statusCode = 426
readonly minimumVersion = MINIMUM_OPENCODE_VERSION
constructor(readonly actualVersion: string) {
super(`${OPENCODE_UPDATE_REQUIRED}: OpenCode ${actualVersion}; requires stable OpenCode >= ${MINIMUM_OPENCODE_VERSION} and < 3.0.0`)
this.name = "UnsupportedOpenCodeError"
}
}
export function assertSupportedOpenCode(version: string): void {
if (!supportsOpenCodeVersion(version)) throw new UnsupportedOpenCodeError(version)
}

View file

@ -11,15 +11,15 @@ import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuat
import { OpenCodeSharedService } from "../../workspaces/opencode-service"
import { legacyContractFixture } from "../../opencode/compatibility/contract-fixture"
test("legacy authority precedes translation for Forms, session writes, cursors and imported history", async () => {
test("modern proxy preserves Forms scope and rejects obsolete selectors in cursors and history", async () => {
const upstream = Fastify()
let forwarded = 0
upstream.all("/*", async request => { forwarded++; return { method: request.method, url: request.url, body: request.body, headers: request.headers } })
await upstream.listen({ host: "127.0.0.1", port: 0 })
const endpoint: Endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } }
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
const client = { session: { get: async ({ sessionID }: { sessionID: string }) => ({ location: { directory: "/native", workspaceID: sessionID === "foreign" ? "two" : "one" } }) } } as unknown as OpenCodeClient
const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent() {}, invalidate() {}, profile: async () => "legacy" as const }
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
const client = { session: { get: async ({ sessionID }: { sessionID: string }) => ({ location: { directory: sessionID === "foreign" ? "/foreign" : "/native" } }) } } as unknown as OpenCodeClient
const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent() {}, invalidate() {}, profile: async () => "modern" as const }
const directories = new Set(["/host", "/native"])
const manager: InstanceProxyWorkspaceManager = {
get: () => ({ id: "w", path: "/host" }) as never,
@ -42,21 +42,21 @@ test("legacy authority precedes translation for Forms, session writes, cursors a
const prefix = "/workspaces/w/instance"
const context = (workspaceID: string) => locationRequestOptions({ directory: "/host", workspaceID })!.headers
try {
const form = await app.inject({ method: "DELETE", url: `${prefix}/api/session/global/form/f`, headers: { ...context("one"), "x-opencode-directory": encodeURIComponent("/host"), cookie: "browser=secret" } })
const form = await app.inject({ method: "DELETE", url: `${prefix}/api/session/global/form/f`, headers: { "x-opencode-directory": encodeURIComponent("/host"), cookie: "browser=secret" } })
assert.equal(form.statusCode, 200, form.body)
assert.equal(form.json().url, "/api/session/global/form/f/cancel")
assert.equal(form.json().method, "POST")
assert.equal(form.json().url, "/api/session/global/form/f")
assert.equal(form.json().method, "DELETE")
assert.equal(form.json().headers["x-opencode-directory"], encodeURIComponent("/native"))
assert.equal(form.json().headers["x-opencode-workspace"], "one")
assert.equal(form.json().headers["x-opencode-workspace"], undefined)
assert.equal(form.json().headers[LOCATION_CONTEXT_HEADER], undefined)
assert.equal(form.json().headers.cookie, undefined)
const create = await app.inject({ method: "POST", url: `${prefix}/api/session`, headers: context("one"), payload: { location: { directory: "/host" } } })
const create = await app.inject({ method: "POST", url: `${prefix}/api/session`, payload: { location: { directory: "/host" } } })
assert.equal(create.statusCode, 200, create.body)
assert.deepEqual(create.json().body.location, { directory: "/native", workspaceID: "one" })
const cursor = (workspace: string) => Buffer.from(JSON.stringify({ directory: "/native", workspace, anchor: { id: "s", time: 1, direction: "next" } })).toString("base64url")
const listed = await app.inject(`${prefix}/api/session?cursor=${cursor("one")}&workspace=ignored&directory=/foreign`)
assert.deepEqual(create.json().body.location, { directory: "/native" })
const cursor = (workspace?: string) => Buffer.from(JSON.stringify({ directory: "/native", workspace, anchor: { id: "s", time: 1, direction: "next" } })).toString("base64url")
const listed = await app.inject(`${prefix}/api/session?cursor=${cursor()}&directory=/foreign`)
assert.equal(listed.statusCode, 200, listed.body)
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.get("cursor"), cursor("one"))
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.get("cursor"), cursor())
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.has("workspace"), false)
const before = forwarded
for (const request of [
@ -67,12 +67,12 @@ test("legacy authority precedes translation for Forms, session writes, cursors a
location: { directory: "/host", workspaceID: "one" }, info: { location: { directory: "/host", workspaceID: "one" } },
messages: [{ type: "location-switched", location: { directory: "/host", workspaceID: "two" } }],
} },
]) assert.equal((await app.inject(request)).statusCode, 403)
]) assert.ok([400, 403].includes((await app.inject(request)).statusCode))
assert.equal(forwarded, before, "foreign identity is refused before forwarding any mutation")
} finally { await app.close(); await upstream.close() }
})
test("the first legacy-context proxy request after reconnect negotiates before authorization", async () => {
test("future releases advertising retired schemas are refused before functional calls", async () => {
const upstream = Fastify()
const calls: string[] = []
upstream.get("/openapi.json", async () => { calls.push("schema"); return legacyContractFixture })
@ -83,24 +83,10 @@ test("the first legacy-context proxy request after reconnect negotiates before a
})
await upstream.listen({ host: "127.0.0.1", port: 0 })
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" })
rememberRuntime(endpoint, { version: "2.0.100", pid: 1, discovery: "info" })
const service = new OpenCodeSharedService()
await service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })
const app = Fastify()
registerInstanceProxyRoutes(app, { workspaceManager: {
get: () => ({ path: "/repo" }) as never,
getSharedServiceConnection: () => service.acquire(), getSharedServiceEndpoint: async () => endpoint,
getSharedServiceClient: () => service.client(), getInstanceAuthorizationHeader: () => undefined,
getSessionEnvironment: async () => ({}),
getWorktreeIdentityForPath: async () => "root", ownsDirectory: async () => true, ownsPath: async () => false,
ownsLocation: async (_id, location) => {
assert.deepEqual(calls, ["schema"], "contract must be selected before profile-dependent authorization")
return location.directory === "/repo" && (location.workspaceID === undefined || location.workspaceID === "one")
},
}, worktreeDeletionFence: new WorktreeDeletionFence(), logger: { debug() {}, error() {} } as never })
try {
const response = await app.inject({ url: "/workspaces/w/instance/api/form?location[directory]=/repo", headers: locationRequestOptions({ directory: "/repo", workspaceID: "one" })!.headers })
assert.equal(response.statusCode, 200, response.body)
assert.deepEqual(calls, ["schema", "forms"])
} finally { await service.shutdown(); await app.close(); await upstream.close() }
await assert.rejects(service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }), /Unsupported OpenCode runtime contract/)
assert.deepEqual(calls, ["schema"])
} finally { await service.shutdown(); await upstream.close() }
})

View file

@ -37,3 +37,21 @@ test("does not pass request-controlled binary paths to the update service", asyn
])
await app.close()
})
test("service activation accepts only an explicit boolean restart intent", async () => {
const calls: boolean[] = []
const app = Fastify()
registerOpenCodeUpdateRoutes(app, {
service: { start: async (restart: boolean) => { calls.push(restart); return { state: "ready" } } } as OpenCodeUpdateService,
logger: { warn() {} } as unknown as Logger,
})
try {
for (const payload of [{ restart: "true" }, { restart: true, binary: "foreign" }, { command: "stop" }]) {
assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 400)
}
for (const payload of [{}, { restart: true }]) {
assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 200)
}
assert.deepEqual(calls, [false, true])
} finally { await app.close() }
})

View file

@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify"
import type { Logger } from "../../logger"
import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service"
import { z } from "zod"
interface RouteDeps {
service: OpenCodeUpdateService
@ -19,6 +20,15 @@ function requestError(error: unknown, fallback: string): { status: number; code:
}
export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/opencode/service", async (request, reply) => {
const parsed = z.object({ restart: z.boolean().default(false) }).strict().safeParse(request.body ?? {})
if (!parsed.success) return reply.code(400).send({ error: "invalid_service_action" })
try { return await deps.service.start(parsed.data.restart) }
catch (error) {
deps.logger.warn({ err: error }, "Failed to activate OpenCode")
return reply.code(502).send({ error: "service_activation_failed" })
}
})
app.get("/api/opencode/update", async (_request, reply) => {
try {
return await deps.service.getStatus()

View file

@ -8,6 +8,7 @@ import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
import { WorkspaceSearchBusyError } from "../../filesystem/search-cache"
import { UnsupportedOpenCodeError } from "../../opencode/runtime-support"
interface RouteDeps {
workspaceManager: WorkspaceManager
@ -83,6 +84,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return result.created ? result.workspace : { ...result.workspace, reused: true as const }
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
if (error instanceof UnsupportedOpenCodeError) return reply.code(error.statusCode).send({
error: error.code, message: error.message, actualVersion: error.actualVersion, minimumVersion: error.minimumVersion,
})
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}

View file

@ -1,4 +1,5 @@
import type { SettingsService } from "./service"
import { readManagedExecutable } from "../opencode-update/managed-installation"
export interface OpenCodeBinaryEntry {
path: string
@ -42,7 +43,8 @@ export class BinaryResolver {
resolveDefault(): ResolvedBinary {
const binaries = this.list()
const configuredDefault = readDefaultBinaryPath(this.settings)
const path = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault
const selected = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault
const path = selected === "opencode2" ? readManagedExecutable() ?? selected : selected
const entry = binaries.find((b) => b.path === path)
return {

View file

@ -9,6 +9,24 @@ import { runtimeIdentity } from "../opencode/compatibility/runtime"
const url = "http://127.0.0.1:4321"
describe("HostOpenCodeService", () => {
it("explicit restart stops through CLI and starts through the native-parent bridge", async () => {
const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = []
let delegated = 0
const service = createService(calls, { FIXTURE: "startup" }, {
startFile: async (file, args, options) => {
delegated++
calls.push({ file, args, options })
assert.equal(options.env?.FIXTURE, "startup")
return { stdout: `${url}\n`, stderr: "" }
},
fetch: async () => Response.json({ version: "2.0.11", pid: 123, urls: [url] }),
})
assert.equal(runtimeIdentity(await service.restart())?.version, "2.0.11")
assert.equal(delegated, 1)
assert.deepEqual(calls.map(call => call.args), [["service", "stop"], ["service", "start"], ["service", "get", "password"]])
assert.equal(calls[0].options.env, undefined)
})
it("uses status, start, and password through buildSpawnSpec without a shell", async () => {
const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = []
const service = createService(calls, { PROVIDER_TOKEN: "secret", NODE_EXTRA_CA_CERTS: "/ca.pem" })

View file

@ -622,27 +622,7 @@ export class WorkspaceManager {
const launch = buildServiceLaunchSpec(resolvedBinaryPath, {
platform: this.options.platform,
})
const timeoutMs = Math.max(1, launchDeadlineAt - this.now())
const startupEnvironment = launch.kind === "wsl"
? await this.wslStartupEnvironment(this.serviceStartupEnvironment(), launch.distro, launchDeadlineAt)
: this.serviceStartupEnvironment()
const serviceOptions: OpenCodeSharedServiceOptions = {
kind: "lifecycle",
identity: launch.kind === "host"
? hostOpenCodeServiceIdentity({
binary: launch.binary,
platform: launch.platform,
startupEnvironment,
})
: `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}`
+ `:env:${startupEnvironmentHash(startupEnvironment, "linux")}`,
lifecycle: launch.kind === "host"
? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment)
: this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment),
prepareDesktopPlugins: this.options.prepareDesktopPlugins
? (connection, deadlineAt) => this.options.prepareDesktopPlugins!(launch, connection, deadlineAt)
: undefined,
}
const serviceOptions = await this.setupServiceOptions(resolvedBinaryPath, launchDeadlineAt)
state.serviceOptions = serviceOptions
this.throwIfCancelled(record)
record.wslDistro = launch.kind === "wsl" ? launch.distro : undefined
@ -971,6 +951,41 @@ export class WorkspaceManager {
return resolveWslHostDirectory(directory, distro, undefined, timeoutMs)
}
async setupServiceOptions(binary: string, deadlineAt = Date.now() + DEFAULT_LAUNCH_TIMEOUT_MS): Promise<OpenCodeSharedServiceOptions> {
const launch = buildServiceLaunchSpec(binary, { platform: this.options.platform })
const timeoutMs = Math.max(1, deadlineAt - this.now())
const startupEnvironment = launch.kind === "wsl"
? await this.wslStartupEnvironment(this.serviceStartupEnvironment(), launch.distro, deadlineAt)
: this.serviceStartupEnvironment()
return {
kind: "lifecycle",
identity: launch.kind === "host"
? hostOpenCodeServiceIdentity({ binary: launch.binary, platform: launch.platform, startupEnvironment })
: `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}:env:${startupEnvironmentHash(startupEnvironment, "linux")}`,
lifecycle: launch.kind === "host"
? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment)
: this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment),
prepareDesktopPlugins: this.options.prepareDesktopPlugins
? (connection, deadline) => this.options.prepareDesktopPlugins!(launch, connection, deadline) : undefined,
}
}
async reconnectAfterSetup(binary: string): Promise<void> {
const launch = buildServiceLaunchSpec(binary, { platform: this.options.platform })
for (const record of this.workspaces.values()) {
const distro = launch.kind === "wsl" ? launch.distro.toLowerCase() : undefined
if (record.wslDistro?.toLowerCase() !== distro) throw new Error("Close workspaces before changing the OpenCode execution host")
}
const options = await this.setupServiceOptions(binary)
// This disposes client authority only; it never stops the shared daemon.
await this.sharedService.shutdown()
for (const record of this.workspaces.values()) {
record[WORKSPACE_STATE].serviceOptions = options
record.binaryId = binary
}
this.serviceAuthorization = (await this.sharedService.headers(options))?.authorization
}
private createWslServiceLifecycle(
spec: Extract<ServiceLaunchSpec, { kind: "wsl" }>,
timeoutMs: number,

View file

@ -64,6 +64,13 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle {
return this.endpoint(url, deadlineAt)
}
async restart(deadlineAt = Date.now() + this.timeoutMs): Promise<Endpoint> {
// Explicit user action only. Delegate the subsequent starter outside native
// backend containment exactly as normal first startup does.
await this.run(["service", "stop"], false, deadlineAt)
return this.ensure(deadlineAt)
}
private async endpoint(value: string, deadlineAt: number): Promise<Endpoint> {
const url = this.assertServiceUrl(value)
const password = this.singleLine(

View file

@ -9,14 +9,14 @@ test("same URL and credentials cannot keep the previous daemon's adapter or inva
let now = 0
const makeEndpoint = (version: string, pid: number): Endpoint => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version, pid, discovery: version === "2.0.3" ? "health" : "status" })
rememberRuntime(endpoint, { version, pid, discovery: "info" })
return endpoint
}
let endpoint = makeEndpoint("2.0.3", 1)
let endpoint = makeEndpoint("2.0.11", 1)
const service = new OpenCodeSharedService({ headers: () => undefined, makeClient: () => ({}) as never, now: () => now })
await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })
const old = await service.acquire()
endpoint = makeEndpoint("2.0.4", 2)
endpoint = makeEndpoint("2.0.11", 2)
now = 30_000
const current = await service.acquire()
assert.notEqual(current, old)
@ -24,13 +24,17 @@ test("same URL and credentials cannot keep the previous daemon's adapter or inva
old.invalidate()
current.assertCurrent()
assert.equal(await service.acquire(), current)
endpoint = makeEndpoint("2.0.10", 3)
now = 60_000
await assert.rejects(service.acquire(), /opencode_update_required/)
assert.throws(current.assertCurrent, /connection changed/)
await service.shutdown()
assert.throws(current.assertCurrent, /connection changed/)
})
test("invalidation during asynchronous mutation preparation prevents upstream dispatch", async (context) => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
const upstream = context.mock.method(globalThis, "fetch", async () => new Response(null, { status: 204 }))
const service = new OpenCodeSharedService()
await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })

View file

@ -212,7 +212,7 @@ describe("OpenCodeSharedService", () => {
await new Promise((resolve) => setImmediate(resolve))
await service.shutdown()
resolveStart(endpoint)
assert.equal(await pending, endpoint)
await assert.rejects(pending, /connection changed/)
assert.equal(await service.endpoint(lifecycleOptions("host:test", lifecycle)), endpoint)
assert.equal(discoveries, 2)
})

View file

@ -9,8 +9,8 @@ import { Service, type Endpoint } from "@opencode/client/service"
import { assertLoopbackServiceUrl } from "./service-state"
import { createRuntimeTransport } from "../opencode/compatibility/transport"
import { contractProfile, rememberRuntime, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime"
import { normalizeRuntimeEvent } from "../opencode/compatibility/events"
import { locationRequestOptions } from "../opencode/compatibility/location"
import { assertSupportedOpenCode } from "../opencode/runtime-support"
type RequestOptions = { signal?: AbortSignal; deadlineAt?: number }
const CONNECTION_RECHECK_INTERVAL_MS = 30_000
@ -18,6 +18,7 @@ const CONNECTION_RECHECK_INTERVAL_MS = 30_000
export interface OpenCodeServiceLifecycle {
discover: (deadlineAt?: number) => Promise<Endpoint | undefined>
ensure: (deadlineAt?: number) => Promise<Endpoint>
restart?: (deadlineAt?: number) => Promise<Endpoint>
}
export type OpenCodeSharedServiceOptions = {
@ -148,6 +149,7 @@ export class OpenCodeSharedService {
private connect(options?: OpenCodeSharedServiceOptions, deadlineAt?: number): Promise<ServiceConnection> {
return this.connectService(options, deadlineAt).then(async connection => {
if (await connection.profile() !== "modern") throw new Error("Unsupported OpenCode runtime contract")
const prepare = this.serviceOptions?.prepareDesktopPlugins
if (!prepare) return connection
connection.assertCurrent()
@ -224,6 +226,10 @@ export class OpenCodeSharedService {
}
private createConnection(endpoint: Endpoint, generation: number): ServiceConnection {
const runtime = runtimeIdentity(endpoint)
// Real CLI lifecycles attach authenticated metadata. Admission precedes
// client construction and plugin provisioning for proxy and direct callers.
if (runtime) assertSupportedOpenCode(runtime.version)
const wildcard = new URL(endpoint.url).hostname === "0.0.0.0"
const url = assertLoopbackServiceUrl(endpoint.url)
if (wildcard) {
@ -289,7 +295,7 @@ export class OpenCodeSharedService {
try {
for await (const event of events) {
connection.assertCurrent()
yield normalizeRuntimeEvent(event)
yield event
}
} finally {
this.invalidateConnection(connection)

View file

@ -194,6 +194,7 @@ export function probeBinaryVersion(
version?: string
reported?: string
error?: string
missing?: boolean
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
@ -210,7 +211,7 @@ export function probeBinaryVersion(
function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof probeBinaryVersion> {
if (result.error) {
return { valid: false, error: result.error.message }
return { valid: false, error: result.error.message, ...((result.error as NodeJS.ErrnoException).code === "ENOENT" ? { missing: true } : {}) }
}
if (result.status !== 0) {
@ -241,6 +242,11 @@ function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof pro
return { valid: true, version, reported }
}
export async function probeBinaryVersionAsync(binaryPath: string): Promise<ReturnType<typeof probeBinaryVersion>> {
try { return parseBinaryVersion(await executeAsyncBinaryProbe(buildSpawnSpec(binaryPath, ["--version"]), 5_000)) }
catch (error) { return { valid: false, error: error instanceof Error ? error.message : String(error) } }
}
export async function probeOpenCodeBinary(
binaryPath: string,
execute: AsyncBinaryProbeExecutor = executeAsyncBinaryProbe,

View file

@ -26,6 +26,8 @@ import { loadedRestorableSession } from "./stores/client-state"
import { shouldShowAppHomeOverlay, shouldShowAppRestoreLoading } from "./stores/app-session-restore-gate"
import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import OpenCodeSetup from "./components/opencode-setup"
import { openOpenCodeSetup } from "./stores/opencode-setup"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases"
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
@ -516,6 +518,10 @@ const App: Component = () => {
t("opencodeBinarySelector.validation.v2Required"),
)
const missingBinary = isMissingBinaryMessage(message)
if (missingBinary || message.includes("opencode_update_required")) {
openOpenCodeSetup(() => handleSelectFolder(folderPath))
return false
}
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
log.error("Failed to create instance", error)
return false
@ -737,6 +743,7 @@ const App: Component = () => {
return (
<>
<OpenCodeSetup />
<InstanceDisconnectedModal
open={Boolean(disconnectedInstance())}
folder={disconnectedInstance()?.folder}

View file

@ -0,0 +1,54 @@
import { createEffect, onCleanup, onMount, Show, untrack } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { useI18n } from "../lib/i18n"
import { useConfig } from "../stores/preferences"
import { sseManager } from "../lib/sse-manager"
import { OpenCodeUpdateCard } from "./settings/opencode-update-card"
import { invalidateOpenCodeSetup, needsOpenCodeSetup, openCodeSetupOpen, openCodeSetupStatus,
openOpenCodeSetup, refreshOpenCodeSetup, setOpenCodeSetupOpen } from "../stores/opencode-setup"
export default function OpenCodeSetup() {
const { t } = useI18n()
const { serverSettings } = useConfig()
createEffect(() => { serverSettings().opencodeBinary; untrack(invalidateOpenCodeSetup) })
createEffect(() => {
sseManager.getStatuses()
untrack(() => { if (!document.hidden) void refreshOpenCodeSetup() })
})
let announced = ""
createEffect(() => {
const status = openCodeSetupStatus()
if (!status || !needsOpenCodeSetup(status)) { announced = ""; return }
const key = `${status.binaryPath}:${status.state}:${status.daemonVersion ?? ""}`
if (announced === key) return
announced = key
setOpenCodeSetupOpen(true)
})
onMount(() => {
const refresh = () => { if (!document.hidden) void refreshOpenCodeSetup() }
window.addEventListener("focus", refresh)
document.addEventListener("visibilitychange", refresh)
onCleanup(() => {
window.removeEventListener("focus", refresh)
document.removeEventListener("visibilitychange", refresh)
})
})
return <>
<Show when={needsOpenCodeSetup() && !openCodeSetupOpen()}>
<div class="fixed bottom-4 right-4 z-50 border border-base bg-surface-secondary p-3" role="status">
<button class="selector-button" onClick={() => openOpenCodeSetup()}>{t("settings.opencode.setup.required")}</button>
</div>
</Show>
<Dialog open={openCodeSetupOpen()} onOpenChange={setOpenCodeSetupOpen}>
<Dialog.Portal><Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-2xl max-h-[85vh] overflow-auto">
<header class="window-header"><Dialog.Title class="window-title">{t("settings.opencode.update.title")}</Dialog.Title>
<Dialog.CloseButton class="window-action" aria-label={t("app.launchError.close")}>{t("app.launchError.close")}</Dialog.CloseButton></header>
<div class="window-body"><OpenCodeUpdateCard /></div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</>
}

View file

@ -1,123 +1,49 @@
import { createEffect, createResource, createSignal, Show, type Component } from "solid-js"
import { serverApi } from "../../lib/api-client"
import { onMount, Show, type Component } from "solid-js"
import { useI18n } from "../../lib/i18n"
import { useConfig } from "../../stores/preferences"
import { openSettings } from "../../stores/settings-screen"
import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError,
refreshOpenCodeSetup, runOpenCodeSetup, setOpenCodeSetupOpen } from "../../stores/opencode-setup"
export const OpenCodeUpdateCard: Component = () => {
const { t } = useI18n()
const { serverSettings } = useConfig()
const [status, { mutate, refetch }] = createResource(
() => serverSettings().opencodeBinary || "opencode2",
() => serverApi.fetchOpenCodeUpdateStatus(),
)
const [updating, setUpdating] = createSignal(false)
const [updatedVersion, setUpdatedVersion] = createSignal<string | null>(null)
const [updateFailed, setUpdateFailed] = createSignal(false)
createEffect(() => {
serverSettings().opencodeBinary
setUpdatedVersion(null)
setUpdateFailed(false)
})
const handleUpdate = async () => {
if (updating()) return
const binary = serverSettings().opencodeBinary || "opencode2"
setUpdating(true)
setUpdateFailed(false)
try {
const result = await serverApi.updateOpenCode()
if ((serverSettings().opencodeBinary || "opencode2") !== binary) return
setUpdatedVersion(result.version)
mutate({
currentVersion: result.version,
latestVersion: result.version,
updateAvailable: false,
canUpgrade: false,
})
} catch {
if ((serverSettings().opencodeBinary || "opencode2") === binary) setUpdateFailed(true)
} finally {
setUpdating(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.opencode.update.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.update.subtitle")}</p>
onMount(() => void refreshOpenCodeSetup())
return <section class="settings-card" aria-busy={busy()}>
<div class="settings-card-header"><div>
<h3 class="settings-card-title">{t("settings.opencode.update.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.setup.description")}</p>
</div><span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span></div>
<Show when={status()} fallback={<p role="status">{t("settings.opencode.update.checking")}</p>}>
{data => <>
<p role="status">{t(`settings.opencode.setup.${data().state}`)}</p>
<div class="settings-info-grid">
<div class="settings-info-row"><span>{t("settings.opencode.update.installed")}</span><span>{data().currentVersion ?? "—"}</span></div>
<div class="settings-info-row"><span>{t("settings.opencode.setup.minimum")}</span><span>{data().minimumVersion}</span></div>
<div class="settings-info-row"><span>{t("settings.opencode.setup.daemon")}</span><span>{data().daemonVersion ?? "—"}</span></div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<Show when={!status.loading} fallback={<div class="settings-toggle-caption">{t("settings.opencode.update.checking")}</div>}>
<Show
when={!status.error && status()}
fallback={
<div>
<div class="settings-error-message" role="alert">{t("settings.opencode.update.checkFailed")}</div>
<button type="button" class="settings-pill-button" onClick={() => void refetch()}>
{t("settings.opencode.update.retry")}
</button>
</div>
}
>
{(updateStatus) => (
<>
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-label">{t("settings.opencode.update.installed")}</span>
<span class="settings-info-value">{updateStatus().currentVersion}</span>
</div>
</div>
<Show when={updateStatus().checkError}>
<div class="settings-error-message" role="status">{t("settings.opencode.update.checkFailed")}</div>
<div class="settings-info-actions">
<button type="button" class="settings-pill-button" onClick={() => void refetch()}>
{t("settings.opencode.update.retry")}
</button>
</div>
</Show>
<Show when={!updateStatus().checkError}>
<Show when={updateStatus().updateAvailable} fallback={
<div class="settings-toggle-caption" role="status">{t("settings.opencode.update.upToDate")}</div>
}>
<Show
when={updateStatus().canUpgrade}
fallback={<div class="settings-toggle-caption">{t("settings.opencode.update.availableUnsupported", { version: updateStatus().latestVersion ?? "" })}</div>}
>
<div class="settings-info-actions">
<button
type="button"
class="settings-pill-button"
disabled={updating()}
onClick={() => void handleUpdate()}
>
{updating()
? t("settings.opencode.update.updating")
: t("settings.opencode.update.action", { version: updateStatus().latestVersion ?? "" })}
</button>
</div>
</Show>
</Show>
</Show>
</>
)}
<p class="settings-toggle-caption break-all">{data().binaryPath}</p>
<Show when={data().target === "wsl"}><p>{t("settings.opencode.setup.wsl")}</p></Show>
<Show when={data().serviceState === "restart_required"}><p role="status">{t("settings.opencode.setup.restartRequired")}</p></Show>
<Show when={data().serviceState === "error"}><p role="alert">{t("settings.opencode.setup.serviceError")}</p></Show>
<Show when={data().checkError}><p role="status">{t("settings.opencode.update.checkFailed")}</p></Show>
<Show when={!data().canUpgrade && (data().state === "missing" || data().state === "update_required")}>
<p>{t("settings.opencode.setup.manual")}</p>
</Show>
</Show>
<Show when={updatedVersion()}>
{(version) => (
<div class="settings-info-toast" role="status" aria-live="polite">
{t("settings.opencode.update.success", { version: version() })}
</div>
)}
</Show>
<Show when={updateFailed()}>
<div class="settings-error-message" role="alert">{t("settings.opencode.update.failed")}</div>
</Show>
<div class="settings-info-actions">
<Show when={data().canUpgrade}><button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("install")}>
{busy() ? t("settings.opencode.update.updating") : data().state === "missing" ? t("settings.opencode.setup.install")
: t("settings.opencode.update.action", { version: data().latestVersion ?? "" })}
</button></Show>
<Show when={data().state === "ready" && data().serviceState !== "restart_required"}>
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("start")}>{t("settings.opencode.setup.connect")}</button>
</Show>
<Show when={data().canRestart}><button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("restart")}>{t("settings.opencode.setup.restart")}</button></Show>
</div>
</>}
</Show>
<Show when={openCodeSetupError()}><p class="settings-error-message" role="alert">{t("settings.opencode.update.failed")}</p></Show>
<div class="settings-info-actions">
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void refreshOpenCodeSetup()}>{t("settings.opencode.update.retry")}</button>
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => { setOpenCodeSetupOpen(false); void openSettings("opencode") }}>{t("settings.opencode.setup.chooseBinary")}</button>
</div>
)
</section>
}

View file

@ -446,6 +446,9 @@ export const serverApi = {
updateOpenCode(): Promise<OpenCodeUpdateResponse> {
return request<OpenCodeUpdateResponse>("/api/opencode/update", { method: "POST" })
},
startOpenCode(restart = false): Promise<OpenCodeUpdateStatus> {
return request<OpenCodeUpdateStatus>("/api/opencode/service", { method: "POST", body: JSON.stringify({ restart }) })
},
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "OpenCode auf dem Server Ihrer Projekte installieren, aktualisieren und verbinden.",
"settings.opencode.setup.missing": "OpenCode ist nicht installiert.",
"settings.opencode.setup.update_required": "Diese OpenCode-Version wird von dieser CodeNomad-Version nicht unterstützt.",
"settings.opencode.setup.ready": "Die installierte OpenCode-Version wird unterstützt.",
"settings.opencode.setup.error": "Die gewählte Programmdatei konnte nicht überprüft werden.",
"settings.opencode.setup.minimum": "Mindestversion",
"settings.opencode.setup.daemon": "Laufender Dienst",
"settings.opencode.setup.install": "OpenCode installieren und starten",
"settings.opencode.setup.connect": "Verbinden und fortfahren",
"settings.opencode.setup.restart": "Gemeinsamen Dienst neu starten",
"settings.opencode.setup.restartRequired": "Der laufende Dienst muss aktualisiert werden. Ein Neustart unterbricht aktive Arbeit in allen verbundenen OpenCode-Clients.",
"settings.opencode.setup.serviceError": "Der gemeinsame Dienst kann nicht überprüft werden. Verbindung und Anmeldung prüfen und erneut versuchen.",
"settings.opencode.setup.manual": "Eine unterstützte Programmdatei wählen oder @opencode/cli auf dem Ausführungshost installieren und erneut versuchen.",
"settings.opencode.setup.wsl": "Diese Programmdatei läuft in WSL. OpenCode in der gewählten Distribution installieren oder aktualisieren.",
"settings.opencode.setup.chooseBinary": "Programmdatei wählen",
"settings.opencode.setup.required": "OpenCode-Einrichtung erforderlich",
"settings.appearance.palette.mode": "Darstellungsmodus",
"settings.appearance.palette.auto": "Auto",
"settings.appearance.palette.independent": "Speichere eine Palette pro Erscheinungsbild. Auto folgt dem System und zeigt die passende Palette an.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "Install, update and connect OpenCode on the server running your projects.",
"settings.opencode.setup.missing": "OpenCode is not installed.",
"settings.opencode.setup.update_required": "This OpenCode version is not supported by this CodeNomad release.",
"settings.opencode.setup.ready": "The installed OpenCode version is supported.",
"settings.opencode.setup.error": "The selected executable could not be verified.",
"settings.opencode.setup.minimum": "Minimum version",
"settings.opencode.setup.daemon": "Running service",
"settings.opencode.setup.install": "Install and start OpenCode",
"settings.opencode.setup.connect": "Connect and continue",
"settings.opencode.setup.restart": "Restart shared service",
"settings.opencode.setup.restartRequired": "The running service needs updating. Restart interrupts active work in all connected OpenCode clients.",
"settings.opencode.setup.serviceError": "Cannot verify the shared service. Check connection and authentication, then retry.",
"settings.opencode.setup.manual": "Select a supported executable or install @opencode/cli on the execution host, then retry.",
"settings.opencode.setup.wsl": "This executable runs inside WSL. Install or update OpenCode inside the selected distribution.",
"settings.opencode.setup.chooseBinary": "Choose executable",
"settings.opencode.setup.required": "OpenCode setup required",
"settings.appearance.palette.mode": "Appearance mode",
"settings.appearance.palette.auto": "Auto",
"settings.appearance.palette.independent": "Save a palette for each appearance. Auto follows the system and shows the matching palette.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "Instala, actualiza y conecta OpenCode en el servidor que ejecuta tus proyectos.",
"settings.opencode.setup.missing": "OpenCode no está instalado.",
"settings.opencode.setup.update_required": "Esta versión de OpenCode no es compatible con esta versión de CodeNomad.",
"settings.opencode.setup.ready": "La versión instalada de OpenCode es compatible.",
"settings.opencode.setup.error": "No se pudo verificar el ejecutable seleccionado.",
"settings.opencode.setup.minimum": "Versión mínima",
"settings.opencode.setup.daemon": "Servicio en ejecución",
"settings.opencode.setup.install": "Instalar e iniciar OpenCode",
"settings.opencode.setup.connect": "Conectar y continuar",
"settings.opencode.setup.restart": "Reiniciar servicio compartido",
"settings.opencode.setup.restartRequired": "El servicio en ejecución necesita actualizarse. Reiniciarlo interrumpe el trabajo activo de todos los clientes OpenCode conectados.",
"settings.opencode.setup.serviceError": "No se puede verificar el servicio compartido. Comprueba la conexión y la autenticación e inténtalo de nuevo.",
"settings.opencode.setup.manual": "Selecciona un ejecutable compatible o instala @opencode/cli en el equipo de ejecución y vuelve a intentarlo.",
"settings.opencode.setup.wsl": "Este ejecutable se ejecuta en WSL. Instala o actualiza OpenCode en la distribución seleccionada.",
"settings.opencode.setup.chooseBinary": "Elegir ejecutable",
"settings.opencode.setup.required": "Configuración de OpenCode necesaria",
"settings.appearance.palette.mode": "Modo de apariencia",
"settings.appearance.palette.auto": "Auto",
"settings.appearance.palette.independent": "Guarda una paleta para cada apariencia. Auto sigue el sistema y muestra la paleta correspondiente.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "Installez, mettez à jour et connectez OpenCode sur le serveur qui exécute vos projets.",
"settings.opencode.setup.missing": "OpenCode n’est pas installé.",
"settings.opencode.setup.update_required": "Cette version d’OpenCode n’est pas prise en charge par cette version de CodeNomad.",
"settings.opencode.setup.ready": "La version installée d’OpenCode est prise en charge.",
"settings.opencode.setup.error": "L’exécutable sélectionné n’a pas pu être vérifié.",
"settings.opencode.setup.minimum": "Version minimale",
"settings.opencode.setup.daemon": "Service en cours",
"settings.opencode.setup.install": "Installer et démarrer OpenCode",
"settings.opencode.setup.connect": "Connecter et continuer",
"settings.opencode.setup.restart": "Redémarrer le service partagé",
"settings.opencode.setup.restartRequired": "Le service en cours doit être mis à jour. Son redémarrage interrompt le travail actif de tous les clients OpenCode connectés.",
"settings.opencode.setup.serviceError": "Impossible de vérifier le service partagé. Vérifiez la connexion et l’authentification, puis réessayez.",
"settings.opencode.setup.manual": "Sélectionnez un exécutable compatible ou installez @opencode/cli sur l’hôte d’exécution, puis réessayez.",
"settings.opencode.setup.wsl": "Cet exécutable tourne dans WSL. Installez ou mettez à jour OpenCode dans la distribution sélectionnée.",
"settings.opencode.setup.chooseBinary": "Choisir l’exécutable",
"settings.opencode.setup.required": "Configuration d’OpenCode requise",
"settings.appearance.palette.mode": "Mode d’apparence",
"settings.appearance.palette.auto": "Auto",
"settings.appearance.palette.independent": "Mémorisez une palette par mode d’apparence. Auto suit le système et affiche la palette correspondante.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "התקנה, עדכון וחיבור של OpenCode בשרת שמריץ את הפרויקטים שלך.",
"settings.opencode.setup.missing": "OpenCode אינו מותקן.",
"settings.opencode.setup.update_required": "גרסת OpenCode זו אינה נתמכת בגרסה זו של CodeNomad.",
"settings.opencode.setup.ready": "גרסת OpenCode המותקנת נתמכת.",
"settings.opencode.setup.error": "לא ניתן לאמת את קובץ ההפעלה שנבחר.",
"settings.opencode.setup.minimum": "גרסה מינימלית",
"settings.opencode.setup.daemon": "השירות הפעיל",
"settings.opencode.setup.install": "התקנה והפעלה של OpenCode",
"settings.opencode.setup.connect": "התחברות והמשך",
"settings.opencode.setup.restart": "הפעלה מחדש של השירות המשותף",
"settings.opencode.setup.restartRequired": "יש לעדכן את השירות הפעיל. הפעלה מחדש תקטע עבודה פעילה בכל לקוחות OpenCode המחוברים.",
"settings.opencode.setup.serviceError": "לא ניתן לאמת את השירות המשותף. יש לבדוק את החיבור והאימות ולנסות שוב.",
"settings.opencode.setup.manual": "יש לבחור קובץ הפעלה נתמך או להתקין @opencode/cli במחשב המבצע ולנסות שוב.",
"settings.opencode.setup.wsl": "קובץ זה פועל בתוך WSL. יש להתקין או לעדכן את OpenCode בהפצה שנבחרה.",
"settings.opencode.setup.chooseBinary": "בחירת קובץ הפעלה",
"settings.opencode.setup.required": "נדרשת הגדרת OpenCode",
"settings.appearance.palette.mode": "מצב תצוגה",
"settings.appearance.palette.auto": "אוטומטי",
"settings.appearance.palette.independent": "שמרו ערכת צבעים לכל מצב תצוגה. המצב האוטומטי עוקב אחר המערכת ומציג את הערכה המתאימה.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "プロジェクトを実行するサーバーで OpenCode をインストール、更新、接続します。",
"settings.opencode.setup.missing": "OpenCode がインストールされていません。",
"settings.opencode.setup.update_required": "この OpenCode バージョンは、この CodeNomad リリースではサポートされていません。",
"settings.opencode.setup.ready": "インストール済みの OpenCode はサポートされています。",
"settings.opencode.setup.error": "選択した実行ファイルを確認できませんでした。",
"settings.opencode.setup.minimum": "最小バージョン",
"settings.opencode.setup.daemon": "実行中のサービス",
"settings.opencode.setup.install": "OpenCode をインストールして起動",
"settings.opencode.setup.connect": "接続して続行",
"settings.opencode.setup.restart": "共有サービスを再起動",
"settings.opencode.setup.restartRequired": "実行中のサービスの更新が必要です。再起動すると、接続中のすべての OpenCode クライアントで作業が中断されます。",
"settings.opencode.setup.serviceError": "共有サービスを確認できません。接続と認証を確認して再試行してください。",
"settings.opencode.setup.manual": "対応する実行ファイルを選ぶか、実行ホストに @opencode/cli をインストールして再試行してください。",
"settings.opencode.setup.wsl": "この実行ファイルは WSL 内で動作します。選択したディストリビューション内で OpenCode をインストールまたは更新してください。",
"settings.opencode.setup.chooseBinary": "実行ファイルを選択",
"settings.opencode.setup.required": "OpenCode の設定が必要です",
"settings.appearance.palette.mode": "外観モード",
"settings.appearance.palette.auto": "自動",
"settings.appearance.palette.independent": "外観ごとにパレットを保存します。自動モードではシステムに合わせて対応するパレットを表示します。",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "तपाईंका परियोजनाहरू चलाउने सर्भरमा OpenCode स्थापना, अद्यावधिक र जडान गर्नुहोस्।",
"settings.opencode.setup.missing": "OpenCode स्थापना गरिएको छैन।",
"settings.opencode.setup.update_required": "यो OpenCode संस्करण यस CodeNomad संस्करणमा समर्थित छैन।",
"settings.opencode.setup.ready": "स्थापित OpenCode संस्करण समर्थित छ।",
"settings.opencode.setup.error": "छानिएको कार्यान्वयन फाइल प्रमाणित गर्न सकिएन।",
"settings.opencode.setup.minimum": "न्यूनतम संस्करण",
"settings.opencode.setup.daemon": "चलिरहेको सेवा",
"settings.opencode.setup.install": "OpenCode स्थापना गरी सुरु गर्नुहोस्",
"settings.opencode.setup.connect": "जडान गरी जारी राख्नुहोस्",
"settings.opencode.setup.restart": "साझा सेवा पुनः सुरु गर्नुहोस्",
"settings.opencode.setup.restartRequired": "चलिरहेको सेवा अद्यावधिक गर्नुपर्छ। पुनः सुरु गर्दा सबै जडित OpenCode क्लाइन्टको सक्रिय काम रोकिन्छ।",
"settings.opencode.setup.serviceError": "साझा सेवा प्रमाणित गर्न सकिएन। जडान र प्रमाणीकरण जाँच गरी फेरि प्रयास गर्नुहोस्।",
"settings.opencode.setup.manual": "समर्थित कार्यान्वयन फाइल छान्नुहोस् वा कार्यान्वयन होस्टमा @opencode/cli स्थापना गरी फेरि प्रयास गर्नुहोस्।",
"settings.opencode.setup.wsl": "यो फाइल WSL भित्र चल्छ। छानिएको वितरणमा OpenCode स्थापना वा अद्यावधिक गर्नुहोस्।",
"settings.opencode.setup.chooseBinary": "कार्यान्वयन फाइल छान्नुहोस्",
"settings.opencode.setup.required": "OpenCode सेटअप आवश्यक छ",
"settings.appearance.palette.mode": "रूप मोड",
"settings.appearance.palette.auto": "स्वतः",
"settings.appearance.palette.independent": "हरेक देखावटका लागि रङ योजना सुरक्षित गर्नुहोस्। स्वतः मोडले प्रणालीअनुसार सम्बन्धित रङ योजना देखाउँछ।",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "Установите, обновите и подключите OpenCode на сервере, выполняющем ваши проекты.",
"settings.opencode.setup.missing": "OpenCode не установлен.",
"settings.opencode.setup.update_required": "Эта версия OpenCode не поддерживается текущим выпуском CodeNomad.",
"settings.opencode.setup.ready": "Установленная версия OpenCode поддерживается.",
"settings.opencode.setup.error": "Не удалось проверить выбранный исполняемый файл.",
"settings.opencode.setup.minimum": "Минимальная версия",
"settings.opencode.setup.daemon": "Работающая служба",
"settings.opencode.setup.install": "Установить и запустить OpenCode",
"settings.opencode.setup.connect": "Подключиться и продолжить",
"settings.opencode.setup.restart": "Перезапустить общую службу",
"settings.opencode.setup.restartRequired": "Работающую службу необходимо обновить. Перезапуск прервёт активную работу всех подключённых клиентов OpenCode.",
"settings.opencode.setup.serviceError": "Не удалось проверить общую службу. Проверьте соединение и аутентификацию, затем повторите попытку.",
"settings.opencode.setup.manual": "Выберите поддерживаемый исполняемый файл или установите @opencode/cli на узле выполнения и повторите попытку.",
"settings.opencode.setup.wsl": "Этот файл выполняется в WSL. Установите или обновите OpenCode в выбранном дистрибутиве.",
"settings.opencode.setup.chooseBinary": "Выбрать исполняемый файл",
"settings.opencode.setup.required": "Требуется настройка OpenCode",
"settings.appearance.palette.mode": "Режим оформления",
"settings.appearance.palette.auto": "Авто",
"settings.appearance.palette.independent": "Сохраните палитру для каждого режима оформления. Авто следует настройкам системы и показывает соответствующую палитру.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "Projelerinizi çalıştıran sunucuda OpenCode'u kurun, güncelleyin ve bağlanın.",
"settings.opencode.setup.missing": "OpenCode kurulu değil.",
"settings.opencode.setup.update_required": "Bu OpenCode sürümü, bu CodeNomad sürümü tarafından desteklenmiyor.",
"settings.opencode.setup.ready": "Kurulu OpenCode sürümü destekleniyor.",
"settings.opencode.setup.error": "Seçilen çalıştırılabilir dosya doğrulanamadı.",
"settings.opencode.setup.minimum": "En düşük sürüm",
"settings.opencode.setup.daemon": "Çalışan hizmet",
"settings.opencode.setup.install": "OpenCode'u kur ve başlat",
"settings.opencode.setup.connect": "Bağlan ve devam et",
"settings.opencode.setup.restart": "Paylaşılan hizmeti yeniden başlat",
"settings.opencode.setup.restartRequired": "Çalışan hizmet güncellenmeli. Yeniden başlatmak tüm bağlı OpenCode istemcilerindeki etkin çalışmayı keser.",
"settings.opencode.setup.serviceError": "Paylaşılan hizmet doğrulanamıyor. Bağlantıyı ve kimlik doğrulamayı kontrol edip tekrar deneyin.",
"settings.opencode.setup.manual": "Desteklenen bir çalıştırılabilir dosya seçin veya yürütme makinesine @opencode/cli kurup tekrar deneyin.",
"settings.opencode.setup.wsl": "Bu dosya WSL içinde çalışır. Seçilen dağıtımda OpenCode'u kurun veya güncelleyin.",
"settings.opencode.setup.chooseBinary": "Çalıştırılabilir dosya seç",
"settings.opencode.setup.required": "OpenCode kurulumu gerekli",
"settings.appearance.palette.mode": "Görünüm modu",
"settings.appearance.palette.auto": "Otomatik",
"settings.appearance.palette.independent": "Her görünüm için bir palet kaydedin. Otomatik mod sistemi izler ve ilgili paleti gösterir.",

View file

@ -1,4 +1,20 @@
export const settingsMessages = {
"settings.opencode.setup.description": "在运行项目的服务器上安装、更新并连接 OpenCode。",
"settings.opencode.setup.missing": "尚未安装 OpenCode。",
"settings.opencode.setup.update_required": "此 CodeNomad 版本不支持当前 OpenCode 版本。",
"settings.opencode.setup.ready": "已安装的 OpenCode 版本受支持。",
"settings.opencode.setup.error": "无法验证所选可执行文件。",
"settings.opencode.setup.minimum": "最低版本",
"settings.opencode.setup.daemon": "运行中的服务",
"settings.opencode.setup.install": "安装并启动 OpenCode",
"settings.opencode.setup.connect": "连接并继续",
"settings.opencode.setup.restart": "重启共享服务",
"settings.opencode.setup.restartRequired": "运行中的服务需要更新。重启会中断所有已连接 OpenCode 客户端的当前工作。",
"settings.opencode.setup.serviceError": "无法验证共享服务。请检查连接和身份验证后重试。",
"settings.opencode.setup.manual": "请选择受支持的可执行文件,或在执行主机上安装 @opencode/cli 后重试。",
"settings.opencode.setup.wsl": "此可执行文件在 WSL 中运行。请在所选发行版中安装或更新 OpenCode。",
"settings.opencode.setup.chooseBinary": "选择可执行文件",
"settings.opencode.setup.required": "需要设置 OpenCode",
"settings.appearance.palette.mode": "外观模式",
"settings.appearance.palette.auto": "自动",
"settings.appearance.palette.independent": "为每种外观保存配色。自动模式跟随系统并显示相应的配色。",

View file

@ -51,6 +51,10 @@ export function createInstanceFetch(baseUrl: string): typeof globalThis.fetch {
...init,
credentials: init?.credentials ?? "include",
})
if (response.status === 426) {
const { reportOpenCodeSetupRequired } = await import("../stores/opencode-setup")
reportOpenCodeSetupRequired()
}
if (response.status === 502) {
const body = await response.clone().json().catch(() => undefined)
if (body?.error === SESSION_ENVIRONMENT_FAILED_ERROR_CODE) {

View file

@ -5,13 +5,12 @@ import { OpenCode, type OpenCodeEvent } from "@opencode/client"
import { createData } from "@opencode/client/solid"
import { createRuntimeFetch } from "../../../server/src/opencode/compatibility/transport"
import { rememberRuntime } from "../../../server/src/opencode/compatibility/runtime"
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events"
import { normalizeSessionMessage } from "./message-v2/normalizers"
test("legacy pending HTTP data and enqueued SSE materialize through the actual stable reducer", async () => {
test("native pending HTTP data and enqueued SSE retain their distinct shapes through the stable reducer", async () => {
const endpoint = { url: "http://127.0.0.1:4321" }
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
const item = { id: "inbox", sessionID: "session", type: "user", payload: { text: "Pending" }, delivery: "queue", timeCreated: 123 }
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
const item = { id: "inbox", sessionID: "session", type: "user", payload: { text: "Pending" }, delivery: "queue", time: { created: 123 } }
const api = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => Response.json({ data: [item] })) })
let dispose!: () => void
let emit!: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void
@ -22,9 +21,9 @@ test("legacy pending HTTP data and enqueued SSE materialize through the actual s
try {
await data.session.pending.sync("session")
assert.equal(data.session.message.list("session")[0].time.created, 123)
const event = normalizeRuntimeEvent({ id: "event", created: 456, type: "session.inbox.enqueued",
const event = { id: "event", created: 456, type: "session.inbox.enqueued",
data: { sessionID: "session", inboxID: "second", item: { type: "user", payload: { text: "Second" }, delivery: "queue" } },
} as OpenCodeEvent)
} as OpenCodeEvent
emit({ name: event.type, details: event })
assert.equal(data.session.message.list("session").find(message => message.id === "second")?.time.created, 456)
} finally { dispose() }

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
import type { OpenCodeEvent } from "@opencode/client"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
@ -53,7 +53,7 @@ function fixture(instanceId: string) {
let sequence = 0
const emit = (type: string, data: Record<string, unknown>) => {
const created = ++sequence
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
const event = { id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as OpenCodeEvent
handleInstanceInvalidation(instanceId, event)
handleNativeSessionEvent(instanceId, event)
}

View file

@ -0,0 +1,82 @@
import { createSignal } from "solid-js"
import type { OpenCodeUpdateStatus } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
export const [openCodeSetupStatus, setOpenCodeSetupStatus] = createSignal<OpenCodeUpdateStatus>()
export const [openCodeSetupOpen, setOpenCodeSetupOpen] = createSignal(false)
export const [openCodeSetupBusy, setOpenCodeSetupBusy] = createSignal(false)
export const [openCodeSetupError, setOpenCodeSetupError] = createSignal(false)
let generation = 0
let notifiedGeneration = -1
let pending: Promise<void> | undefined
let resume: (() => Promise<unknown>) | undefined
export function needsOpenCodeSetup(status = openCodeSetupStatus()): boolean {
return status?.state === "missing" || status?.state === "update_required" || status?.serviceState === "restart_required"
}
export function openOpenCodeSetup(retry?: () => Promise<unknown>) {
if (retry) resume = retry
setOpenCodeSetupOpen(true)
void refreshOpenCodeSetup()
}
export function reportOpenCodeSetupRequired() {
if (openCodeSetupBusy() || notifiedGeneration === generation) return
notifiedGeneration = generation
openOpenCodeSetup()
}
export function invalidateOpenCodeSetup() {
generation++
pending = undefined
// Keep the selected folder while the user chooses a different executable.
// Its callback resolves the current settings again; old mutations stay fenced.
setOpenCodeSetupStatus(undefined)
void refreshOpenCodeSetup()
}
export function refreshOpenCodeSetup(afterMutation = false): Promise<void> {
if (openCodeSetupBusy() && !afterMutation) return Promise.resolve()
if (pending) return pending
const epoch = generation
const request = serverApi.fetchOpenCodeUpdateStatus().then(status => {
if (epoch !== generation) return
setOpenCodeSetupStatus(status)
setOpenCodeSetupError(false)
}).catch(() => { if (epoch === generation) setOpenCodeSetupError(true) })
.finally(() => { if (pending === request) pending = undefined })
pending = request
return request
}
export async function runOpenCodeSetup(action: "install" | "start" | "restart") {
if (openCodeSetupBusy()) return
const epoch = ++generation
pending = undefined
setOpenCodeSetupBusy(true)
setOpenCodeSetupError(false)
try {
if (action === "install") await serverApi.updateOpenCode()
if (epoch !== generation) return
// Installation can leave an older shared daemon running. Re-read first so
// restart remains a separate explicit action, never an implicit interruption.
await refreshOpenCodeSetup(true)
if (epoch !== generation) return
if (action !== "restart" && openCodeSetupStatus()?.serviceState === "restart_required") return
const status = await serverApi.startOpenCode(action === "restart")
if (epoch !== generation) return
setOpenCodeSetupStatus(status)
if (status.state === "ready" && status.serviceState === "ready") {
const retry = resume
resume = undefined
setOpenCodeSetupOpen(false)
await retry?.() // Workspace-open retry only; never a session prompt/mutation.
}
} catch {
if (epoch === generation) { await refreshOpenCodeSetup(true); setOpenCodeSetupError(true) }
} finally {
setOpenCodeSetupBusy(false)
if (epoch !== generation) void refreshOpenCodeSetup()
}
}

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
import type { OpenCodeEvent } from "@opencode/client"
import { applyOpenCodeDataEvent, destroyOpenCodeData } from "./opencode-data.ts"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
@ -9,19 +9,19 @@ import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from
import { messageStoreBus } from "./message-v2/bus.ts"
import { setActiveSession, setSessions } from "./session-state.ts"
test("the current native reducer preserves old event timing and newer precise start times", () => {
for (const started of [undefined, 0, 45]) {
test("the native reducer preserves canonical precise start times and durable identity", () => {
for (const started of [0, 45]) {
const instanceId = `runtime-started-${started}`
const original = {
id: "evt_step", type: "session.step.started", created: 100,
durable: { aggregateID: "s", seq: 2, version: 1 },
data: { sessionID: "s", assistantMessageID: "m", agent: "build", model: { providerID: "fixture", id: "fixture" },
...(started === undefined ? {} : { started }) },
started },
}
try {
const event = normalizeRuntimeEvent(original as any)
const event = original as OpenCodeEvent
const data = applyOpenCodeDataEvent(instanceId, "/fixture", event)
assert.equal(data.session.message.get("s", "m")?.time.created, started ?? 100)
assert.equal(data.session.message.get("s", "m")?.time.created, started)
assert.ok("durable" in event)
assert.deepEqual(event.durable, original.durable)
assert.equal(original.data.started, started, "normalization must not mutate the input event")
@ -56,7 +56,7 @@ test(`settled tools reach the visible store with following ${following}`, async
} as any]])))
setActiveSession(instanceId, sessionId)
const emit = (type: string, data: Record<string, unknown>, created: number) => {
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
const event = { id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as OpenCodeEvent
handleInstanceInvalidation(instanceId, event)
handleNativeSessionEvent(instanceId, event)
}

View file

@ -0,0 +1,19 @@
import { render } from "solid-js/web"
import OpenCodeSetup from "../../../src/components/opencode-setup"
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { serverApi } from "../../../src/lib/api-client"
import { openOpenCodeSetup } from "../../../src/stores/opencode-setup"
import { createInstanceFetch } from "../../../src/lib/sdk-manager"
import "../../../src/index.css"
serverApi.fetchConfigOwner = async () => ({ settings: { locale: "en" } }) as any
serverApi.fetchStateOwner = async () => ({}) as any
serverApi.patchConfigOwner = async (_owner, patch) => patch as any
let resumed = 0
render(() => <ConfigProvider><I18nProvider><OpenCodeSetup /></I18nProvider></ConfigProvider>, document.getElementById("root")!)
await updatePreferences({ locale: "en" })
;(window as any).fixture = {
open: () => openOpenCodeSetup(async () => { resumed++ }), resumed: () => resumed,
unsupported: () => createInstanceFetch(`${location.origin}/workspaces/w/instance/`)(`${location.origin}/api/session/s/prompt`,
{ method: "POST", body: "{}" }).then(response => response.status),
}

View file

@ -0,0 +1,133 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import path from "node:path"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "setup-fixture", configureServer(s) {
s.middlewares.use("/fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/opencode-setup.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
test("missing and required-update flows share a screen; stale daemon restart is explicit", async () => {
const page = await browser.newPage()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
let installed = false, running = false, restarts = 0, installs = 0
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
installed = true; installs++
return route.fulfill({ json: { success: true, version: "2.0.11" } })
}
if (request.url().endsWith("/api/opencode/service")) {
assert.equal(request.postDataJSON().restart, true)
restarts++; running = true
}
return route.fulfill({ json: { state: installed ? "ready" : "missing", currentVersion: installed ? "2.0.11" : null,
latestVersion: "2.0.11", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host",
canUpgrade: !installed, updateAvailable: !installed, daemonVersion: running ? "2.0.11" : "2.0.10",
serviceState: installed ? running ? "ready" : "restart_required" : undefined, canRestart: installed && !running } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("button", { name: "Install and start OpenCode" }).click()
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-restart.png") })
assert.equal(installs, 1)
assert.equal(restarts, 0)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.getByText(/Restart interrupts active work/).waitFor()
await page.getByRole("button", { name: "Restart shared service" }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(restarts, 1)
assert.equal(await page.getByRole("dialog").count(), 0)
assert.deepEqual(errors, [])
} catch (error) { console.error(errors, await page.locator("body").innerText()); throw error }
finally { await page.close() }
})
test("closing required-update screen leaves a persistent recovery entry", async () => {
const page = await browser.newPage()
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.10",
latestVersion: "2.0.11", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false } }))
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByRole("dialog").waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-required.png") })
await page.getByRole("button", { name: "Close", exact: true }).click()
await page.getByRole("button", { name: "OpenCode setup required" }).click()
await page.getByRole("dialog").waitFor()
assert.equal(await page.getByText("2.0.10", { exact: true }).count(), 1)
} catch (error) { console.error(await page.locator("body").innerText()); throw error }
finally { await page.close() }
})
test("optional updates stay non-blocking and a failed install remains recoverable", async () => {
const page = await browser.newPage()
let attempts = 0, starts = 0
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
attempts++
return route.fulfill({ status: 500, json: { error: "upgrade_failed" } })
}
if (request.url().endsWith("/api/opencode/service")) starts++
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.12",
minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false,
serviceState: "ready", daemonVersion: "2.0.11" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
assert.equal(await page.getByRole("dialog").count(), 0)
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("button", { name: "Update to OpenCode 2.0.12" }).click()
await page.locator(".settings-error-message").waitFor()
assert.equal(starts, 0)
assert.equal(attempts, 1)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.getByRole("button", { name: "Update to OpenCode 2.0.12" }).click()
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(attempts, 2)
} finally { await page.close() }
})
test("unsupported proxy calls open recovery once without replaying mutations", async () => {
const page = await browser.newPage()
let blocked = false, prompts = 0
await page.route("**/api/**", route => {
if (route.request().url().includes("/instance/api/")) {
prompts++; blocked = true
return route.fulfill({ status: 426, json: { code: "opencode_update_required" } })
}
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11",
minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: false,
serviceState: blocked ? "restart_required" : "ready", canRestart: blocked, daemonVersion: blocked ? "2.0.10" : "2.0.11" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
await page.getByRole("button", { name: "Close", exact: true }).click()
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
assert.equal(await page.getByRole("dialog").count(), 0)
assert.equal(prompts, 2, "each intentional call occurs once; recovery never retries a prompt")
} finally { await page.close() }
})

View file

@ -139,6 +139,11 @@ function pruneForRuntime(sourceRoot, destinationRoot, binaryRelativePath) {
fs.rmSync(destinationRoot, { recursive: true, force: true })
fs.mkdirSync(path.dirname(destinationBinary), { recursive: true })
fs.copyFileSync(sourceBinary, destinationBinary)
const npmRelativePath = binaryRelativePath === "node.exe" ? "node_modules/npm" : "lib/node_modules/npm"
const npmSource = path.join(sourceRoot, npmRelativePath)
if (!fs.existsSync(path.join(npmSource, "bin/npm-cli.js"))) throw new Error("Node archive is missing npm")
fs.cpSync(npmSource, path.join(destinationRoot, npmRelativePath), { recursive: true })
fs.copyFileSync(path.join(sourceRoot, "LICENSE"), path.join(destinationRoot, "LICENSE"))
}
async function prepareBundledNodeRuntime(options) {
@ -197,6 +202,7 @@ async function prepareBundledNodeRuntime(options) {
module.exports = {
MANAGED_NODE_VERSION,
pruneForRuntime,
prepareBundledNodeRuntime,
normalizeTarget,
currentTarget,

View file

@ -3,6 +3,7 @@ const fs = require("node:fs")
const path = require("node:path")
const test = require("node:test")
const yaml = require("yaml")
const os = require("node:os")
const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs")
const root = path.resolve(__dirname, "..")
@ -40,3 +41,23 @@ test("both macOS bundles require the OS supported by Node 24", () => {
assert.equal(electron.build.mac.minimumSystemVersion, "13.5")
assert.equal(tauri.bundle.macOS.minimumSystemVersion, "13.5")
})
test("both desktop resource layouts retain the complete npm runtime and licenses", () => {
const { pruneForRuntime } = require("./prepare-node-runtime.cjs")
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-node-packaging-"))
try {
for (const binary of ["node.exe", "bin/node"]) {
const source = path.join(fixture, binary === "node.exe" ? "windows" : "posix")
const destination = `${source}-out`
const npm = binary === "node.exe" ? "node_modules/npm" : "lib/node_modules/npm"
for (const file of [binary, "LICENSE", `${npm}/bin/npm-cli.js`, `${npm}/node_modules/fixture/index.js`, `${npm}/LICENSE`]) {
fs.mkdirSync(path.dirname(path.join(source, file)), { recursive: true })
fs.writeFileSync(path.join(source, file), file)
}
pruneForRuntime(source, destination, binary)
assert.equal(fs.readFileSync(path.join(destination, `${npm}/node_modules/fixture/index.js`), "utf8"), `${npm}/node_modules/fixture/index.js`)
assert.ok(fs.existsSync(path.join(destination, "LICENSE")))
assert.ok(fs.existsSync(path.join(destination, `${npm}/bin/npm-cli.js`)))
}
} finally { fs.rmSync(fixture, { recursive: true, force: true }) }
})

View file

@ -0,0 +1,140 @@
// Native migration only: isolated synthetic storage, never service discovery or
// a user's database. Keep an untouched seed and migrate a copied DB via OpenCode.
import assert from "node:assert/strict"
import { spawn, execFileSync } from "node:child_process"
import { randomUUID, createHash } from "node:crypto"
import { mkdtemp, mkdir, readFile, writeFile, copyFile } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { setTimeout as delay } from "node:timers/promises"
import { tsImport } from "tsx/esm/api"
const [oldCli, newCli] = process.argv.slice(2)
if (![oldCli, newCli].every(value => value && path.isAbsolute(value))) throw new Error("Pass absolute old and target CLI paths")
const parent = path.join(os.tmpdir(), "opencode")
await mkdir(parent, { recursive: true })
const root = await mkdtemp(path.join(parent, "codenomad-history-migration-"))
const repo = path.join(root, "repo"), worktree = path.join(root, "worktree")
await mkdir(repo)
const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" })
git("init"); git("config", "user.name", "Synthetic fixture"); git("config", "user.email", "fixture@example.invalid")
await writeFile(path.join(repo, "fixture.txt"), "synthetic")
git("add", "."); git("commit", "-m", "fixture"); git("worktree", "add", "-b", "fixture-worktree", worktree)
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/.test(key)))
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
const password = randomUUID()
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_CONFIG_PROJECT_DISABLE: "1",
OPENCODE_SERVER_PASSWORD: password, OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", OPENCODE_CONFIG_CONTENT: "{}" })
await mkdir(env.OPENCODE_CONFIG_DIR)
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const versions = [oldCli, newCli].map(cli => execFileSync(cli, ["--version"], { encoding: "utf8" }).trim())
const hashes = await Promise.all([oldCli, newCli].map(async cli => createHash("sha256").update(await readFile(cli)).digest("hex")))
await writeFile(path.join(root, "artifacts.json"), JSON.stringify({ versions, hashes }, null, 2))
async function start(cli, database, name) {
let output = ""
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], {
cwd: root, env: { ...env, OPENCODE_DB: database }, windowsHide: true,
})
const stopped = new Promise(resolve => child.once("close", resolve))
child.stdout.on("data", data => { output += data }); child.stderr.on("data", data => { output += data })
const close = async () => { child.kill(); await stopped; await writeFile(path.join(root, `${name}.log`), output) }
try {
const deadline = Date.now() + 30_000
while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) {
if (child.exitCode !== null || Date.now() > deadline) throw new Error(`Fixture startup failed: ${output}`)
await delay(20)
}
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
const raw = async (route, body, method = body === undefined ? "GET" : "POST") => {
const response = await fetch(`${baseUrl}${route}`, { method, headers: { authorization, "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000) })
const text = await response.text()
assert.ok(response.ok, `${method} ${route}: ${response.status} ${text}`)
return text ? JSON.parse(text) : undefined
}
return { baseUrl, raw, close }
} catch (error) { await close(); throw error }
}
const seedDb = path.join(root, "seed.db"), migratedDb = path.join(root, "migrated.db")
const seed = await start(oldCli, seedDb, "seed")
const snapshots = []
let pendingSession
try {
for (const workspaceID of [undefined, "wrk_fixture_one", "wrk_fixture_two"]) {
const location = { directory: repo, ...(workspaceID ? { workspaceID } : {}) }
const created = (await seed.raw("/api/session", { location })).data
const exported = (await seed.raw(`/api/session/${created.id}/export`)).data
const time = Date.now()
const messages = Array.from({ length: 213 }, (_, index) => ({ type: "synthetic", id: `msg_${randomUUID().replaceAll("-", "")}`,
text: `history-${workspaceID ?? "local"}-${index}`, time: { created: time + index } }))
messages.push({ type: "assistant", id: `msg_${randomUUID().replaceAll("-", "")}`, time: { created: time + 214, completed: time + 215 },
agent: "build", model: { providerID: "fixture", id: "fixture" }, finish: "stop", content: [
{ type: "reasoning", text: "historical reasoning" }, { type: "text", text: "historical answer" },
{ type: "tool", id: "fixture-tool", name: "fixture", time: { created: time + 214, completed: time + 215 },
state: { status: "completed", input: {}, content: [{ type: "text", text: "historical tool output" }] } },
] })
messages.push({ type: "compaction", id: `msg_${randomUUID().replaceAll("-", "")}`, time: { created: time + 216 },
status: "completed", reason: "manual", summary: "Synthetic historical checkpoint", recent: messages[212].id })
const info = { ...exported.info, id: `ses_${randomUUID().replaceAll("-", "")}`, title: `history-${workspaceID ?? "local"}` }
const imported = (await seed.raw("/api/session/import", { info, messages, location })).data
const fork = (await seed.raw(`/api/session/${imported.id}/fork`, { boundary: { type: "through" } })).data
for (const session of [imported, fork]) snapshots.push((await seed.raw(`/api/session/${session.id}/export`)).data)
}
const moved = (await seed.raw("/api/session", { location: { directory: repo } })).data
await seed.raw(`/api/session/${moved.id}/move`, { directory: worktree })
// Move is queued by native OpenCode; wait for its authoritative record.
for (let n = 0; n < 200; n++) {
const current = (await seed.raw(`/api/session/${moved.id}`)).data
if (current.location.directory.replaceAll("\\", "/").toLowerCase() === worktree.replaceAll("\\", "/").toLowerCase()) break
if (n === 199) throw new Error("Native move did not settle")
await delay(25)
}
snapshots.push((await seed.raw(`/api/session/${moved.id}/export`)).data)
pendingSession = (await seed.raw("/api/session", { location: { directory: repo } })).data.id
await seed.raw(`/api/session/${pendingSession}/synthetic`, { text: "pending migration fixture", resume: false })
assert.equal((await seed.raw(`/api/session/${pendingSession}/inbox`)).data.length, 1)
await writeFile(path.join(root, "seed-exports.json"), JSON.stringify(snapshots))
} finally { await seed.close() }
await copyFile(seedDb, migratedDb)
await copyFile(`${seedDb}-wal`, `${migratedDb}-wal`).catch(error => { if (error.code !== "ENOENT") throw error })
const untouchedHash = createHash("sha256").update(await readFile(seedDb)).digest("hex")
const target = await start(newCli, migratedDb, "target")
try {
const { OpenCodeCliService } = await tsImport("../packages/server/src/workspaces/opencode-cli-service.ts", import.meta.url)
const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url)
const lifecycle = new OpenCodeCliService({ label: "Migration fixture", timeoutMs: 30_000, command: args => ({ command: newCli, args, options: {} }) },
{ execFile: async (_file, args) => ({ stdout: args.at(-1) === "password" ? password : target.baseUrl, stderr: "" }) })
const shared = new OpenCodeSharedService()
const client = await shared.client({ kind: "lifecycle", identity: "migration-fixture", lifecycle })
const inbox = await client.session.inbox.list({ sessionID: pendingSession })
assert.equal(inbox.length, 1)
assert.equal(inbox[0].payload.text, "pending migration fixture")
assert.equal(typeof inbox[0].time.created, "number")
for (const original of snapshots) {
const restored = await client.session.export({ sessionID: original.info.id })
assert.equal(restored.info.id, original.info.id)
assert.equal(restored.info.location.directory, original.info.location.directory)
assert.deepEqual(restored.messages, original.messages, `history survives native migration: ${original.info.id}`)
const messages = []
let page = await client.message.list({ sessionID: original.info.id, limit: 200 })
messages.push(...page.data)
while (page.cursor.next) { page = await client.message.list({ sessionID: original.info.id, cursor: page.cursor.next }); messages.push(...page.data) }
assert.equal(messages.length, original.messages.length)
console.log(`PASS migrated ${original.info.id}: ${messages.length} messages, old identity ${original.info.location.workspaceID ?? "local"}, current identity ${restored.info.location.workspaceID ?? "local"}`)
}
const listed = new Set()
let page = await client.session.list({ directory: repo, limit: 2 })
for (const item of page.data) listed.add(item.id)
while (page.cursor.next) {
page = await client.session.list({ cursor: page.cursor.next })
for (const item of page.data) listed.add(item.id)
}
for (const original of snapshots.filter(snapshot => snapshot.info.title?.startsWith("history-"))) {
assert.ok(listed.has(original.info.id), `native pagination retains migrated session ${original.info.id}`)
}
await shared.shutdown()
} finally { await target.close() }
assert.equal(createHash("sha256").update(await readFile(seedDb)).digest("hex"), untouchedHash)
console.log(`PASS: native synthetic history migration ${versions.join(" -> ")}; evidence ${root}`)

View file

@ -0,0 +1,32 @@
// Downloads only into a fresh fixture. No global npm install or shared daemon.
import assert from "node:assert/strict"
import { mkdtemp, mkdir, cp, copyFile, chmod, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { execFileSync } from "node:child_process"
import { tsImport } from "tsx/esm/api"
const { bundledNpm, installManagedOpenCode, readManagedExecutable } = await tsImport("../packages/server/src/opencode-update/managed-installation.ts", import.meta.url)
const { MINIMUM_OPENCODE_VERSION } = await tsImport("../packages/server/src/opencode/runtime-support.ts", import.meta.url)
const parent = path.join(os.tmpdir(), "opencode")
await mkdir(parent, { recursive: true })
const root = await mkdtemp(path.join(parent, "codenomad-install-native-"))
const runtime = path.join(root, "runtime")
await mkdir(runtime)
const node = path.join(runtime, process.platform === "win32" ? "node.exe" : "bin/node")
await mkdir(path.dirname(node), { recursive: true })
await copyFile(process.execPath, node)
await chmod(node, 0o755)
const npm = bundledNpm()
assert.ok(npm, "Fixture requires the npm from its Node installation")
const npmRoot = path.resolve(npm, "../..")
await cp(npmRoot, path.join(runtime, process.platform === "win32" ? "node_modules/npm" : "lib/node_modules/npm"), { recursive: true })
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(PATH|OPENCODE_|XDG_|npm_)/i.test(key)))
Object.assign(env, { HOME: root, USERPROFILE: root, LOCALAPPDATA: root,
npm_config_cache: path.join(root, "npm-cache"), npm_config_userconfig: path.join(root, "npmrc"),
PATH: process.platform === "win32" ? `${process.env.SystemRoot}\\System32` : "" })
try {
const binary = await installManagedOpenCode(MINIMUM_OPENCODE_VERSION, { root: path.join(root, "install"), node, env })
assert.equal(readManagedExecutable(path.join(root, "install")), binary)
assert.match(execFileSync(binary, ["--version"], { encoding: "utf8", env }), new RegExp(MINIMUM_OPENCODE_VERSION.replaceAll(".", "\\.")))
console.log(`PASS: native npm installation with isolated bundled Node, no system Node PATH, verified executable (${MINIMUM_OPENCODE_VERSION})`)
} finally { await rm(root, { recursive: true, force: true }) }

View file

@ -154,14 +154,11 @@ try {
const client = await sharedService.client({ kind: "lifecycle", identity: "isolated-native", lifecycle })
const connection = await sharedService.acquire()
const runtimeFetch = connection.fetch
// Recognition follows the actual authenticated schema, not an exact version
// allowlist. Exercise this route against every real runtime in the matrix.
// Independently verify the authenticated schema even for the known minimum.
const { negotiateRuntime } = await tsImport("../packages/server/src/opencode/compatibility/negotiate.ts", import.meta.url)
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
const { createRuntimeFetch } = await tsImport("../packages/server/src/opencode/compatibility/transport.ts", import.meta.url)
const futureEndpoint = { ...connection.endpoint }
rememberRuntime(futureEndpoint, { version: "contract-probe", pid: 1, discovery: "status" })
const negotiatedClient = OpenCode.make({ baseUrl, fetch: createRuntimeFetch(futureEndpoint) })
assert.equal((await negotiatedClient.location.get({ location: { directory: root } })).directory, (await client.location.get({ location: { directory: root } })).directory)
assert.equal(await negotiateRuntime(connection.endpoint, fetch, AbortSignal.timeout(10_000)), "modern")
const makeClient = () => OpenCode.make({ baseUrl, headers: {
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
}, fetch: runtimeFetch })