From 17dd01102865f5e070194d9be7c8afb2f59104d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 16 Sep 2026 23:56:16 +0200 Subject: [PATCH 01/14] docs(opencode): prepare the post-beta runtime support transition Start a maintained preparation register from the merged cross-runtime implementation in #695. Separate the client/plugin dependency versions, authenticated daemon support policy and CodeNomad release decision so a future minimum is chosen from the actual post-beta publication contract. Map legacy retirement candidates to their current files while preserving the shared connection seam, lifecycle/authorization guarantees, native pagination and pruning storage/claim safety. Define isolated historical data acceptance before removing legacy location identity, with explicit coverage for same-directory sessions, moved histories, forks and compaction. Track policy preparation, migration certification, implementation cleanup and release gates as small reviewable increments. Link the new register from the index and compatibility audit. Current dependencies, supported runtimes and production behavior are unchanged; implementation work remains open. Validate document links, repository paths and whitespace only. --- dev-docs/INDEX.md | 4 + dev-docs/OPENCODE_V2_COMPATIBILITY.md | 2 + dev-docs/OPENCODE_V2_POST_BETA.md | 140 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 dev-docs/OPENCODE_V2_POST_BETA.md diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 7fdfbd422..1784830be 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -24,6 +24,10 @@ Incremental comparison with official OpenCode Desktop V2, including parity, clos September 2026 published API-contract census, verified compatibility failures, connection-scoped adapter proposal, support targets and cross-runtime acceptance roadmap. Start here for OpenCode upgrade and backward-compatibility work. +### [OPENCODE_V2_POST_BETA.md](OPENCODE_V2_POST_BETA.md) + +Draft preparation for retiring earlier V2 runtime contracts after beta: support-policy decisions, a file-level retirement map, historical-data migration acceptance and incremental release gates. Current runtime support remains unchanged. + ### [DEV_V2_CODE_REDUCTION.md](DEV_V2_CODE_REDUCTION.md) Measured DEV-v2 growth analysis, maintainer position on test volume, ranked reduction candidates, and guardrails for later simplification without product regressions. diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 508e4e952..83641328f 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -5,6 +5,8 @@ ## Decision summary +Future retirement of the earlier-runtime adapter is tracked separately in [OpenCode V2 post-beta preparation](OPENCODE_V2_POST_BETA.md). That draft preserves this register as the current support/evidence baseline and selects no new minimum runtime yet. + 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. The primary split is the pre-2.0.4 V2 contract versus the 2.0.4 contract. Earlier changes also matter, particularly message editing, catalog activation, location identity and native cache behavior. Support should be defined by tested contract families and features, not inferred from a healthy daemon, a package name, a beta-number comparison or a passing TypeScript build. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md new file mode 100644 index 000000000..234b622b1 --- /dev/null +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -0,0 +1,140 @@ +# OpenCode V2 post-beta preparation + +**Status:** draft preparation; 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). + +## Objective + +Prepare a small, deliberate retirement of earlier V2 wire contracts when OpenCode leaves beta. Continue ordinary CodeNomad development in parallel, merging compatible preparation in small increments. Keep the eventual support-policy change and deletion reviewable together. + +Three versions must remain distinct: + +| Version | Current baseline | Post-beta 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. | + +No minimum or retirement date is selected yet. A numeric `2.0.x` version or an npm dist-tag is not sufficient evidence of the product's exit from beta. Record the upstream announcement, exact packages and reviewed contracts before making that decision. A newer CLI installed on disk also does not prove the already-running daemon was upgraded. + +## Permanent interface + +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. + +The future single-contract implementation must preserve: + +- 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. + +The directory name `compatibility/` is not a deletion instruction. Some of its implementation provides these permanent guarantees. + +## Retirement inventory + +Paths below are relative to the repository root. This is a working deletion map, not authorization to remove code before its evidence is available. + +| 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` | `/api/health` fallback | Remove only when every supported daemon exposes authenticated `/api/status`; retain all discovery error/deadline guards. | +| `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 the preparation 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. + +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. +- [ ] 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. + +### P2 — Certify historical data on the replacement runtime + +- [ ] 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: + +| 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. | + +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. + +### 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 + +- [ ] Record the upstream post-beta announcement and exact target contract/dependency versions. +- [ ] 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 + +Use this draft as the maintained preparation register. Keep it close to `dev` and small: merge completed backward-compatible preparation as independent increments, then update this register's progress and links. Avoid accumulating an alternate application implementation for the rest of beta. + +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. | From e986e66d1b0b2b13a1f2890e452c48ac4f26ae3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 14:31:45 +0200 Subject: [PATCH 02/14] docs(opencode): define stable runtime setup and update plan Record that OpenCode V2 is published on the stable channel and make the transition register independently mergeable, with implementation and retirement gates maintained through follow-up PRs. Specify a shared missing-runtime and required-update screen, optional-update notices, first installation through the existing updater, and bundled Node plus npm for desktops without system tooling. Preserve host/WSL targeting, persistent installation paths, shared service ownership and actual daemon revalidation after installation or explicit restart. Correct the discovery-route retirement criteria and retain historical-data certification before changing support. Validate whitespace, referenced repository paths and local documentation links; no runtime behavior is changed. --- dev-docs/INDEX.md | 2 +- dev-docs/OPENCODE_V2_COMPATIBILITY.md | 2 +- dev-docs/OPENCODE_V2_POST_BETA.md | 49 ++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 7bbebe4e9..4f54497bb 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -26,7 +26,7 @@ September 2026 published API-contract census, verified compatibility failures, c ### [OPENCODE_V2_POST_BETA.md](OPENCODE_V2_POST_BETA.md) -Draft preparation for retiring earlier V2 runtime contracts after beta: support-policy decisions, a file-level retirement map, historical-data migration acceptance and incremental release gates. Current runtime support remains unchanged. +Maintained stable-V2 transition plan: shared installation/required-update screen, bundled Node/npm proposal, support-policy decisions, adapter retirement map, historical-data acceptance and incremental release gates. Current runtime support remains unchanged. ### [DEV_V2_CODE_REDUCTION.md](DEV_V2_CODE_REDUCTION.md) diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index f1dbff64e..8051ae6e6 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -5,7 +5,7 @@ ## Decision summary -Future retirement of the earlier-runtime adapter is tracked separately in [OpenCode V2 post-beta preparation](OPENCODE_V2_POST_BETA.md). That draft preserves this register as the current support/evidence baseline and selects no new minimum runtime yet. +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. 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. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 234b622b1..007ca55c4 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -1,22 +1,24 @@ -# OpenCode V2 post-beta preparation +# OpenCode V2 stable-runtime transition and installation plan -**Status:** draft preparation; runtime support is unchanged. +**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). ## Objective -Prepare a small, deliberate retirement of earlier V2 wire contracts when OpenCode leaves beta. Continue ordinary CodeNomad development in parallel, merging compatible preparation in small increments. Keep the eventual support-policy change and deletion reviewable together. +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. Three versions must remain distinct: -| Version | Current baseline | Post-beta decision | +| 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. | -No minimum or retirement date is selected yet. A numeric `2.0.x` version or an npm dist-tag is not sufficient evidence of the product's exit from beta. Record the upstream announcement, exact packages and reviewed contracts before making that decision. A newer CLI installed on disk also does not prove the already-running daemon was upgraded. +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. + +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. ## Permanent interface @@ -49,7 +51,7 @@ Paths below are relative to the repository root. This is a working deletion map, | `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` | `/api/health` fallback | Remove only when every supported daemon exposes authenticated `/api/status`; retain all discovery error/deadline guards. | +| `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. | @@ -60,12 +62,13 @@ Keep the original compatibility audit as historical evidence. Record which predi ## Work packages -### P0 — Establish the preparation register (this increment) +### 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. @@ -75,11 +78,35 @@ This increment adds documentation only. All implementation and native-validation - [ ] 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 | +| --- | --- | +| 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. | + +- [ ] 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. + +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. + ### P2 — Certify historical data on the replacement runtime - [ ] Add reproducible synthetic-history seeds using the already-audited legacy runtimes; retain exact executable/package versions and integrity metadata. @@ -107,7 +134,8 @@ Old-runtime execution for generating migration fixtures is distinct from promisi ### P4 — Publish the new support floor -- [ ] Record the upstream post-beta announcement and exact target contract/dependency versions. +- [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. @@ -117,7 +145,7 @@ Old-runtime execution for generating migration fixtures is distinct from promisi ## Working in parallel -Use this draft as the maintained preparation register. Keep it close to `dev` and small: merge completed backward-compatible preparation as independent increments, then update this register's progress and links. Avoid accumulating an alternate application implementation for the rest of beta. +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: @@ -138,3 +166,6 @@ The eventual retirement change is ready when the support-policy, historical-data | 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. | From 7869e609b3404a55e41c736c6d723796acc1cb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 16:09:36 +0200 Subject: [PATCH 03/14] 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. --- .github/workflows/pr-build.yml | 22 ++ .../codenomad-architecture-guide/SKILL.md | 2 + .../references/sdk-api-reference.md | 4 +- .../references/sdk-critical-behaviors.md | 4 +- .../references/sdk-integration-patterns.md | 2 +- AGENTS.md | 2 + MIGRATION_V2.md | 8 +- dev-docs/OPENCODE_V2_COMPATIBILITY.md | 12 +- dev-docs/OPENCODE_V2_POST_BETA.md | 290 +++++++--------- packages/server/src/api-types.ts | 10 +- .../managed-installation.test.ts | 31 ++ .../opencode-update/managed-installation.ts | 89 +++++ .../src/opencode-update/service.test.ts | 314 ++++++++---------- .../server/src/opencode-update/service.ts | 111 +++++-- .../src/opencode/compatibility/events.ts | 15 - .../opencode/compatibility/location.test.ts | 22 +- .../opencode/compatibility/negotiate.test.ts | 10 +- .../src/opencode/compatibility/requests.ts | 49 --- .../src/opencode/compatibility/runtime.ts | 10 +- .../opencode/compatibility/transport.test.ts | 136 +++----- .../src/opencode/compatibility/transport.ts | 39 +-- .../src/opencode/runtime-support.test.ts | 26 ++ .../server/src/opencode/runtime-support.ts | 25 ++ .../__tests__/instance-proxy-legacy.test.ts | 52 ++- .../src/server/routes/opencode-update.test.ts | 18 + .../src/server/routes/opencode-update.ts | 10 + .../server/src/server/routes/workspaces.ts | 4 + packages/server/src/settings/binaries.ts | 4 +- .../workspaces/host-opencode-service.test.ts | 18 + packages/server/src/workspaces/manager.ts | 57 ++-- .../src/workspaces/opencode-cli-service.ts | 7 + .../opencode-connection-generation.test.ts | 12 +- .../src/workspaces/opencode-service.test.ts | 2 +- .../server/src/workspaces/opencode-service.ts | 10 +- packages/server/src/workspaces/spawn.ts | 8 +- packages/ui/src/App.tsx | 7 + packages/ui/src/components/opencode-setup.tsx | 54 +++ .../settings/opencode-update-card.tsx | 156 +++------ packages/ui/src/lib/api-client.ts | 3 + .../ui/src/lib/i18n/messages/de/settings.ts | 16 + .../ui/src/lib/i18n/messages/en/settings.ts | 16 + .../ui/src/lib/i18n/messages/es/settings.ts | 16 + .../ui/src/lib/i18n/messages/fr/settings.ts | 16 + .../ui/src/lib/i18n/messages/he/settings.ts | 16 + .../ui/src/lib/i18n/messages/ja/settings.ts | 16 + .../ui/src/lib/i18n/messages/ne/settings.ts | 16 + .../ui/src/lib/i18n/messages/ru/settings.ts | 16 + .../ui/src/lib/i18n/messages/tr/settings.ts | 16 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 16 + packages/ui/src/lib/sdk-manager.ts | 4 + .../src/stores/legacy-runtime-cache.test.ts | 11 +- .../stores/opencode-data-settlement.test.ts | 4 +- packages/ui/src/stores/opencode-setup.ts | 82 +++++ .../ui/src/stores/runtime-contract.test.ts | 14 +- .../tests/browser/fixtures/opencode-setup.tsx | 19 ++ .../ui/tests/browser/opencode-setup.test.ts | 133 ++++++++ scripts/prepare-node-runtime.cjs | 6 + scripts/prepare-node-runtime.test.cjs | 21 ++ scripts/test-opencode-history-migration.mjs | 140 ++++++++ scripts/test-opencode-install-native.mjs | 32 ++ scripts/test-session-pruning-native.mjs | 9 +- 61 files changed, 1504 insertions(+), 786 deletions(-) create mode 100644 packages/server/src/opencode-update/managed-installation.test.ts create mode 100644 packages/server/src/opencode-update/managed-installation.ts delete mode 100644 packages/server/src/opencode/compatibility/events.ts delete mode 100644 packages/server/src/opencode/compatibility/requests.ts create mode 100644 packages/server/src/opencode/runtime-support.test.ts create mode 100644 packages/server/src/opencode/runtime-support.ts create mode 100644 packages/ui/src/components/opencode-setup.tsx create mode 100644 packages/ui/src/stores/opencode-setup.ts create mode 100644 packages/ui/tests/browser/fixtures/opencode-setup.tsx create mode 100644 packages/ui/tests/browser/opencode-setup.test.ts create mode 100644 scripts/test-opencode-history-migration.mjs create mode 100644 scripts/test-opencode-install-native.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 840a77561..d40ce1a7a 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -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: diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index b2f17ef25..464877aa8 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -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. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index 04ccd57ff..fa30a9302 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -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. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 2a4ee64f4..1a3dca75a 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -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 diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index 4002445a4..0acc24ec9 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 32285eaa2..762d06f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 000878d89..bd296ba41 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -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 diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 3f5114c3d..958d0befd 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -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. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 007ca55c4..2416e519d 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -1,171 +1,121 @@ -# OpenCode V2 stable-runtime transition and installation plan - -**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). - -## Objective - -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. - -Three versions must remain distinct: - -| 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. | - -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. - -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. - -## Permanent interface - -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. - -The future single-contract implementation must preserve: - -- 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. - -The directory name `compatibility/` is not a deletion instruction. Some of its implementation provides these permanent guarantees. - -## Retirement inventory - -Paths below are relative to the repository root. This is a working deletion map, not authorization to remove code before its evidence is available. - -| 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 | -| --- | --- | -| 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. | - -- [ ] 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. - -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. - -### P2 — Certify historical data on the replacement runtime - -- [ ] 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: - -| Synthetic legacy history | Required result on target runtime | +# OpenCode V2 stable-runtime transition + +**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. + +## Release contract + +- 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. + +## Setup and recovery + +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. + +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. + +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. + +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. + +## Historical migration + +`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. + +Local Windows acceptance for **2.0.3 → 2.0.11** covers: + +- 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. + +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. + +## Retirement disposition + +| Area | Implementation | | --- | --- | -| 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. | - -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. - -### 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. | +| 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. | + +## Validation and release gates + +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. + +Open release gates, not completed local claims: + +- 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. + +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. + +## Decision history + +- 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. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index cf4190ffb..185e21bcb 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -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 { diff --git a/packages/server/src/opencode-update/managed-installation.test.ts b/packages/server/src/opencode-update/managed-installation.test.ts new file mode 100644 index 000000000..e9f321aae --- /dev/null +++ b/packages/server/src/opencode-update/managed-installation.test.ts @@ -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 }) } +}) diff --git a/packages/server/src/opencode-update/managed-installation.ts b/packages/server/src/opencode-update/managed-installation.ts new file mode 100644 index 000000000..e748083b9 --- /dev/null +++ b/packages/server/src/opencode-update/managed-installation.ts @@ -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 { + 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 { + 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 }) + } +} diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index b274f5304..1228072f6 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -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" - -function createDeps(overrides: Partial = {}): OpenCodeUpdateServiceDeps { - let currentVersion = "0.0.0-beta-19271" - const latestVersion = "2.0.10" +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 deps(overrides: Partial = {}): 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" }), - upgradeBinary: async (_binary, target) => ({ success: true, version: target }), +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 } }, })) - - await assert.rejects( - () => service.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 } - }, + 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("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((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(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"], - }) + 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("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") }, +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(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++ }, })) - assert.equal((await service.getStatus()).updateAvailable, false) - assert.deepEqual(await service.upgrade(), { success: true, version: "2.0.11" }) + 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("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/, - ) - } +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("detects the package manager from the binary path or launch environment", () => { +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(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("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) }) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index e0056be3f..67a54bd66 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -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>() +const inFlightActivations = new Map>() type UpgradeResult = { success: true; version: string } | { success: false; error: string } export interface OpenCodeUpdateServiceDeps { resolveBinary: () => ResolvedBinary - probeBinary: typeof probeBinaryVersion + probeBinary: (path: string) => ReturnType | Promise> resolveLatestVersion: () => Promise canUpgradeBinary: (binary: ResolvedBinary) => boolean upgradeBinary: (binary: ResolvedBinary, target: string) => Promise + lifecycle?: (binary: ResolvedBinary) => Promise + reconnect?: (binary: ResolvedBinary) => Promise } export class OpenCodeUpdateError extends Error { @@ -43,17 +51,70 @@ export class OpenCodeUpdateService { async getStatus(): Promise { 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 { + 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 { + 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 { @@ -69,10 +130,12 @@ export class OpenCodeUpdateService { } private async performUpgrade(binary: ResolvedBinary): Promise { - 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): Promise { 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), }) } diff --git a/packages/server/src/opencode/compatibility/events.ts b/packages/server/src/opencode/compatibility/events.ts deleted file mode 100644 index 4b6cbe1a5..000000000 --- a/packages/server/src/opencode/compatibility/events.ts +++ /dev/null @@ -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 -} diff --git a/packages/server/src/opencode/compatibility/location.test.ts b/packages/server/src/opencode/compatibility/location.test.ts index 6cf54022d..65cf51b7f 100644 --- a/packages/server/src/opencode/compatibility/location.test.ts +++ b/packages/server/src/opencode/compatibility/location.test.ts @@ -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 }> = [] diff --git a/packages/server/src/opencode/compatibility/negotiate.test.ts b/packages/server/src/opencode/compatibility/negotiate.test.ts index d379de7a1..da3848e11 100644 --- a/packages/server/src/opencode/compatibility/negotiate.test.ts +++ b/packages/server/src/opencode/compatibility/negotiate.test.ts @@ -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(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"]) }) diff --git a/packages/server/src/opencode/compatibility/requests.ts b/packages/server/src/opencode/compatibility/requests.ts deleted file mode 100644 index cd0831204..000000000 --- a/packages/server/src/opencode/compatibility/requests.ts +++ /dev/null @@ -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 | 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 } -} diff --git a/packages/server/src/opencode/compatibility/runtime.ts b/packages/server/src/opencode/compatibility/runtime.ts index f914a5d84..a6f700d56 100644 --- a/packages/server/src/opencode/compatibility/runtime.ts +++ b/packages/server/src/opencode/compatibility/runtime.ts @@ -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" } diff --git a/packages/server/src/opencode/compatibility/transport.test.ts b/packages/server/src/opencode/compatibility/transport.test.ts index b6a675551..e7e5e9026 100644 --- a/packages/server/src/opencode/compatibility/transport.test.ts +++ b/packages/server/src/opencode/compatibility/transport.test.ts @@ -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(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/) }) diff --git a/packages/server/src/opencode/compatibility/transport.ts b/packages/server/src/opencode/compatibility/transport.ts index 5075318da..f67446276 100644 --- a/packages/server/src/opencode/compatibility/transport.ts +++ b/packages/server/src/opencode/compatibility/transport.ts @@ -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 { 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 } } diff --git a/packages/server/src/opencode/runtime-support.test.ts b/packages/server/src/opencode/runtime-support.test.ts new file mode 100644 index 000000000..cb8147b5d --- /dev/null +++ b/packages/server/src/opencode/runtime-support.test.ts @@ -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) +}) diff --git a/packages/server/src/opencode/runtime-support.ts b/packages/server/src/opencode/runtime-support.ts new file mode 100644 index 000000000..5d8f6a881 --- /dev/null +++ b/packages/server/src/opencode/runtime-support.ts @@ -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) +} diff --git a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts index a228c9ba9..ad25f4a14 100644 --- a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts @@ -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() } }) diff --git a/packages/server/src/server/routes/opencode-update.test.ts b/packages/server/src/server/routes/opencode-update.test.ts index ef0f60042..c40797034 100644 --- a/packages/server/src/server/routes/opencode-update.test.ts +++ b/packages/server/src/server/routes/opencode-update.test.ts @@ -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() } +}) diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts index 54ef593bf..9f9ba1025 100644 --- a/packages/server/src/server/routes/opencode-update.ts +++ b/packages/server/src/server/routes/opencode-update.ts @@ -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() diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 2c524089f..9331a6e95 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -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) } diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index ce76b6ae5..e4d75807d 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -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 { diff --git a/packages/server/src/workspaces/host-opencode-service.test.ts b/packages/server/src/workspaces/host-opencode-service.test.ts index 30c462d37..0ff17849b 100644 --- a/packages/server/src/workspaces/host-opencode-service.test.ts +++ b/packages/server/src/workspaces/host-opencode-service.test.ts @@ -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" }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 59a5e4ca9..1bc4f8b9f 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -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 { + 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 { + 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, timeoutMs: number, diff --git a/packages/server/src/workspaces/opencode-cli-service.ts b/packages/server/src/workspaces/opencode-cli-service.ts index fc2394b40..769c4c938 100644 --- a/packages/server/src/workspaces/opencode-cli-service.ts +++ b/packages/server/src/workspaces/opencode-cli-service.ts @@ -64,6 +64,13 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { return this.endpoint(url, deadlineAt) } + async restart(deadlineAt = Date.now() + this.timeoutMs): Promise { + // 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 { const url = this.assertServiceUrl(value) const password = this.singleLine( diff --git a/packages/server/src/workspaces/opencode-connection-generation.test.ts b/packages/server/src/workspaces/opencode-connection-generation.test.ts index c4b027bbf..188e65f00 100644 --- a/packages/server/src/workspaces/opencode-connection-generation.test.ts +++ b/packages/server/src/workspaces/opencode-connection-generation.test.ts @@ -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 } }) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 3fd6166ae..d31f4acb2 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -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) }) diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 91348e53d..ad8cfdaa0 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -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 ensure: (deadlineAt?: number) => Promise + restart?: (deadlineAt?: number) => Promise } export type OpenCodeSharedServiceOptions = { @@ -148,6 +149,7 @@ export class OpenCodeSharedService { private connect(options?: OpenCodeSharedServiceOptions, deadlineAt?: number): Promise { 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) diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 2087fa50d..6c26bbcdb 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -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 { 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> { + 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, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d5de1d643..a65497bf0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 ( <> + { 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 <> + +
+ +
+
+ + +
+ +
{t("settings.opencode.update.title")} + {t("app.launchError.close")}
+
+
+
+
+
+ +} diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index a312c2b19..787ba011c 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -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(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 ( -
-
-
-

{t("settings.opencode.update.title")}

-

{t("settings.opencode.update.subtitle")}

+ onMount(() => void refreshOpenCodeSetup()) + return
+
+

{t("settings.opencode.update.title")}

+

{t("settings.opencode.setup.description")}

+
{t("settings.scope.server")}
+ {t("settings.opencode.update.checking")}

}> + {data => <> +

{t(`settings.opencode.setup.${data().state}`)}

+
+
{t("settings.opencode.update.installed")}{data().currentVersion ?? "—"}
+
{t("settings.opencode.setup.minimum")}{data().minimumVersion}
+
{t("settings.opencode.setup.daemon")}{data().daemonVersion ?? "—"}
- {t("settings.scope.server")} -
- - {t("settings.opencode.update.checking")}
}> - - - -
- } - > - {(updateStatus) => ( - <> -
-
- {t("settings.opencode.update.installed")} - {updateStatus().currentVersion} -
-
- - -
{t("settings.opencode.update.checkFailed")}
-
- -
-
- - - {t("settings.opencode.update.upToDate")} - }> - {t("settings.opencode.update.availableUnsupported", { version: updateStatus().latestVersion ?? "" })}} - > -
- -
-
-
-
- - )} +

{data().binaryPath}

+

{t("settings.opencode.setup.wsl")}

+

{t("settings.opencode.setup.restartRequired")}

+

{t("settings.opencode.setup.serviceError")}

+

{t("settings.opencode.update.checkFailed")}

+ +

{t("settings.opencode.setup.manual")}

- - - {(version) => ( -
- {t("settings.opencode.update.success", { version: version() })} -
- )} -
- - - +
+ + + + + +
+ } + + +
+ +
- ) + } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index b875f2b1c..4fcaa4e9a 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -446,6 +446,9 @@ export const serverApi = { updateOpenCode(): Promise { return request("/api/opencode/update", { method: "POST" }) }, + startOpenCode(restart = false): Promise { + return request("/api/opencode/service", { method: "POST", body: JSON.stringify({ restart }) }) + }, fetchSpeechCapabilities(): Promise { return request("/api/speech/capabilities") }, diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index b25fd98f2..0a047bc85 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index e22929e87..1317c5a24 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 9760d0b9a..6fbe2449a 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 601ba403c..73dfab2a7 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 8479f0602..f19aeebad 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -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": "שמרו ערכת צבעים לכל מצב תצוגה. המצב האוטומטי עוקב אחר המערכת ומציג את הערכה המתאימה.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 81fa9e518..4e36df91b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -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": "外観ごとにパレットを保存します。自動モードではシステムに合わせて対応するパレットを表示します。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 28f63acd0..9e439cc67 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -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": "हरेक देखावटका लागि रङ योजना सुरक्षित गर्नुहोस्। स्वतः मोडले प्रणालीअनुसार सम्बन्धित रङ योजना देखाउँछ।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 3e7db4c83..a4300bda0 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -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": "Сохраните палитру для каждого режима оформления. Авто следует настройкам системы и показывает соответствующую палитру.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index b9002650f..95b259442 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 96aaf216b..4a109ebca 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -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": "为每种外观保存配色。自动模式跟随系统并显示相应的配色。", diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 769940254..86ecceb20 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -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) { diff --git a/packages/ui/src/stores/legacy-runtime-cache.test.ts b/packages/ui/src/stores/legacy-runtime-cache.test.ts index e6ff50e4e..5f972c1eb 100644 --- a/packages/ui/src/stores/legacy-runtime-cache.test.ts +++ b/packages/ui/src/stores/legacy-runtime-cache.test.ts @@ -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() } diff --git a/packages/ui/src/stores/opencode-data-settlement.test.ts b/packages/ui/src/stores/opencode-data-settlement.test.ts index b4f071fbb..bfd10df35 100644 --- a/packages/ui/src/stores/opencode-data-settlement.test.ts +++ b/packages/ui/src/stores/opencode-data-settlement.test.ts @@ -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) => { 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) } diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts new file mode 100644 index 000000000..96b25c206 --- /dev/null +++ b/packages/ui/src/stores/opencode-setup.ts @@ -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() +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 | undefined +let resume: (() => Promise) | undefined + +export function needsOpenCodeSetup(status = openCodeSetupStatus()): boolean { + return status?.state === "missing" || status?.state === "update_required" || status?.serviceState === "restart_required" +} + +export function openOpenCodeSetup(retry?: () => Promise) { + 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 { + 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() + } +} diff --git a/packages/ui/src/stores/runtime-contract.test.ts b/packages/ui/src/stores/runtime-contract.test.ts index 394ebe114..099262200 100644 --- a/packages/ui/src/stores/runtime-contract.test.ts +++ b/packages/ui/src/stores/runtime-contract.test.ts @@ -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, 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) } diff --git a/packages/ui/tests/browser/fixtures/opencode-setup.tsx b/packages/ui/tests/browser/fixtures/opencode-setup.tsx new file mode 100644 index 000000000..0ed5ec0cf --- /dev/null +++ b/packages/ui/tests/browser/fixtures/opencode-setup.tsx @@ -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(() => , 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), +} diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts new file mode 100644 index 000000000..276e99df6 --- /dev/null +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -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", '
')) + }) + } }], 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() } +}) diff --git a/scripts/prepare-node-runtime.cjs b/scripts/prepare-node-runtime.cjs index 7a44b1416..0c5b151ef 100644 --- a/scripts/prepare-node-runtime.cjs +++ b/scripts/prepare-node-runtime.cjs @@ -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, diff --git a/scripts/prepare-node-runtime.test.cjs b/scripts/prepare-node-runtime.test.cjs index 3f5e05989..caaa1404f 100644 --- a/scripts/prepare-node-runtime.test.cjs +++ b/scripts/prepare-node-runtime.test.cjs @@ -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 }) } +}) diff --git a/scripts/test-opencode-history-migration.mjs b/scripts/test-opencode-history-migration.mjs new file mode 100644 index 000000000..eeb05bd3c --- /dev/null +++ b/scripts/test-opencode-history-migration.mjs @@ -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}`) diff --git a/scripts/test-opencode-install-native.mjs b/scripts/test-opencode-install-native.mjs new file mode 100644 index 000000000..cf21c6e9f --- /dev/null +++ b/scripts/test-opencode-install-native.mjs @@ -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 }) } diff --git a/scripts/test-session-pruning-native.mjs b/scripts/test-session-pruning-native.mjs index d9d771de1..94f762ade 100644 --- a/scripts/test-session-pruning-native.mjs +++ b/scripts/test-session-pruning-native.mjs @@ -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 }) From 419fe6a60eed1ef755cf366261a1628919b835ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 16:42:01 +0200 Subject: [PATCH 04/14] fix(opencode): close managed setup activation and publication races Keep activation explicit after optional upgrades while reconnecting backend authority to the newly selected executable. Supported running daemons remain usable and new workspaces can open when restart is deferred. Recheck executable selection and execution-host eligibility before mutating the shared service. Publish verified installations with immutable, exclusive version receipts. Concurrent older installs cannot downgrade a newer selection, and same-version Windows publishers no longer replace an open receipt. Continue reading previous current markers. Cover the gatekeeper findings with real manager/shared-service ownership regressions, browser recovery tests, and concurrent installer tests. Extend native migration to beta and stable seeds, same-version Forms durability controls and historical provider connections. Provide the POSIX shell explicitly in the no-system-Node fixture, fixing Linux/macOS installation CI isolation. --- .github/workflows/pr-build.yml | 20 ++-- packages/server/src/api-types.ts | 2 +- .../managed-installation.test.ts | 51 ++++++++++ .../opencode-update/managed-installation.ts | 33 ++++--- .../src/opencode-update/service.test.ts | 25 ++++- .../server/src/opencode-update/service.ts | 17 +++- .../opencode-update/setup-reconnect.test.ts | 97 +++++++++++++++++++ packages/server/src/workspaces/manager.ts | 6 +- .../settings/opencode-update-card.tsx | 4 +- packages/ui/src/stores/opencode-setup.ts | 4 +- .../ui/tests/browser/opencode-setup.test.ts | 36 +++++++ scripts/test-opencode-history-migration.mjs | 67 ++++++++++++- scripts/test-opencode-install-native.mjs | 9 +- 13 files changed, 336 insertions(+), 35 deletions(-) create mode 100644 packages/server/src/opencode-update/setup-reconnect.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index d40ce1a7a..1aa1d339e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -409,17 +409,25 @@ jobs: $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" + $seeds = @( + @{ scope = '@opencode'; version = '2.0.3'; command = 'opencode' }, + @{ scope = '@opencode-ai'; version = '0.0.0-beta-19271'; command = 'opencode2' } + ) + $oldClis = foreach ($entry in $seeds) { + $seed = Join-Path $env:RUNNER_TEMP "opencode-migration-seed-$($entry.version)" + npm install --prefix $seed "$($entry.scope)/cli@$($entry.version)" --ignore-scripts --no-audit --no-fund | Out-Host + if ($LASTEXITCODE) { exit $LASTEXITCODE } + Join-Path $seed "node_modules/$($entry.scope)/cli-$platform-$arch/bin/$($entry.command)$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 } + foreach ($oldCli in $oldClis) { + node scripts/test-opencode-history-migration.mjs $oldCli $cli + if ($LASTEXITCODE) { exit $LASTEXITCODE } + } } # Exercise the window persistence regressions on the architecture reported in diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 185e21bcb..3eedce2bb 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -382,7 +382,7 @@ export interface OpenCodeUpdateStatus { state: "missing" | "update_required" | "ready" | "error" binaryPath: string daemonVersion?: string - serviceState?: "stopped" | "ready" | "restart_required" | "error" + serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "error" serviceError?: string target: "host" | "wsl" canRestart: boolean diff --git a/packages/server/src/opencode-update/managed-installation.test.ts b/packages/server/src/opencode-update/managed-installation.test.ts index e9f321aae..1a929b101 100644 --- a/packages/server/src/opencode-update/managed-installation.test.ts +++ b/packages/server/src/opencode-update/managed-installation.test.ts @@ -3,6 +3,7 @@ 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 { readFileSync } from "node:fs" import { installManagedOpenCode, readManagedExecutable } from "./managed-installation" test("failed install never changes the selected version; verified install publishes atomically", async () => { @@ -29,3 +30,53 @@ test("failed install never changes the selected version; verified install publis assert.equal(readManagedExecutable(root), binary) } finally { await rm(root, { recursive: true, force: true }) } }) + +test("immutable publication cannot select an older installer finishing after a newer one", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-install-race-")) + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const options = { root, node: process.execPath, npm: "fixture-npm.js", + execute: async (_file: string, args: string[]) => { + const version = args.at(-1)!.split("@").at(-1)! + const staging = args[args.indexOf("--prefix") + 1]! + if (version === "2.0.11") await gate + await mkdir(path.join(staging, "node_modules/@opencode/cli/bin"), { recursive: true }) + await writeFile(path.join(staging, "node_modules/@opencode/cli/bin/opencode.exe"), version) + }, + probe: (file: string) => { + try { return { valid: true, version: readFileSync(file, "utf8") } } + catch { return { valid: false } } + }, + } + try { + const older = installManagedOpenCode("2.0.11", options) + const newer = await installManagedOpenCode("2.0.12", options) + release() + assert.equal(await older, newer) + assert.equal(readManagedExecutable(root), newer) + await writeFile(path.join(root, "current"), "2.0.11") + assert.equal(readManagedExecutable(root), newer, "old marker writers cannot downgrade receipt selection") + } finally { release(); await rm(root, { recursive: true, force: true }) } +}) + +test("same-version concurrent publishers all succeed without replacing Windows receipts", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-same-version-install-")) + const options = { root, node: process.execPath, npm: "fixture-npm.js", + execute: async (_file: string, args: string[]) => { + 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"), "2.0.12") + }, + probe: (file: string) => { + try { return { valid: true, version: readFileSync(file, "utf8") } } + catch { return { valid: false } } + }, + } + try { + for (let batch = 0; batch < 6; batch++) { + const results = await Promise.all(Array.from({ length: 20 }, () => installManagedOpenCode("2.0.12", options))) + assert.equal(new Set(results).size, 1) + assert.equal(readManagedExecutable(root), results[0]) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/opencode-update/managed-installation.ts b/packages/server/src/opencode-update/managed-installation.ts index e748083b9..1d2b2d51e 100644 --- a/packages/server/src/opencode-update/managed-installation.ts +++ b/packages/server/src/opencode-update/managed-installation.ts @@ -1,11 +1,12 @@ import { execFile } from "node:child_process" -import { existsSync, readFileSync } from "node:fs" -import { mkdir, rename, rm, writeFile } from "node:fs/promises" +import { existsSync, readFileSync, readdirSync } from "node:fs" +import { mkdir, rename, rm, open } 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" +import { compareVersionStrings } from "../releases/release-monitor" // Never place changing installations in OpenCode's recursively watched config. export function managedInstallRoot(): string { @@ -17,12 +18,15 @@ export function managedExecutable(root: string, version: string): string { } 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 } + // Immutable receipts make selection monotonic across backends/processes. + // A slower older install can publish its receipt but cannot replace a newer + // selection. Unfinished staging/version directories never count as receipts. + let versions: string[] = [] + try { versions = readdirSync(path.join(root, "selected")) } catch { /* First installation. */ } + try { versions.push(readFileSync(path.join(root, "current"), "utf8").trim()) } catch { /* Previous marker format is optional. */ } + return versions.filter(version => /^\d+\.\d+\.\d+$/.test(version)) + .sort((a, b) => compareVersionStrings(b, a)) + .map(version => managedExecutable(root, version)).find(binary => existsSync(binary)) } /** npm from the official Node archive, beside the backend runtime on both hosts. */ @@ -78,11 +82,14 @@ export async function installManagedOpenCode(version: string, options: { 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) + const selections = path.join(root, "selected") + await mkdir(selections, { recursive: true }) + // The filename is the entire receipt. Exclusive creation publishes it in + // one operation and never replaces an existing same-version receipt. + await open(path.join(selections, version), "wx").then(file => file.close()).catch(error => { + if (error.code !== "EEXIST") throw error + }) + return readManagedExecutable(root)! } finally { await rm(staging, { recursive: true, force: true }) } diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 1228072f6..e3442be88 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -147,7 +147,7 @@ test("activation coalesces repeated clicks and refuses a changed selection befor selected = "replacement" release() await assert.rejects(first, /selection changed/) - assert.equal(restarts, 1) + assert.equal(restarts, 0, "a stale selection must be refused before stopping its daemon") assert.equal(reconnects, 0) }) @@ -166,6 +166,29 @@ test("an unsupported latest or newer daemon cannot trigger a downgrade or restar await assert.rejects(service.start(true), /not an older runtime/) }) +test("optional upgrades retain explicit activation for an admitted but older daemon", async () => { + let installed = "2.0.11", daemon = "2.0.11", restarts = 0 + const endpoint = () => { + const value: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(value, { version: daemon, pid: 123, discovery: "info" }) + return value + } + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: installed }), resolveLatestVersion: async () => "2.0.12", + upgradeBinary: async () => { installed = "2.0.12"; return { success: true, version: installed } }, + lifecycle: async () => ({ discover: async () => endpoint(), ensure: async () => endpoint(), + restart: async () => { restarts++; daemon = installed; return endpoint() } }), + })) + await service.upgrade() + const available = await service.start() + assert.equal(available.state, "ready") + assert.equal(available.serviceState, "restart_available") + assert.equal(available.canRestart, true) + assert.equal(restarts, 0) + assert.equal((await service.start(true)).serviceState, "ready") + assert.equal(restarts, 1) +}) + 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}`] }) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 67a54bd66..3794f43bd 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -29,6 +29,7 @@ export interface OpenCodeUpdateServiceDeps { upgradeBinary: (binary: ResolvedBinary, target: string) => Promise lifecycle?: (binary: ResolvedBinary) => Promise reconnect?: (binary: ResolvedBinary) => Promise + admitActivation?: (binary: ResolvedBinary) => void } export class OpenCodeUpdateError extends Error { @@ -75,10 +76,13 @@ export class OpenCodeUpdateService { 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" + const olderDaemon = identity && currentVersion && /^(?:0|1|2)\./.test(identity.version) + && compareOpenCodeVersionStrings(currentVersion, identity.version) > 0 + status.serviceState = !endpoint ? "stopped" : olderDaemon + ? supportsOpenCodeVersion(identity!.version) ? "restart_available" : "restart_required" + : identity && supportsOpenCodeVersion(identity.version) ? "ready" : "error" + status.canRestart = Boolean(lifecycle.restart) && state === "ready" + && (status.serviceState === "restart_required" || status.serviceState === "restart_available") } catch { status.serviceState = "error"; status.serviceError = "service_check_failed" } } return status @@ -108,6 +112,8 @@ export class OpenCodeUpdateService { throw new Error("The shared daemon is not an older runtime eligible for this update") } } + if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation") + this.deps.admitActivation?.(binary) 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") @@ -152,7 +158,7 @@ export class OpenCodeUpdateService { throw new OpenCodeUpdateError("upgrade_failed", result.error) } const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path) - if (installedVersion !== latestVersion) { + if (!supportsOpenCodeVersion(installedVersion) || compareOpenCodeVersionStrings(installedVersion, latestVersion) < 0) { throw new OpenCodeUpdateError( "upgrade_verification_failed", `OpenCode reported ${result.version}, but the configured binary is ${installedVersion} instead of ${latestVersion}`, @@ -319,5 +325,6 @@ export function createOpenCodeUpdateService( }, lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle), reconnect: binary => workspaceManager.reconnectAfterSetup(binary.path), + admitActivation: binary => workspaceManager.assertSetupExecutionHost(binary.path), }) } diff --git a/packages/server/src/opencode-update/setup-reconnect.test.ts b/packages/server/src/opencode-update/setup-reconnect.test.ts new file mode 100644 index 000000000..9099be326 --- /dev/null +++ b/packages/server/src/opencode-update/setup-reconnect.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import pino from "pino" +import type { OpenCodeClient } from "@opencode/client" +import { Service, type Endpoint } from "@opencode/client/service" +import { EventBus } from "../events/bus" +import { rememberRuntime } from "../opencode/compatibility/runtime" +import { WorkspaceManager } from "../workspaces/manager" +import { OpenCodeSharedService, type OpenCodeServiceLifecycle } from "../workspaces/opencode-service" +import { OpenCodeUpdateService } from "./service" + +test("optional setup rebinds real workspace ownership without restarting the supported older daemon", async () => { + const parent = path.join(os.tmpdir(), "opencode") + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(path.join(parent, "codenomad-setup-reconnect-")) + const originalBinary = path.join(root, "2.0.11", "opencode.exe") + const installedBinary = path.join(root, "2.0.12", "opencode.exe") + let selected = originalBinary + let starts = 0 + let restarts = 0 + const endpoint: Endpoint = { url: "http://127.0.0.1:1" } + rememberRuntime(endpoint, { version: "2.0.11", pid: 123, discovery: "info" }) + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => endpoint, + ensure: async () => { starts++; throw new Error("The existing daemon must not be started again") }, + restart: async () => { restarts++; throw new Error("Restart requires a separate explicit action") }, + } + const validatedDirectories: string[] = [] + const client = { + location: { get: async ({ location }: { location: { directory: string } }) => { + validatedDirectories.push(location.directory) + return { directory: location.directory, + project: { id: "fixture", directory: location.directory, canonical: location.directory } } + } }, + } as unknown as OpenCodeClient + const sharedService = new OpenCodeSharedService({ + headers: Service.headers, + makeClient: () => client, + }) + const manager = new WorkspaceManager({ + rootDir: root, + settings: { getOwner: () => ({ environmentVariables: {} }) } as never, + binaryResolver: { resolveDefault: () => ({ path: selected, label: "Fixture OpenCode" }) } as never, + eventBus: new EventBus(), + logger: pino({ level: "silent" }), + sharedService, + hostServiceLifecycleFactory: () => lifecycle, + }) + const updater = new OpenCodeUpdateService({ + resolveBinary: () => ({ path: selected, label: "Fixture OpenCode" }), + probeBinary: binary => ({ valid: true, version: binary === installedBinary ? "2.0.12" : "2.0.11" }), + resolveLatestVersion: async () => "2.0.12", + canUpgradeBinary: () => true, + upgradeBinary: async () => { + selected = installedBinary + return { success: true, version: "2.0.12" } + }, + lifecycle: async binary => (await manager.setupServiceOptions(binary.path)).lifecycle, + admitActivation: binary => manager.assertSetupExecutionHost(binary.path), + reconnect: binary => manager.reconnectAfterSetup(binary.path), + }) + + try { + const first = await manager.create(path.join(root, "first")) + const originalConnection = await sharedService.acquire() + await updater.upgrade() + assert.equal((await updater.getStatus()).serviceState, "restart_available") + + // Reproduce why simply returning after installation is insufficient: the + // resolver now selects the new executable, but client ownership is pinned. + const nextFolder = path.join(root, "second") + await assert.rejects(manager.create(nextFolder), /OpenCode service identity cannot change/) + + const status = await updater.start(false) + assert.equal(status.state, "ready") + assert.equal(status.currentVersion, "2.0.12") + assert.equal(status.daemonVersion, "2.0.11") + assert.equal(status.serviceState, "restart_available") + assert.equal(status.canRestart, true, "non-disruptive reconnect retains explicit activation") + assert.throws(() => originalConnection.assertCurrent(), /OpenCode connection changed/) + + const second = await manager.create(nextFolder) + assert.equal(second.workspace.status, "ready") + assert.equal(second.workspace.binaryId, installedBinary) + assert.equal(manager.list().find(workspace => workspace.id === first.workspace.id)?.binaryId, installedBinary) + assert.equal(manager.list().length, 2, "the failed open did not leak a workspace") + assert.ok(validatedDirectories.includes(nextFolder)) + assert.equal(starts, 0) + assert.equal(restarts, 0) + } finally { + try { await manager.shutdown() } + finally { await rm(root, { recursive: true, force: true }) } + } +}) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 1bc4f8b9f..e992cf684 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -970,12 +970,16 @@ export class WorkspaceManager { } } - async reconnectAfterSetup(binary: string): Promise { + assertSetupExecutionHost(binary: string): 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") } + } + + async reconnectAfterSetup(binary: string): Promise { + this.assertSetupExecutionHost(binary) const options = await this.setupServiceOptions(binary) // This disposes client authority only; it never stops the shared daemon. await this.sharedService.shutdown() diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index 787ba011c..b61edc2d3 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -22,7 +22,7 @@ export const OpenCodeUpdateCard: Component = () => {

{data().binaryPath}

{t("settings.opencode.setup.wsl")}

-

{t("settings.opencode.setup.restartRequired")}

+

{t("settings.opencode.setup.restartRequired")}

{t("settings.opencode.setup.serviceError")}

{t("settings.opencode.update.checkFailed")}

@@ -33,7 +33,7 @@ export const OpenCodeUpdateCard: Component = () => { {busy() ? t("settings.opencode.update.updating") : data().state === "missing" ? t("settings.opencode.setup.install") : t("settings.opencode.update.action", { version: data().latestVersion ?? "" })} - + diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts index 96b25c206..37d4b79f4 100644 --- a/packages/ui/src/stores/opencode-setup.ts +++ b/packages/ui/src/stores/opencode-setup.ts @@ -67,10 +67,10 @@ export async function runOpenCodeSetup(action: "install" | "start" | "restart") const status = await serverApi.startOpenCode(action === "restart") if (epoch !== generation) return setOpenCodeSetupStatus(status) - if (status.state === "ready" && status.serviceState === "ready") { + if (status.state === "ready" && (status.serviceState === "ready" || status.serviceState === "restart_available")) { const retry = resume resume = undefined - setOpenCodeSetupOpen(false) + if (status.serviceState === "ready") setOpenCodeSetupOpen(false) await retry?.() // Workspace-open retry only; never a session prompt/mutation. } } catch { diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 276e99df6..78ab9577c 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -131,3 +131,39 @@ test("unsupported proxy calls open recovery once without replaying mutations", a assert.equal(prompts, 2, "each intentional call occurs once; recovery never retries a prompt") } finally { await page.close() } }) + +test("optional install reconnects without restarting and leaves explicit activation available", async () => { + const page = await browser.newPage() + let installed = false, restarts = 0, connects = 0 + await page.route("**/api/**", async route => { + const request = route.request() + if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") { + installed = true + return route.fulfill({ json: { success: true, version: "2.0.12" } }) + } + if (request.url().endsWith("/api/opencode/service")) { + if (request.postDataJSON().restart) restarts++ + else connects++ + } + return route.fulfill({ json: { state: "ready", currentVersion: installed ? "2.0.12" : "2.0.11", + latestVersion: "2.0.12", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", + canUpgrade: !installed, updateAvailable: !installed, daemonVersion: restarts ? "2.0.12" : "2.0.11", + serviceState: installed && !restarts ? "restart_available" : "ready", canRestart: installed && !restarts } }) + }) + 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: "Update to OpenCode 2.0.12" }).click() + await page.waitForFunction(() => (window as any).fixture.resumed() === 1) + assert.equal(connects, 1, "rebind backend authority while retaining the supported daemon") + assert.equal(restarts, 0) + await page.getByRole("button", { name: "Restart shared service" }).waitFor() + await page.getByRole("button", { name: "Close", exact: true }).click() + assert.equal(await page.getByRole("button", { name: "OpenCode setup required" }).count(), 0) + await page.evaluate(() => (window as any).fixture.open()) + await page.getByRole("button", { name: "Restart shared service" }).click() + await page.waitForFunction(() => !document.querySelector('[role="dialog"]')) + assert.equal(restarts, 1) + } finally { await page.close() } +}) diff --git a/scripts/test-opencode-history-migration.mjs b/scripts/test-opencode-history-migration.mjs index eeb05bd3c..9c1a59795 100644 --- a/scripts/test-opencode-history-migration.mjs +++ b/scripts/test-opencode-history-migration.mjs @@ -26,6 +26,10 @@ 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 providerConfig = JSON.stringify({ providers: { openai: { settings: { apiKey: "synthetic-migration-fixture" }, + models: { "migration-fixture": { name: "Historical fixture model" } } } } }) +const configFile = path.join(env.OPENCODE_CONFIG_DIR, "opencode.json") +await writeFile(configFile, providerConfig) 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"))) @@ -46,8 +50,8 @@ async function start(cli, database, name) { 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" }, + const raw = async (route, body, method = body === undefined ? "GET" : "POST", headers = {}) => { + const response = await fetch(`${baseUrl}${route}`, { method, headers: { authorization, "content-type": "application/json", ...headers }, ...(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}`) @@ -60,6 +64,8 @@ async function start(cli, database, name) { const seedDb = path.join(root, "seed.db"), migratedDb = path.join(root, "migrated.db") const seed = await start(oldCli, seedDb, "seed") const snapshots = [] +const forms = [] +let providerConnections let pendingSession try { for (const workspaceID of [undefined, "wrk_fixture_one", "wrk_fixture_two"]) { @@ -95,11 +101,46 @@ try { 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) + for (const owner of [pendingSession, "global"]) { + const headers = owner === "global" ? { "x-opencode-directory": encodeURIComponent(repo) } : {} + for (const action of ["reply", "cancel"]) { + const form = (await seed.raw(`/api/session/${owner}/form`, { + title: `Historical ${owner === "global" ? "global" : "session"} ${action}`, + fields: [{ key: "answer", type: "string" }], + }, "POST", headers)).data + forms.push({ form, owner, action }) + } + } + const models = await seed.raw(`/api/model?location[directory]=${encodeURIComponent(repo)}`) + assert.ok(JSON.stringify(models).includes("Historical fixture model")) + await seed.raw(`/api/integration/openai/connect/key?location[directory]=${encodeURIComponent(repo)}`, { + key: "synthetic-not-a-provider-key", label: "Historical fixture credential", + }) + providerConnections = (await seed.raw(`/api/integration/openai?location[directory]=${encodeURIComponent(repo)}`)).data.connections + assert.ok(JSON.stringify(providerConnections).includes("Historical fixture credential")) + await writeFile(path.join(root, "seed-provider-connections.json"), JSON.stringify(providerConnections)) 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 controlDb = path.join(root, "restart-control.db") +await copyFile(seedDb, controlDb) +await copyFile(`${seedDb}-wal`, `${controlDb}-wal`).catch(error => { if (error.code !== "ENOENT") throw error }) +const control = await start(oldCli, controlDb, "same-version-restart-control") +try { + for (const entry of forms) { + const headers = entry.owner === "global" ? { "x-opencode-directory": encodeURIComponent(repo) } : {} + try { + await control.raw(`/api/session/${entry.owner}/form/${entry.form.id}`, undefined, "GET", headers) + entry.durable = true + } catch (error) { + assert.match(String(error), /404.*FormNotFoundError/) + entry.durable = false + } + } +} finally { await control.close() } +await writeFile(path.join(root, "forms-restart-control.json"), JSON.stringify(forms)) const target = await start(newCli, migratedDb, "target") try { const { OpenCodeCliService } = await tsImport("../packages/server/src/workspaces/opencode-cli-service.ts", import.meta.url) @@ -112,10 +153,32 @@ try { assert.equal(inbox.length, 1) assert.equal(inbox[0].payload.text, "pending migration fixture") assert.equal(typeof inbox[0].time.created, "number") + for (const { form, owner, action, durable } of forms) { + const headers = owner === "global" ? { headers: { "x-opencode-directory": encodeURIComponent(repo) } } : undefined + if (!durable) { + await assert.rejects(client.session.form.get({ sessionID: owner, formID: form.id }, headers), error => error?._tag === "FormNotFoundError") + assert.ok(!(await client.form.list({ location: { directory: repo } })).data.some(item => item.id === form.id)) + } + const restored = durable ? await client.session.form.get({ sessionID: owner, formID: form.id }, headers) + : await client.session.form.create({ sessionID: owner, title: form.title, fields: form.fields }, headers) + assert.equal(restored.title, form.title) + assert.deepEqual(restored.fields, form.fields) + assert.equal((await client.session.form.get({ sessionID: owner, formID: restored.id }, headers)).state.status, "pending") + if (action === "reply") await client.session.form.reply({ sessionID: owner, formID: restored.id, answer: { answer: "migrated" } }, headers) + else await client.session.form.cancel({ sessionID: owner, formID: restored.id }, headers) + const settled = await client.session.form.get({ sessionID: owner, formID: restored.id }, headers) + assert.equal(settled.state.status, action === "reply" ? "answered" : "cancelled") + } + assert.ok(JSON.stringify(await client.model.list({ location: { directory: repo } })).includes("Historical fixture model")) + assert.deepEqual((await client.integration.get({ integrationID: "openai", location: { directory: repo } })).data.connections, providerConnections) + assert.equal(await readFile(configFile, "utf8"), providerConfig) + console.log("PASS global/session Forms match same-version native restart durability; fresh Forms settle and historical provider/model configuration remains intact") 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.equal(restored.info.parentID, original.info.parentID) + assert.deepEqual(restored.info.fork, original.info.fork) 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 }) diff --git a/scripts/test-opencode-install-native.mjs b/scripts/test-opencode-install-native.mjs index cf21c6e9f..998c3dedf 100644 --- a/scripts/test-opencode-install-native.mjs +++ b/scripts/test-opencode-install-native.mjs @@ -1,6 +1,6 @@ // 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 { mkdtemp, mkdir, cp, copyFile, chmod, rm, symlink } from "node:fs/promises" import path from "node:path" import os from "node:os" import { execFileSync } from "node:child_process" @@ -21,9 +21,14 @@ 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))) +const tools = path.join(root, "tools") +await mkdir(tools) +// npm invokes the OS shell for package lifecycle scripts; provide that shell +// explicitly without admitting /usr/bin/node or any other system Node to PATH. +if (process.platform !== "win32") await symlink("/bin/sh", path.join(tools, "sh")) 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` : "" }) + PATH: process.platform === "win32" ? `${process.env.SystemRoot}\\System32` : tools }) try { const binary = await installManagedOpenCode(MINIMUM_OPENCODE_VERSION, { root: path.join(root, "install"), node, env }) assert.equal(readManagedExecutable(path.join(root, "install")), binary) From 8e32d4626b385d8c88f196b5644a68c90a90808d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 16:50:04 +0200 Subject: [PATCH 05/14] test(opencode): bound native migration acceptance and cleanup Ensure a wedged migration reports a failure instead of retaining a native fixture indefinitely. Apply a whole-run deadline and per-request cancellation through the generated client, bound startup/version probes, and reject repeated pagination cursors. Escalate termination only for the child spawned by the fixture, with bounded graceful/forced waits and explicit failure if termination cannot complete. Keep cleanup in finally blocks and add workflow timeout backstops. Gatekeeper re-review reports no remaining findings in acceptance/CI paths. Three failure-guard regressions pass; native historical migrations pass again on Windows and Linux with the guards enabled. --- .github/workflows/pr-build.yml | 5 +++ .../settings/opencode-update-card.tsx | 4 +- scripts/native-fixture-guards.mjs | 30 +++++++++++++ scripts/native-fixture-guards.test.mjs | 27 +++++++++++ scripts/test-opencode-history-migration.mjs | 45 +++++++++++++++---- 5 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 scripts/native-fixture-guards.mjs create mode 100644 scripts/native-fixture-guards.test.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1aa1d339e..02f51dcec 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -401,9 +401,14 @@ jobs: packages/ui/src/lib/launch-errors.test.ts - name: Test first installation without system Node + timeout-minutes: 10 run: node scripts/test-opencode-install-native.mjs + - name: Test migration failure guards + run: node --test scripts/native-fixture-guards.test.mjs + - name: Certify native history migration at the minimum and latest stable + timeout-minutes: 20 shell: pwsh run: | $platform = node -p "process.platform === 'win32' ? 'windows' : process.platform" diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index b61edc2d3..3356e9122 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -22,7 +22,7 @@ export const OpenCodeUpdateCard: Component = () => {

{data().binaryPath}

{t("settings.opencode.setup.wsl")}

-

{t("settings.opencode.setup.restartRequired")}

+

{t("settings.opencode.setup.restartRequired")}

{t("settings.opencode.setup.serviceError")}

{t("settings.opencode.update.checkFailed")}

@@ -33,7 +33,7 @@ export const OpenCodeUpdateCard: Component = () => { {busy() ? t("settings.opencode.update.updating") : data().state === "missing" ? t("settings.opencode.setup.install") : t("settings.opencode.update.action", { version: data().latestVersion ?? "" })} - + diff --git a/scripts/native-fixture-guards.mjs b/scripts/native-fixture-guards.mjs new file mode 100644 index 000000000..fc8a45b2d --- /dev/null +++ b/scripts/native-fixture-guards.mjs @@ -0,0 +1,30 @@ +// Guards for isolated fixture-owned children and native acceptance pagination. +async function completesWithin(promise, timeoutMs) { + let timer + try { + return await Promise.race([promise.then(() => true), new Promise(resolve => { + timer = setTimeout(() => resolve(false), timeoutMs) + })]) + } finally { clearTimeout(timer) } +} + +export async function stopFixtureChild(child, stopped, graceMs = 1_000, forceMs = 2_000) { + child.kill() + if (await completesWithin(stopped, graceMs)) return + child.kill("SIGKILL") + if (await completesWithin(stopped, forceMs)) return + // A failed OS kill must report failure rather than retaining test handles. + child.stdout?.destroy() + child.stderr?.destroy() + child.stdin?.destroy() + child.unref() + throw new Error(`Fixture-owned child ${child.pid} did not exit after forced termination`) +} + +export function fixturePaginationGuard() { + const seen = new Set() + return cursor => { + if (seen.has(cursor)) throw new Error("Native fixture pagination repeated a nonterminal cursor") + seen.add(cursor) + } +} diff --git a/scripts/native-fixture-guards.test.mjs b/scripts/native-fixture-guards.test.mjs new file mode 100644 index 000000000..678740ee2 --- /dev/null +++ b/scripts/native-fixture-guards.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { fixturePaginationGuard, stopFixtureChild } from "./native-fixture-guards.mjs" + +test("fixture cleanup escalates a stuck owned child and returns a bounded failure", async () => { + const signals = [] + let released = false + const child = { pid: 123, kill: signal => signals.push(signal ?? "SIGTERM"), unref: () => { released = true } } + await assert.rejects(stopFixtureChild(child, new Promise(() => {}), 5, 5), /forced termination/) + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]) + assert.equal(released, true) +}) + +test("fixture cleanup accepts an owned child closing after forced termination", async () => { + let closed + const stopped = new Promise(resolve => { closed = resolve }) + const signals = [] + const child = { kill: signal => { signals.push(signal ?? "SIGTERM"); if (signal === "SIGKILL") closed() } } + await stopFixtureChild(child, stopped, 5, 5) + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]) +}) + +test("native pagination rejects cycles instead of looping indefinitely", () => { + const accept = fixturePaginationGuard() + accept("one"); accept("two") + assert.throws(() => accept("one"), /repeated a nonterminal cursor/) +}) diff --git a/scripts/test-opencode-history-migration.mjs b/scripts/test-opencode-history-migration.mjs index 9c1a59795..829b89208 100644 --- a/scripts/test-opencode-history-migration.mjs +++ b/scripts/test-opencode-history-migration.mjs @@ -8,6 +8,11 @@ import path from "node:path" import os from "node:os" import { setTimeout as delay } from "node:timers/promises" import { tsImport } from "tsx/esm/api" +import { OpenCode } from "@opencode/client" +import { Service } from "@opencode/client/service" +import { fixturePaginationGuard, stopFixtureChild } from "./native-fixture-guards.mjs" +const deadlineAt = Date.now() + 180_000 +const signal = AbortSignal.timeout(180_000) 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") @@ -15,7 +20,7 @@ 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" }) +const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 15_000 }) 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) @@ -31,28 +36,36 @@ const providerConfig = JSON.stringify({ providers: { openai: { settings: { apiKe const configFile = path.join(env.OPENCODE_CONFIG_DIR, "opencode.json") await writeFile(configFile, providerConfig) const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` -const versions = [oldCli, newCli].map(cli => execFileSync(cli, ["--version"], { encoding: "utf8" }).trim()) +const versions = [oldCli, newCli].map(cli => execFileSync(cli, ["--version"], { encoding: "utf8", timeout: 15_000 }).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) { + signal.throwIfAborted() 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)) + let spawnError + child.once("error", error => { spawnError = error }) 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) } + const close = async () => { + try { await stopFixtureChild(child, stopped) } + finally { await writeFile(path.join(root, `${name}.log`), output) } + } try { const deadline = Date.now() + 30_000 while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) { + signal.throwIfAborted() + if (spawnError) throw spawnError 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", headers = {}) => { const response = await fetch(`${baseUrl}${route}`, { method, headers: { authorization, "content-type": "application/json", ...headers }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000) }) + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.any([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 @@ -142,13 +155,18 @@ try { } finally { await control.close() } await writeFile(path.join(root, "forms-restart-control.json"), JSON.stringify(forms)) const target = await start(newCli, migratedDb, "target") +let shared 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 }) + shared = new OpenCodeSharedService({ headers: Service.headers, makeClient: options => OpenCode.make({ ...options, + fetch: (input, init) => options.fetch(input, { ...init, + signal: AbortSignal.any([signal, AbortSignal.timeout(15_000), ...(init?.signal ? [init.signal] : [])]), + }), + }) }) + const client = await shared.client({ kind: "lifecycle", identity: "migration-fixture", lifecycle }, { deadlineAt }) const inbox = await client.session.inbox.list({ sessionID: pendingSession }) assert.equal(inbox.length, 1) assert.equal(inbox[0].payload.text, "pending migration fixture") @@ -183,21 +201,30 @@ try { 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) } + const acceptCursor = fixturePaginationGuard() + while (page.cursor.next) { + acceptCursor(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) + const acceptCursor = fixturePaginationGuard() while (page.cursor.next) { + acceptCursor(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() } +} finally { + try { 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}`) From 4e564809f5b4693c11394ebc97909ec45d1db719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 17:12:45 +0200 Subject: [PATCH 06/14] fix(opencode): canonicalize WSL plugin provisioning paths Resolve the authenticated daemon's discovery root through the selected distro and translate its canonical path for Windows filesystem access. Mounted drives and Linux aliases now use accessible host paths while plugin import URLs and presence paths remain Linux-native. Preserve existing outside-root storage and migrate canonically inside-root storage without modifying older backend leases. Carry the surviving installation claim's ownership fence through asynchronous translation, validate outputs under one deadline, and encode POSIX file URLs without corrupting literal percent sequences. Native, mounted and aliased WSL fixtures verified provisioning, heartbeat stability, explicit reload, restart and reconnect. Nineteen path/installation/lifecycle regressions pass; independent gatekeeper re-review reports no remaining path-handling findings. Native watcher limitations require separate explicit configuration reload recovery. --- .../desktop-plugin-installation.test.ts | 138 ++++++++++++++++++ .../opencode/desktop-plugin-installation.ts | 22 +-- .../src/opencode/desktop-plugin-paths.test.ts | 79 +++++++++- .../src/opencode/desktop-plugin-paths.ts | 24 ++- .../src/opencode/desktop-plugin-wsl-paths.ts | 69 +++++++++ 5 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 packages/server/src/opencode/desktop-plugin-installation.test.ts create mode 100644 packages/server/src/opencode/desktop-plugin-wsl-paths.ts diff --git a/packages/server/src/opencode/desktop-plugin-installation.test.ts b/packages/server/src/opencode/desktop-plugin-installation.test.ts new file mode 100644 index 000000000..8bd93dc4b --- /dev/null +++ b/packages/server/src/opencode/desktop-plugin-installation.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { installDesktopPluginPresence } from "./desktop-plugin-installation" +import { DesktopPluginLifecycle } from "./desktop-plugin-lifecycle" +import { resolveDesktopPluginPaths } from "./desktop-plugin-paths" + +test("WSL preserves existing outside-root native storage with a drive-backed config", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-storage-")) + const config = path.join(root, "config"), oldData = path.join(root, "old-data"), data = path.join(root, "new-data") + const disposals: Array<() => Promise> = [] + t.after(async () => { await Promise.all(disposals.map(dispose => dispose())); await rm(root, { recursive: true, force: true }) }) + for (const feature of ["automation", "session-pruning"] as const) { + disposals.push(await installDesktopPluginPresence(feature, Buffer.from("old"), { + config, data: oldData, nativeData: "/linux/old-data", + })) + const entry = path.join(config, "plugins", `codenomad-${feature}.ts`) + const resolveNativePath = async (native: string) => { + assert.equal(native, `/linux/old-data/${feature}`) + return { native, host: path.join(oldData, feature) } + } + const paths = { config, data, nativeData: "/windows/new-data", resolveNativePath } + const latest = await installDesktopPluginPresence(feature, Buffer.from("new"), paths) + disposals.push(latest) + const source = await readFile(entry, "utf8") + assert.ok(source.includes(`file:///linux/old-data/${feature}/`)) + assert.ok(source.includes(JSON.stringify(`/linux/old-data/${feature}/presence`))) + assert.equal((await readdir(path.join(oldData, feature, "presence"))).length, 2) + await assert.rejects(readdir(data), { code: "ENOENT" }) + await latest() + assert.equal((await readdir(path.join(oldData, feature, "presence"))).length, 1) + } +}) + +test("WSL canonical storage inside config migrates without modifying older leases", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-migration-")) + const config = path.join(root, "config"), oldData = path.join(config, "watched"), data = path.join(root, "outside") + const disposals: Array<() => Promise> = [] + t.after(async () => { await Promise.all(disposals.map(dispose => dispose())); await rm(root, { recursive: true, force: true }) }) + for (const feature of ["automation", "session-pruning"] as const) { + // The old spelling appears outside config, but distro realpath resolves it + // into the watched directory. Host containment must use that resolved path. + disposals.push(await installDesktopPluginPresence(feature, Buffer.from("old"), { + config, data: oldData, nativeData: "/old-alias", + })) + const oldLeases = path.join(oldData, feature, "presence") + const original = await readdir(oldLeases) + const paths = { + config, data, nativeData: "/canonical/outside", + resolveNativePath: async (native: string) => native.startsWith("/old-alias/") + ? { native: `/canonical/config/watched/${feature}`, host: path.join(oldData, feature) } + : { native, host: path.join(data, feature) }, + } + disposals.push(await installDesktopPluginPresence(feature, Buffer.from("new"), paths)) + const entry = path.join(config, "plugins", `codenomad-${feature}.ts`) + const source = await readFile(entry, "utf8") + assert.ok(source.includes(JSON.stringify([`/canonical/outside/${feature}/presence`, `/old-alias/${feature}/presence`]))) + assert.deepEqual(await readdir(oldLeases), original) + assert.equal((await readdir(path.join(data, feature, "presence"))).length, 1) + disposals.push(await installDesktopPluginPresence(feature, Buffer.from("new"), paths)) + assert.equal(await readFile(entry, "utf8"), source, "Reopening preserves old readers without rewriting config") + assert.equal((await readdir(path.join(data, feature, "presence"))).length, 2) + assert.deepEqual(await readdir(oldLeases), original) + } +}) + +test("WSL storage translation completing on a stale connection cannot write", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-storage-fence-")) + const config = path.join(root, "config"), data = path.join(root, "data") + const stop = await installDesktopPluginPresence("automation", Buffer.from("old"), { config, data, nativeData: "/native/data" }) + t.after(async () => { await stop(); await rm(root, { recursive: true, force: true }) }) + const entry = path.join(config, "plugins", "codenomad-automation.ts") + const before = await readFile(entry, "utf8"), leases = await readdir(path.join(data, "automation", "presence")) + let current = true + await assert.rejects(installDesktopPluginPresence("automation", Buffer.from("new"), { + config, data, nativeData: "/native/data", + resolveNativePath: async native => { current = false; return { native, host: path.join(data, "automation") } }, + }, () => { if (!current) throw new Error("stale connection") }), /stale connection/) + assert.equal(await readFile(entry, "utf8"), before) + assert.deepEqual(await readdir(path.join(data, "automation", "presence")), leases) + assert.equal((await readdir(path.join(data, "automation"))).filter(name => name.endsWith(".mjs")).length, 1) +}) + +test("WSL file URLs preserve percent escapes and special characters when reopening entries", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-url-")) + const config = path.join(root, "config"), data = path.join(root, "data") + const nativeData = "/linux/literal%20name #日本語" + const paths = { config, data, nativeData, + resolveNativePath: async (native: string) => ({ native, host: path.join(data, "automation") }), + } + const disposals: Array<() => Promise> = [] + t.after(async () => { await Promise.all(disposals.map(dispose => dispose())); await rm(root, { recursive: true, force: true }) }) + disposals.push(await installDesktopPluginPresence("automation", Buffer.from("fixture"), paths)) + const entry = path.join(config, "plugins", "codenomad-automation.ts") + const source = await readFile(entry, "utf8") + const encodedUrl = JSON.parse(/from (".+")\n/.exec(source)![1]) + assert.equal(path.posix.dirname(decodeURIComponent(new URL(encodedUrl).pathname)), `${nativeData}/automation`) + assert.ok(encodedUrl.includes("literal%2520name")) + disposals.push(await installDesktopPluginPresence("automation", Buffer.from("fixture"), paths)) + assert.equal(await readFile(entry, "utf8"), source) +}) + +test("existing WSL storage resolution uses the surviving overlapping lifecycle claim", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-claims-")) + const config = path.join(root, "config"), data = path.join(root, "data") + let currentA = true + const assertA = () => { if (!currentA) throw new Error("stale A") } + const executor = async (_distro: string, command: string, args: string[]) => command === "realpath" + ? args.at(-1)! : `C:\\fixture${args.at(-1)!.replaceAll("/", "\\")}` + const makePaths = async (assertCurrent: () => void) => { + const resolved = await resolveDesktopPluginPaths({ assertCurrent, + client: { config: { get: async () => [{ type: "directory", path: "/native/config" }] } } as never, + }, { kind: "wsl", distro: "Ubuntu", binary: "/bin/opencode" }, Date.now() + 15_000, executor) + return { ...resolved, config, data, + resolveNativePath: async (native: string, fence: () => void) => ({ + ...await resolved.resolveNativePath!(native, fence), host: path.join(data, "automation"), + }), + } + } + const pathsA = await makePaths(assertA), pathsB = await makePaths(() => {}) + const seedStop = await installDesktopPluginPresence("automation", Buffer.from("old"), pathsA) + let releaseBundle!: () => void + const bundle = new Promise(resolve => { releaseBundle = resolve }) + const lifecycle = new DesktopPluginLifecycle("automation", async () => { await bundle; return Buffer.from("new") }) + t.after(async () => { releaseBundle(); await lifecycle.stop(); await seedStop(); await rm(root, { recursive: true, force: true }) }) + const first = lifecycle.prepare(pathsA, assertA) + const firstRejected = assert.rejects(first, /stale A/) + const second = lifecycle.prepare(pathsB, () => {}) + currentA = false + releaseBundle() + await firstRejected + const prepared = await second + prepared.commit() + await prepared.release() + assert.equal((await readdir(path.join(data, "automation", "presence"))).length, 2) +}) diff --git a/packages/server/src/opencode/desktop-plugin-installation.ts b/packages/server/src/opencode/desktop-plugin-installation.ts index cb76102b7..4a0e33285 100644 --- a/packages/server/src/opencode/desktop-plugin-installation.ts +++ b/packages/server/src/opencode/desktop-plugin-installation.ts @@ -4,18 +4,20 @@ import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { PRESENCE_INTERVAL_MS } from "./desktop-plugin-presence" import { isLegacyAutomationPlugin } from "./automation-plugin" +import { assertNativePluginPath, type DesktopPluginNativePath } from "./desktop-plugin-wsl-paths" export interface DesktopPluginPaths { config: string data: string - // WSL uses Linux paths in the entry, UNC paths only for host filesystem I/O. + // WSL entries use Linux paths; filesystem I/O uses translated drive/UNC paths. nativeData?: string + resolveNativePath?: (directory: string, assertCurrent: () => void) => Promise } export type DesktopPluginFeature = "session-pruning" | "automation" // Only parse our exact generated entry shape. Older backend leases remain // readable during migration, but new heartbeats must leave the watched root. -function managedStorage(feature: DesktopPluginFeature, existing: string | undefined, marker: string, paths: DesktopPluginPaths) { +async function managedStorage(feature: DesktopPluginFeature, existing: string | undefined, marker: string, paths: DesktopPluginPaths, assertCurrent: () => void) { if (!existing?.startsWith(marker)) return undefined const match = /^import \{ desktopPlugin \} from ("(?:[^"\\]|\\.)*")\nexport default desktopPlugin\((.+)\)\n$/.exec(existing.slice(marker.length)) if (!match) throw new Error("Invalid managed CodeNomad plugin entry") @@ -39,9 +41,10 @@ function managedStorage(feature: DesktopPluginFeature, existing: string | undefi throw new Error("Invalid managed CodeNomad plugin storage") } if (!paths.nativeData) return { directory: nativeDirectory, nativeDirectory, presenceDirectories: presenceDirectories as string[] } - const uncRoot = /^(\\\\wsl(?:\.localhost|\$)\\[^\\]+)/i.exec(paths.config)?.[1] - if (!uncRoot || nativeDirectory.includes("\\")) throw new Error("Invalid managed WSL plugin storage") - return { directory: `${uncRoot}${nativeDirectory.replaceAll("/", "\\")}`, nativeDirectory, presenceDirectories: presenceDirectories as string[] } + for (const directory of presenceDirectories) assertNativePluginPath(directory) + if (!paths.resolveNativePath) throw new Error("Missing managed WSL plugin path resolver") + const resolved = await paths.resolveNativePath(nativeDirectory, assertCurrent) + return { directory: resolved.host, nativeDirectory, presenceDirectories: presenceDirectories as string[] } } function within(directory: string, root: string): boolean { @@ -68,16 +71,17 @@ export async function installDesktopPluginPresence( && !(feature === "automation" && isLegacyAutomationPlugin(existing))) { throw new Error(`Existing plugin entry is not managed by CodeNomad: ${entry}`) } - const storage = managedStorage(feature, existing, marker, paths) + const storage = await managedStorage(feature, existing, marker, paths, assertCurrent) + assertCurrent() const retainStorage = storage && !within(storage.directory, paths.config) const directory = retainStorage ? storage.directory : path.join(paths.data, feature) const nativeDirectory = retainStorage ? storage.nativeDirectory : (paths.nativeData ? path.posix.join(paths.nativeData, feature) : directory) const leases = path.join(directory, "presence") const nativeLeases = paths.nativeData ? path.posix.join(nativeDirectory, "presence") : leases const plugin = path.join(directory, `${hash}.mjs`) - const nativeUrl = new URL("file:///") - nativeUrl.pathname = `${nativeDirectory}/${hash}.mjs` - const pluginUrl = paths.nativeData ? nativeUrl.href : pathToFileURL(plugin).href + const pluginUrl = paths.nativeData + ? pathToFileURL(`${nativeDirectory}/${hash}.mjs`, { windows: false }).href + : pathToFileURL(plugin).href const presenceDirectories = [...new Set([nativeLeases, ...(storage?.presenceDirectories ?? [])])] const source = `${marker}import { desktopPlugin } from ${JSON.stringify(pluginUrl)}\nexport default desktopPlugin(${JSON.stringify(presenceDirectories.length === 1 ? nativeLeases : presenceDirectories)})\n` await mkdir(leases, { recursive: true }) diff --git a/packages/server/src/opencode/desktop-plugin-paths.test.ts b/packages/server/src/opencode/desktop-plugin-paths.test.ts index 64d2faa51..8f8a8f070 100644 --- a/packages/server/src/opencode/desktop-plugin-paths.test.ts +++ b/packages/server/src/opencode/desktop-plugin-paths.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test" import path from "node:path" import type { ServiceConnection } from "../workspaces/opencode-service" import { resolveDesktopPluginPaths } from "./desktop-plugin-paths" +import type { WslPathExecutor } from "./desktop-plugin-wsl-paths" function connection(entries: unknown, assertCurrent = () => {}) { return { @@ -29,13 +30,89 @@ test("uses the connected host's first discovery directory, not a document or pro }) test("WSL resolves the connected daemon root into its selected distro, without a shell environment", async () => { + const calls: unknown[] = [] + const execute: WslPathExecutor = async (distro, command, args, timeout) => { + calls.push({ distro, command, args }) + assert.ok(timeout > 0 && timeout <= 15_000) + const directory = args.at(-1)! + return command === "realpath" ? `${directory}\n` : `\\\\wsl.localhost\\${distro}${directory.replaceAll("/", "\\")}\n` + } const paths = await resolveDesktopPluginPaths(connection([ { type: "directory", path: "/srv/existing daemon/config" }, { type: "directory", path: "/home/new-user/project/.opencode" }, - ]), { kind: "wsl", distro: "Ubuntu-24.04", binary: "/usr/bin/opencode" }) + ]), { kind: "wsl", distro: "Ubuntu-24.04", binary: "/usr/bin/opencode" }, Date.now() + 15_000, execute) assert.equal(paths.config, "\\\\wsl.localhost\\Ubuntu-24.04\\srv\\existing daemon\\config") assert.equal(path.posix.dirname(paths.nativeData!), "/srv/existing daemon/.codenomad") assert.equal(paths.data, `\\\\wsl.localhost\\Ubuntu-24.04${paths.nativeData!.replaceAll("/", "\\")}`) + assert.deepEqual(calls.slice(0, 2), [ + { distro: "Ubuntu-24.04", command: "realpath", args: ["-m", "--", "/srv/existing daemon/config"] }, + { distro: "Ubuntu-24.04", command: "wslpath", args: ["-aw", "/srv/existing daemon/config"] }, + ]) +}) + +test("WSL aliases and mounted roots share canonical storage and use actual mount translation", async () => { + const launch = { kind: "wsl", distro: "Ubuntu", binary: "/bin/opencode" } as const + const execute: WslPathExecutor = async (_distro, command, args) => { + const directory = args.at(-1)!.replace("/home/test/config-alias", "/windows-drive/settings") + return command === "realpath" ? `${directory}\n` : `${directory.replace("/windows-drive", "Q:").replaceAll("/", "\\")}\n` + } + const resolve = (directory: string) => resolveDesktopPluginPaths(connection([{ type: "directory", path: directory }]), launch, Date.now() + 15_000, execute) + const alias = await resolve("/home/test/config-alias"), mounted = await resolve("/windows-drive/settings") + assert.equal(alias.config, "Q:\\settings") + assert.equal(alias.data, mounted.data) + assert.equal(alias.nativeData, mounted.nativeData) + assert.equal(path.posix.dirname(alias.nativeData!), "/windows-drive/.codenomad") +}) + +test("WSL translation is fenced after each async command", async () => { + const launch = { kind: "wsl", distro: "Ubuntu", binary: "/bin/opencode" } as const + for (const staleAt of [1, 2, 3, 4]) { + let calls = 0, current = true + const execute: WslPathExecutor = async (_distro, command, args) => { + if (++calls === staleAt) current = false + return command === "realpath" ? args.at(-1)! : "C:\\fixture" + } + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }], () => { + if (!current) throw new Error("superseded") + }), launch, Date.now() + 15_000, execute), /superseded/) + assert.equal(calls, staleAt) + } + let calls = 0 + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), launch, Date.now() - 1, async () => { + calls++; return "/config" + }), /timed out/) + assert.equal(calls, 0) +}) + +test("WSL canonicalization and translation share the original deadline", async (t) => { + let now = 1000 + t.mock.method(Date, "now", () => now) + const timeouts: number[] = [] + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), + { kind: "wsl", distro: "Ubuntu", binary: "/bin/opencode" }, now + 100, + async (_distro, command, args, timeout) => { + timeouts.push(timeout) + now += 30 + return command === "realpath" ? args.at(-1)! : "C:\\config" + }), /timed out/) + assert.deepEqual(timeouts, [100, 70, 40, 10]) +}) + +test("WSL rejects malformed translation output and canonical storage inside config", async () => { + const launch = { kind: "wsl", distro: "Ubuntu", binary: "/bin/opencode" } as const + for (const output of ["relative", "/config\n/other", "/bad\u0000path", "//ambiguous", "/back\\slash"]) { + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), launch, + Date.now() + 15_000, async () => output), /Invalid/) + } + for (const output of ["C:relative", "\\root-relative", "\\\\?\\C:\\device", "C:\\path\nC:\\second", "C:\\trailing ", "C:\\dir.\\config", "C:\\file:stream"]) { + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), launch, + Date.now() + 15_000, async (_distro, command) => command === "realpath" ? "/config" : output), /Invalid/) + } + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), launch, + Date.now() + 15_000, async (_distro, command) => command === "realpath" ? "/config" : "\\\\wsl.localhost\\Other\\config"), /another distro/) + await assert.rejects(resolveDesktopPluginPaths(connection([{ type: "directory", path: "/config" }]), launch, + Date.now() + 15_000, async (_distro, command, args) => command === "wslpath" ? "C:\\config" + : args.at(-1)!.includes(".codenomad") ? "/config/storage" : "/config"), /outside the discovery root/) }) test("POSIX hosts preserve daemon-native paths", async () => { diff --git a/packages/server/src/opencode/desktop-plugin-paths.ts b/packages/server/src/opencode/desktop-plugin-paths.ts index 06045a0af..0972e2b35 100644 --- a/packages/server/src/opencode/desktop-plugin-paths.ts +++ b/packages/server/src/opencode/desktop-plugin-paths.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto" import type { ServiceConnection } from "../workspaces/opencode-service" import type { ServiceLaunchSpec } from "../workspaces/spawn" import type { DesktopPluginPaths } from "./desktop-plugin-installation" +import { assertNativePluginPath, resolveDesktopPluginWslPath, type WslPathExecutor } from "./desktop-plugin-wsl-paths" // Native config.get returns discovery sources in precedence order. The first // directory is the global discovery root, including OPENCODE_CONFIG_DIR when @@ -11,11 +12,14 @@ export async function resolveDesktopPluginPaths( connection: Pick, launch: ServiceLaunchSpec, deadlineAt = Date.now() + 15_000, + executeWslPath?: WslPathExecutor, ): Promise { + if (deadlineAt <= Date.now()) throw new Error("Desktop plugin path resolution timed out") const entries: unknown = await connection.client.config.get(undefined, { signal: AbortSignal.timeout(Math.max(1, deadlineAt - Date.now())), }) connection.assertCurrent() + if (deadlineAt <= Date.now()) throw new Error("Desktop plugin path resolution timed out") const directory = Array.isArray(entries) ? entries.find(entry => entry?.type === "directory")?.path : undefined @@ -23,7 +27,15 @@ export async function resolveDesktopPluginPaths( if (typeof directory !== "string" || !paths.isAbsolute(directory) || directory.includes("\0")) { throw new Error("Connected OpenCode daemon did not report an absolute global plugin discovery directory") } - const config = paths.normalize(directory) + if (launch.kind === "wsl") assertNativePluginPath(directory) + let config = paths.normalize(directory) + if (paths.dirname(config) === config) throw new Error("OpenCode discovery root has no unwatched parent") + const resolveNativePath = launch.kind === "wsl" + ? (directory: string, assertCurrent: () => void) => resolveDesktopPluginWslPath(directory, launch.distro, deadlineAt, assertCurrent, executeWslPath) + : undefined + const resolvedConfig = await resolveNativePath?.(config, connection.assertCurrent) + connection.assertCurrent() + if (resolvedConfig) config = resolvedConfig.native // OpenCode recursively watches the entire discovery root, not just plugins/. // A lease heartbeat inside it reloads configuration in every loaded location. // Derive a sibling namespace from the authenticated root, never our process's @@ -33,9 +45,11 @@ export async function resolveDesktopPluginPaths( const namespace = createHash("sha256").update(identity).digest("hex") const data = paths.join(paths.dirname(config), ".codenomad", namespace) if (launch.kind !== "wsl") return { config, data } - if (config.includes("\\") || config.startsWith("//")) { - throw new Error("Connected WSL daemon reported a path that cannot be accessed through its distro") + const resolvedData = await resolveNativePath!(data, connection.assertCurrent) + connection.assertCurrent() + const relative = path.posix.relative(config, resolvedData.native) + if (!relative || (!relative.startsWith("../") && relative !== ".." && !path.posix.isAbsolute(relative))) { + throw new Error("OpenCode plugin storage must stay outside the discovery root") } - const unc = (directory: string) => `\\\\wsl.localhost\\${launch.distro}${directory.replaceAll("/", "\\")}` - return { config: unc(config), data: unc(data), nativeData: data } + return { config: resolvedConfig!.host, data: resolvedData.host, nativeData: resolvedData.native, resolveNativePath } } diff --git a/packages/server/src/opencode/desktop-plugin-wsl-paths.ts b/packages/server/src/opencode/desktop-plugin-wsl-paths.ts new file mode 100644 index 000000000..ea78be0a5 --- /dev/null +++ b/packages/server/src/opencode/desktop-plugin-wsl-paths.ts @@ -0,0 +1,69 @@ +import { execFile } from "node:child_process" +import path from "node:path" +import { resolveWslHostDirectory } from "../workspaces/spawn" + +export interface DesktopPluginNativePath { native: string; host: string } +export type WslPathExecutor = (distro: string, command: string, args: string[], timeoutMs: number) => Promise + +function remaining(deadlineAt: number): number { + const timeout = deadlineAt - Date.now() + if (timeout <= 0) throw new Error("WSL desktop plugin path resolution timed out") + return timeout +} + +function line(output: string): string { + const value = output.replace(/\r?\n$/, "") + if (!value || /[\x00-\x1f\x7f]/.test(value)) throw new Error("Invalid WSL desktop plugin path output") + return value +} + +export function assertNativePluginPath(directory: string): void { + if (!path.posix.isAbsolute(directory) || directory.startsWith("//") || /[\\\x00-\x1f\x7f]/.test(directory)) { + throw new Error("Invalid native WSL desktop plugin path") + } +} + +// Resolve existing symlink ancestors, including when the storage tail does not +// exist yet. wslpath knows the distro's mount layout; /mnt/ is not assumed. +export async function resolveDesktopPluginWslPath( + directory: string, + distro: string, + deadlineAt: number, + assertCurrent: () => void, + execute: WslPathExecutor = executePath, +): Promise { + assertNativePluginPath(directory) + assertCurrent() + const native = line(await execute(distro, "realpath", ["-m", "--", directory], remaining(deadlineAt))) + assertCurrent() + assertNativePluginPath(native) + const host = await resolveWslHostDirectory(native, distro, async (folder, selectedDistro, timeout) => { + const translated = line(await execute(selectedDistro, "wslpath", ["-aw", folder], timeout)) + // Disallow root-relative/drive-relative/device paths and lossy whitespace + // trimming by the shared conversion utility. UNC mounts remain supported. + if (translated !== translated.trim() || /[<>"|?*]/.test(translated) + || !(/^[A-Za-z]:\\/.test(translated) || /^\\\\(?![?.]\\)[^\\:]+\\[^\\]+(?:\\|$)/.test(translated)) + || translated.slice(/^[A-Za-z]:/.test(translated) ? 2 : 0).includes(":") + || translated.split("\\").some(part => /[ .]$/.test(part))) { + throw new Error("Invalid Windows desktop plugin path from WSL") + } + const wslShare = /^\\\\wsl(?:\.localhost|\$)\\([^\\]+)/i.exec(translated) + if (wslShare && wslShare[1].toLowerCase() !== distro.toLowerCase()) throw new Error("WSL desktop plugin path belongs to another distro") + return translated + }, remaining(deadlineAt)) + assertCurrent() + remaining(deadlineAt) + if (!host) throw new Error("Cannot translate WSL desktop plugin path for Windows access") + return { native: path.posix.normalize(native), host: path.win32.normalize(host) } +} + +function executePath(distro: string, command: string, args: string[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile("wsl.exe", ["--distribution", distro, "--exec", command, ...args], { + encoding: "utf8", windowsHide: true, timeout: timeoutMs, maxBuffer: 64 * 1024, + }, (error, stdout) => { + if (error) reject(new Error(`WSL desktop plugin ${command} failed`)) + else resolve(stdout) + }) + }) +} From 550e0a4311046b7350db4cf024545eb045dbc45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 17:18:05 +0200 Subject: [PATCH 07/14] feat(opencode): add explicit configuration reload recovery Expose informed native configuration reload in the existing setup and preferences card for installed plugins that remain unavailable on mounted or aliased discovery roots. All ten locales explain that this rebuilds every loaded location, cancels pending permissions and Forms, and closes terminals/background commands. Reads, installation and ordinary connect never invoke reload. Admit the authenticated daemon, provision through current connection authority, and recheck executable/host selection immediately before the bounded native reload. Serialize daemon-wide actions by updater/shared-service authority rather than mutable executable path; coalesce only the same action and captured selection. Add strict exclusive action-route tests, real manager reconnect/reload coverage, cross-selection in-flight races and browser explicit-intent regression. Gatekeeper re-review reports no remaining recovery findings. Final server suite passes 616 tests with two skips; six browser regressions and server/UI typechecks pass. --- packages/server/src/api-types.ts | 1 + .../src/opencode-update/service.test.ts | 59 +++++++++++++++++++ .../server/src/opencode-update/service.ts | 48 ++++++++++++--- .../opencode-update/setup-reconnect.test.ts | 9 ++- .../src/server/routes/opencode-update.test.ts | 15 +++-- .../src/server/routes/opencode-update.ts | 5 +- packages/server/src/workspaces/manager.ts | 11 ++++ .../settings/opencode-update-card.tsx | 4 ++ packages/ui/src/lib/api-client.ts | 3 + .../ui/src/lib/i18n/messages/de/settings.ts | 2 + .../ui/src/lib/i18n/messages/en/settings.ts | 2 + .../ui/src/lib/i18n/messages/es/settings.ts | 2 + .../ui/src/lib/i18n/messages/fr/settings.ts | 2 + .../ui/src/lib/i18n/messages/he/settings.ts | 2 + .../ui/src/lib/i18n/messages/ja/settings.ts | 2 + .../ui/src/lib/i18n/messages/ne/settings.ts | 2 + .../ui/src/lib/i18n/messages/ru/settings.ts | 2 + .../ui/src/lib/i18n/messages/tr/settings.ts | 2 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 + packages/ui/src/stores/opencode-setup.ts | 4 +- .../ui/tests/browser/opencode-setup.test.ts | 25 ++++++++ 21 files changed, 184 insertions(+), 20 deletions(-) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 3eedce2bb..c29c29668 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -383,6 +383,7 @@ export interface OpenCodeUpdateStatus { binaryPath: string daemonVersion?: string serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "error" + canReload?: boolean serviceError?: string target: "host" | "wsl" canRestart: boolean diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index e3442be88..c443742cf 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -189,6 +189,65 @@ test("optional upgrades retain explicit activation for an admitted but older dae assert.equal(restarts, 1) }) +test("explicit configuration reload is admitted, fenced and serialized with service actions", async () => { + let selected = "/fixture/opencode", version = "2.0.11", reloads = 0 + let release!: () => void + let entered!: () => void + const gate = new Promise(resolve => { release = resolve }) + const reached = new Promise(resolve => { entered = resolve }) + const service = new OpenCodeUpdateService(deps({ + resolveBinary: () => ({ path: selected, label: "Fixture" }), + probeBinary: () => ({ valid: true, version: "2.0.11" }), + lifecycle: async () => ({ discover: async () => { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version, pid: 123, discovery: "info" }) + return endpoint + }, ensure: async () => { throw new Error("reload must not start a daemon") } }), + reload: async (_binary, assertCurrent) => { entered(); await gate; assertCurrent(); reloads++ }, + })) + assert.equal((await service.getStatus()).canReload, true) + assert.equal(reloads, 0) + const first = service.reload() + assert.equal(service.reload(), first) + await reached + await assert.rejects(service.start(), /action is in progress/) + selected = "/replacement/opencode" + await assert.rejects(service.start(true), /action is in progress/, "changing executables cannot overlap the shared-daemon reload") + await assert.rejects(service.reload(), /action is in progress/, "a different selection must not coalesce with the captured one") + release() + await assert.rejects(first, /selection changed/) + assert.equal(reloads, 0) + version = "2.0.10" + await assert.rejects(service.reload(), /opencode_update_required/) + assert.equal(reloads, 0) +}) + +test("an in-flight native reload holds shared-service authority across binary changes", async () => { + let selected = "/fixture/a/opencode", restarts = 0 + let entered!: () => void, release!: () => void + const reached = new Promise(resolve => { entered = resolve }) + const gate = new Promise(resolve => { release = resolve }) + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: "2.0.11", pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + resolveBinary: () => ({ path: selected, label: "Fixture" }), + probeBinary: () => ({ valid: true, version: "2.0.12" }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => endpoint, + restart: async () => { restarts++; return endpoint } }), + reload: async (_binary, assertCurrent) => { assertCurrent(); entered(); await gate }, + })) + const first = service.reload() + await reached + selected = "/fixture/b/opencode" + try { + await assert.rejects(service.start(true), /action is in progress/) + await assert.rejects(service.reload(), /action is in progress/) + assert.equal(restarts, 0) + } finally { release() } + await assert.rejects(first, /selection changed/) + await service.reload() +}) + 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}`] }) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 3794f43bd..843d6e984 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -17,7 +17,7 @@ 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>() -const inFlightActivations = new Map>() +type ServiceAction = "start" | "restart" | "reload" type UpgradeResult = { success: true; version: string } | { success: false; error: string } @@ -30,6 +30,7 @@ export interface OpenCodeUpdateServiceDeps { lifecycle?: (binary: ResolvedBinary) => Promise reconnect?: (binary: ResolvedBinary) => Promise admitActivation?: (binary: ResolvedBinary) => void + reload?: (binary: ResolvedBinary, assertCurrent: () => void) => Promise } export class OpenCodeUpdateError extends Error { @@ -48,6 +49,8 @@ export class OpenCodeUpdateError extends Error { } export class OpenCodeUpdateService { + private activation?: { binaryPath: string; action: ServiceAction; pending: Promise } + constructor(private readonly deps: OpenCodeUpdateServiceDeps) {} async getStatus(): Promise { @@ -83,23 +86,36 @@ export class OpenCodeUpdateService { : identity && supportsOpenCodeVersion(identity.version) ? "ready" : "error" status.canRestart = Boolean(lifecycle.restart) && state === "ready" && (status.serviceState === "restart_required" || status.serviceState === "restart_available") + status.canReload = Boolean(this.deps.reload) && state === "ready" && Boolean(identity && supportsOpenCodeVersion(identity.version)) } catch { status.serviceState = "error"; status.serviceError = "service_check_failed" } } return status } start(restart = false): Promise { + return this.runServiceAction(restart ? "restart" : "start") + } + + reload(): Promise { + return this.runServiceAction("reload") + } + + private runServiceAction(action: ServiceAction): Promise { 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) + // One updater owns one manager/shared-service authority. Executable selection + // is mutable and cannot be the lock key for daemon-wide operations. + const existing = this.activation + if (existing) return existing.action === action && existing.binaryPath === binary.path + ? existing.pending : Promise.reject(new Error("Another OpenCode service action is in progress")) + const pending = this.activate(binary, action).finally(() => { + if (this.activation?.pending === pending) this.activation = undefined }) - inFlightActivations.set(binary.path, pending) + this.activation = { binaryPath: binary.path, action, pending } return pending } - private async activate(binary: ResolvedBinary, restart: boolean): Promise { + private async activate(binary: ResolvedBinary, action: ServiceAction): Promise { + const restart = action === "restart" const installedVersion = await this.readCurrentVersion(binary.path) assertSupportedOpenCode(installedVersion) const lifecycle = await this.deps.lifecycle?.(binary) @@ -112,8 +128,21 @@ export class OpenCodeUpdateService { throw new Error("The shared daemon is not an older runtime eligible for this update") } } - if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation") - this.deps.admitActivation?.(binary) + const assertCurrent = () => { + if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation") + this.deps.admitActivation?.(binary) + } + assertCurrent() + if (action === "reload") { + const identity = previous && runtimeIdentity(previous) + if (!identity || !this.deps.reload) throw new Error("OpenCode configuration reload unavailable") + assertSupportedOpenCode(identity.version) + // Explicit daemon-wide mutation: never use this as an automatic watcher + // fallback, because native reload cancels pending Forms and permissions. + await this.deps.reload(binary, assertCurrent) + assertCurrent() + return this.getStatus() + } 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") @@ -326,5 +355,6 @@ export function createOpenCodeUpdateService( lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle), reconnect: binary => workspaceManager.reconnectAfterSetup(binary.path), admitActivation: binary => workspaceManager.assertSetupExecutionHost(binary.path), + reload: (binary, assertCurrent) => workspaceManager.reloadConfigurationAfterSetup(binary.path, assertCurrent), }) } diff --git a/packages/server/src/opencode-update/setup-reconnect.test.ts b/packages/server/src/opencode-update/setup-reconnect.test.ts index 9099be326..fcdea513d 100644 --- a/packages/server/src/opencode-update/setup-reconnect.test.ts +++ b/packages/server/src/opencode-update/setup-reconnect.test.ts @@ -21,6 +21,7 @@ test("optional setup rebinds real workspace ownership without restarting the sup let selected = originalBinary let starts = 0 let restarts = 0 + let reloads = 0 const endpoint: Endpoint = { url: "http://127.0.0.1:1" } rememberRuntime(endpoint, { version: "2.0.11", pid: 123, discovery: "info" }) const lifecycle: OpenCodeServiceLifecycle = { @@ -30,7 +31,7 @@ test("optional setup rebinds real workspace ownership without restarting the sup } const validatedDirectories: string[] = [] const client = { - location: { get: async ({ location }: { location: { directory: string } }) => { + location: { reload: async () => { reloads++ }, get: async ({ location }: { location: { directory: string } }) => { validatedDirectories.push(location.directory) return { directory: location.directory, project: { id: "fixture", directory: location.directory, canonical: location.directory } } @@ -61,6 +62,7 @@ test("optional setup rebinds real workspace ownership without restarting the sup lifecycle: async binary => (await manager.setupServiceOptions(binary.path)).lifecycle, admitActivation: binary => manager.assertSetupExecutionHost(binary.path), reconnect: binary => manager.reconnectAfterSetup(binary.path), + reload: (binary, assertCurrent) => manager.reloadConfigurationAfterSetup(binary.path, assertCurrent), }) try { @@ -90,6 +92,11 @@ test("optional setup rebinds real workspace ownership without restarting the sup assert.ok(validatedDirectories.includes(nextFolder)) assert.equal(starts, 0) assert.equal(restarts, 0) + assert.equal(reloads, 0, "ordinary setup must never rebuild shared native locations") + await updater.reload() + assert.equal(reloads, 1, "an explicit reload reaches the admitted native client") + assert.equal(starts, 0) + assert.equal(restarts, 0) } finally { try { await manager.shutdown() } finally { await rm(root, { recursive: true, force: true }) } diff --git a/packages/server/src/server/routes/opencode-update.test.ts b/packages/server/src/server/routes/opencode-update.test.ts index c40797034..595044d95 100644 --- a/packages/server/src/server/routes/opencode-update.test.ts +++ b/packages/server/src/server/routes/opencode-update.test.ts @@ -38,20 +38,23 @@ 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[] = [] +test("service activation accepts only exclusive explicit restart/reload intents", async () => { + const calls: Array = [] const app = Fastify() registerOpenCodeUpdateRoutes(app, { - service: { start: async (restart: boolean) => { calls.push(restart); return { state: "ready" } } } as OpenCodeUpdateService, + service: { + start: async (restart: boolean) => { calls.push(restart); return { state: "ready" } }, + reload: async () => { calls.push("reload"); return { state: "ready" } }, + } as OpenCodeUpdateService, logger: { warn() {} } as unknown as Logger, }) try { - for (const payload of [{ restart: "true" }, { restart: true, binary: "foreign" }, { command: "stop" }]) { + for (const payload of [{ restart: "true" }, { restart: true, binary: "foreign" }, { command: "stop" }, { reload: "true" }, { reload: true, restart: true }]) { assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 400) } - for (const payload of [{}, { restart: true }]) { + for (const payload of [{}, { restart: true }, { reload: true }]) { assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 200) } - assert.deepEqual(calls, [false, true]) + assert.deepEqual(calls, [false, true, "reload"]) } finally { await app.close() } }) diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts index 9f9ba1025..043db60eb 100644 --- a/packages/server/src/server/routes/opencode-update.ts +++ b/packages/server/src/server/routes/opencode-update.ts @@ -21,9 +21,10 @@ 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 ?? {}) + const parsed = z.object({ restart: z.boolean().default(false), reload: z.boolean().default(false) }).strict() + .refine(value => !(value.restart && value.reload)).safeParse(request.body ?? {}) if (!parsed.success) return reply.code(400).send({ error: "invalid_service_action" }) - try { return await deps.service.start(parsed.data.restart) } + try { return await (parsed.data.reload ? deps.service.reload() : 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" }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index e992cf684..0c75c189a 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -990,6 +990,17 @@ export class WorkspaceManager { this.serviceAuthorization = (await this.sharedService.headers(options))?.authorization } + async reloadConfigurationAfterSetup(binary: string, assertCurrent: () => void): Promise { + assertCurrent() + if (!this.sharedService.acquire) throw new Error("OpenCode connection acquisition unavailable") + await this.reconnectAfterSetup(binary) + const connection = await this.sharedService.acquire() + assertCurrent() + connection.assertCurrent() + await connection.client.location.reload({ signal: AbortSignal.timeout(30_000) }) + connection.assertCurrent() + } + private createWslServiceLifecycle( spec: Extract, timeoutMs: number, diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index 3356e9122..c235cb83d 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -38,6 +38,10 @@ export const OpenCodeUpdateCard: Component = () => { + +

{t("settings.opencode.setup.reloadDescription")}

+
+
}
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 4fcaa4e9a..d854d29fe 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -449,6 +449,9 @@ export const serverApi = { startOpenCode(restart = false): Promise { return request("/api/opencode/service", { method: "POST", body: JSON.stringify({ restart }) }) }, + reloadOpenCodeConfiguration(): Promise { + return request("/api/opencode/service", { method: "POST", body: JSON.stringify({ reload: true }) }) + }, fetchSpeechCapabilities(): Promise { return request("/api/speech/capabilities") }, diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 0a047bc85..da73c0241 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "OpenCode-Konfiguration neu laden", + "settings.opencode.setup.reloadDescription": "Wenn installierte Plugins nicht verfügbar sind, laden Sie die Konfiguration neu. Dabei werden alle geladenen Arbeitsorte für alle verbundenen Clients neu aufgebaut, ausstehende Berechtigungsanfragen und Formulare abgebrochen sowie Terminals und Hintergrundbefehle geschlossen. Aktive Arbeit kann unterbrochen werden.", "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.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 1317c5a24..342e8775d 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "Reload OpenCode configuration", + "settings.opencode.setup.reloadDescription": "If installed plugins remain unavailable, reload configuration. This rebuilds every loaded location for all connected clients, cancels pending permissions and forms, and closes terminals and background commands. Active work may be interrupted.", "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.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 6fbe2449a..8639e22af 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "Recargar la configuración de OpenCode", + "settings.opencode.setup.reloadDescription": "Si los plugins instalados siguen sin estar disponibles, recarga la configuración. Esto reconstruye todas las ubicaciones cargadas para todos los clientes conectados, cancela los permisos y formularios pendientes y cierra los terminales y comandos en segundo plano. El trabajo activo puede interrumpirse.", "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.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 73dfab2a7..baab32fde 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "Recharger la configuration OpenCode", + "settings.opencode.setup.reloadDescription": "Si les plugins installés restent indisponibles, rechargez la configuration. Cette action reconstruit tous les emplacements chargés pour tous les clients connectés, annule les demandes d’autorisation et formulaires en attente, et ferme les terminaux et commandes d’arrière-plan. Le travail actif peut être interrompu.", "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.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index f19aeebad..81a314cff 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "טעינה מחדש של תצורת OpenCode", + "settings.opencode.setup.reloadDescription": "אם התוספים המותקנים עדיין אינם זמינים, טען מחדש את התצורה. פעולה זו בונה מחדש את כל המיקומים הטעונים עבור כל הלקוחות המחוברים, מבטלת בקשות הרשאה וטפסים ממתינים וסוגרת מסופים ופקודות ברקע. עבודה פעילה עלולה להיקטע.", "settings.opencode.setup.description": "התקנה, עדכון וחיבור של OpenCode בשרת שמריץ את הפרויקטים שלך.", "settings.opencode.setup.missing": "OpenCode אינו מותקן.", "settings.opencode.setup.update_required": "גרסת OpenCode זו אינה נתמכת בגרסה זו של CodeNomad.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 4e36df91b..f73f11819 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "OpenCode の設定を再読み込み", + "settings.opencode.setup.reloadDescription": "インストール済みプラグインが利用できない場合は設定を再読み込みしてください。接続中のすべてのクライアントで読み込み済みの全ロケーションが再構築され、保留中の権限要求とフォームが取り消され、ターミナルとバックグラウンドコマンドが終了します。進行中の作業が中断される場合があります。", "settings.opencode.setup.description": "プロジェクトを実行するサーバーで OpenCode をインストール、更新、接続します。", "settings.opencode.setup.missing": "OpenCode がインストールされていません。", "settings.opencode.setup.update_required": "この OpenCode バージョンは、この CodeNomad リリースではサポートされていません。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 9e439cc67..55ccd17d9 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "OpenCode कन्फिगरेसन पुनः लोड गर्नुहोस्", + "settings.opencode.setup.reloadDescription": "स्थापित प्लगइनहरू अझै उपलब्ध छैनन् भने कन्फिगरेसन पुनः लोड गर्नुहोस्। यसले सबै जडित क्लाइन्टका लोड गरिएका सबै स्थानहरू पुनर्निर्माण गर्छ, विचाराधीन अनुमति अनुरोध तथा फारमहरू रद्द गर्छ र टर्मिनल तथा पृष्ठभूमि आदेशहरू बन्द गर्छ। सक्रिय काम अवरुद्ध हुन सक्छ।", "settings.opencode.setup.description": "तपाईंका परियोजनाहरू चलाउने सर्भरमा OpenCode स्थापना, अद्यावधिक र जडान गर्नुहोस्।", "settings.opencode.setup.missing": "OpenCode स्थापना गरिएको छैन।", "settings.opencode.setup.update_required": "यो OpenCode संस्करण यस CodeNomad संस्करणमा समर्थित छैन।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index a4300bda0..ad4c6a104 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "Перезагрузить конфигурацию OpenCode", + "settings.opencode.setup.reloadDescription": "Если установленные плагины недоступны, перезагрузите конфигурацию. Это пересоздаёт все загруженные расположения для всех подключённых клиентов, отменяет ожидающие запросы разрешений и формы, закрывает терминалы и фоновые команды. Активная работа может быть прервана.", "settings.opencode.setup.description": "Установите, обновите и подключите OpenCode на сервере, выполняющем ваши проекты.", "settings.opencode.setup.missing": "OpenCode не установлен.", "settings.opencode.setup.update_required": "Эта версия OpenCode не поддерживается текущим выпуском CodeNomad.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index 95b259442..cf74bc527 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "OpenCode yapılandırmasını yeniden yükle", + "settings.opencode.setup.reloadDescription": "Yüklü eklentiler hâlâ kullanılamıyorsa yapılandırmayı yeniden yükleyin. Bu işlem tüm bağlı istemciler için yüklenmiş tüm konumları yeniden oluşturur, bekleyen izin istekleriyle formları iptal eder ve terminallerle arka plan komutlarını kapatır. Etkin çalışma kesintiye uğrayabilir.", "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.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 4a109ebca..71f0b6e00 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -1,4 +1,6 @@ export const settingsMessages = { + "settings.opencode.setup.reload": "重新加载 OpenCode 配置", + "settings.opencode.setup.reloadDescription": "如果已安装的插件仍不可用,请重新加载配置。这会为所有已连接的客户端重建所有已加载的位置,取消待处理的权限请求和表单,并关闭终端和后台命令。正在进行的工作可能会中断。", "settings.opencode.setup.description": "在运行项目的服务器上安装、更新并连接 OpenCode。", "settings.opencode.setup.missing": "尚未安装 OpenCode。", "settings.opencode.setup.update_required": "此 CodeNomad 版本不支持当前 OpenCode 版本。", diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts index 37d4b79f4..ad85e5b82 100644 --- a/packages/ui/src/stores/opencode-setup.ts +++ b/packages/ui/src/stores/opencode-setup.ts @@ -50,7 +50,7 @@ export function refreshOpenCodeSetup(afterMutation = false): Promise { return request } -export async function runOpenCodeSetup(action: "install" | "start" | "restart") { +export async function runOpenCodeSetup(action: "install" | "start" | "restart" | "reload") { if (openCodeSetupBusy()) return const epoch = ++generation pending = undefined @@ -64,7 +64,7 @@ export async function runOpenCodeSetup(action: "install" | "start" | "restart") await refreshOpenCodeSetup(true) if (epoch !== generation) return if (action !== "restart" && openCodeSetupStatus()?.serviceState === "restart_required") return - const status = await serverApi.startOpenCode(action === "restart") + const status = action === "reload" ? await serverApi.reloadOpenCodeConfiguration() : await serverApi.startOpenCode(action === "restart") if (epoch !== generation) return setOpenCodeSetupStatus(status) if (status.state === "ready" && (status.serviceState === "ready" || status.serviceState === "restart_available")) { diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 78ab9577c..78eb4e7e0 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -167,3 +167,28 @@ test("optional install reconnects without restarting and leaves explicit activat assert.equal(restarts, 1) } finally { await page.close() } }) + +test("configuration reload is an informed explicit action and never runs on setup reads", async () => { + const page = await browser.newPage() + let reloads = 0 + await page.route("**/api/**", route => { + if (route.request().url().endsWith("/api/opencode/service")) { + assert.deepEqual(route.request().postDataJSON(), { reload: true }) + reloads++ + } + return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11", + minimumVersion: "2.0.11", binaryPath: "opencode2", target: "wsl", canUpgrade: false, + serviceState: "ready", canRestart: false, canReload: true, daemonVersion: "2.0.11" } }) + }) + 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.getByText(/cancels pending permissions and forms/).waitFor() + assert.equal(reloads, 0) + await page.getByRole("button", { name: "Reload OpenCode configuration" }).click() + await page.waitForFunction(() => (window as any).fixture.resumed() === 1) + assert.equal(reloads, 1) + assert.equal(await page.getByRole("dialog").count(), 0) + } finally { await page.close() } +}) From 91c6523f1fe8f6ccab051092f60dc20b5f0a3b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 17:21:31 +0200 Subject: [PATCH 08/14] test(opencode): certify bounded WSL setup and reload effects Exercise the real Windows-to-WSL service lifecycle and plugin provisioning through native, mounted and aliased discovery roots. Use guarded fixture-owned registrations, unique service ports and fully isolated environments; record actual watcher behavior, measured lease heartbeats, explicit reload, restart and cleanup. Characterize reload with a held synthetic provider stream and native pending Form, Shell and PTY. Verify one successful provider request without replay and record native resource teardown. Update all locale descriptions to explicitly include terminals and background commands. Bound the whole fixture, native requests, predicates and cleanup independently. Handle detached event-consumer failures immediately while preserving awaited failure reporting. Seven guard regressions and final real aliased reload-safety acceptance pass; autonomous gatekeeper re-review reports no remaining fixture findings. --- .github/workflows/pr-build.yml | 2 +- scripts/fixtures/wsl-fixture-bounds.mjs | 21 ++ scripts/fixtures/wsl-fixture-bounds.test.mjs | 25 ++ scripts/fixtures/wsl-reload-safety.mjs | 176 ++++++++++++ scripts/fixtures/wsl-reload-safety.test.mjs | 25 ++ scripts/test-opencode-setup-wsl.mjs | 278 +++++++++++++++++++ 6 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 scripts/fixtures/wsl-fixture-bounds.mjs create mode 100644 scripts/fixtures/wsl-fixture-bounds.test.mjs create mode 100644 scripts/fixtures/wsl-reload-safety.mjs create mode 100644 scripts/fixtures/wsl-reload-safety.test.mjs create mode 100644 scripts/test-opencode-setup-wsl.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 02f51dcec..294190d05 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -405,7 +405,7 @@ jobs: run: node scripts/test-opencode-install-native.mjs - name: Test migration failure guards - run: node --test scripts/native-fixture-guards.test.mjs + run: node --test scripts/native-fixture-guards.test.mjs "scripts/fixtures/wsl-*.test.mjs" - name: Certify native history migration at the minimum and latest stable timeout-minutes: 20 diff --git a/scripts/fixtures/wsl-fixture-bounds.mjs b/scripts/fixtures/wsl-fixture-bounds.mjs new file mode 100644 index 000000000..bdaab6ad2 --- /dev/null +++ b/scripts/fixtures/wsl-fixture-bounds.mjs @@ -0,0 +1,21 @@ +// A predicate's timeout must bound its pending request, not just its next poll. +// Cleanup supplies its own deadline, independent of an aborted fixture run. +export async function boundedFixtureOperation(operation, deadlineAt, label, signal) { + signal?.throwIfAborted() + const remaining = deadlineAt - Date.now() + if (remaining <= 0) throw new Error(`${label} timed out`) + let timer, onAbort + try { + return await Promise.race([ + Promise.resolve().then(() => { signal?.throwIfAborted(); return operation() }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), remaining) + onAbort = () => reject(signal.reason) + signal?.addEventListener("abort", onAbort, { once: true }) + }), + ]) + } finally { + clearTimeout(timer) + if (onAbort) signal?.removeEventListener("abort", onAbort) + } +} diff --git a/scripts/fixtures/wsl-fixture-bounds.test.mjs b/scripts/fixtures/wsl-fixture-bounds.test.mjs new file mode 100644 index 000000000..6f1b18227 --- /dev/null +++ b/scripts/fixtures/wsl-fixture-bounds.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { boundedFixtureOperation } from "./wsl-fixture-bounds.mjs" + +test("a never-settling request cannot retain the polling deadline", async () => { + await assert.rejects(boundedFixtureOperation(() => new Promise(() => {}), Date.now() + 20, "poll"), /poll timed out/) +}) + +test("whole-run abort interrupts a pending operation", async () => { + const controller = new AbortController() + const pending = boundedFixtureOperation(() => new Promise(() => {}), Date.now() + 10_000, "run", controller.signal) + controller.abort(new Error("whole-run deadline")) + await assert.rejects(pending, /whole-run deadline/) +}) + +test("bounded cleanup remains available after run abort and expired work never starts", async () => { + const controller = new AbortController() + controller.abort() + let calls = 0 + await assert.rejects(boundedFixtureOperation(() => { calls++ }, Date.now() + 100, "run", controller.signal)) + await assert.rejects(boundedFixtureOperation(() => { calls++ }, Date.now() - 1, "expired"), /expired timed out/) + assert.equal(calls, 0) + await boundedFixtureOperation(() => { calls++ }, Date.now() + 100, "cleanup") + assert.equal(calls, 1) +}) diff --git a/scripts/fixtures/wsl-reload-safety.mjs b/scripts/fixtures/wsl-reload-safety.mjs new file mode 100644 index 000000000..d18650b9e --- /dev/null +++ b/scripts/fixtures/wsl-reload-safety.mjs @@ -0,0 +1,176 @@ +// Destructive reload characterization against an explicitly isolated fixture. +// The synthetic provider holds one real model stream open across location.reload. +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { setTimeout as delay } from "node:timers/promises" +import { boundedFixtureOperation } from "./wsl-fixture-bounds.mjs" +import { stopFixtureChild } from "../native-fixture-guards.mjs" + +export async function startReloadSafetyProvider({ distro, root, environment, unc, signal }) { + const directory = `${root}/reload-provider` + await mkdir(unc(directory)) + const source = `import http.server, json, pathlib, sys, threading, time +root = pathlib.Path(sys.argv[1]) +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['content-length']))) + primary = self.headers.get('x-reload-kind') == 'primary' + with (root / 'requests.jsonl').open('a') as f: + f.write(json.dumps({'primary': primary, 'stream': body.get('stream', False)}) + '\\n') + if not body.get('stream'): + self.send_response(200); self.send_header('content-type', 'application/json'); self.end_headers() + self.wfile.write(json.dumps({'id':'fixture','choices':[{'message':{'role':'assistant','content':'Fixture'},'finish_reason':'stop'}]}).encode()) + return + self.send_response(200); self.send_header('content-type','text/event-stream'); self.end_headers() + def chunk(delta, finish=None): + self.wfile.write(('data: ' + json.dumps({'id':'fixture','object':'chat.completion.chunk','model':'fixture','choices':[{'index':0,'delta':delta,'finish_reason':finish}]}) + '\\n\\n').encode()) + self.wfile.flush() + try: + chunk({'role':'assistant','content':'Before reload. '}) + if primary: + (root / 'stream-open').touch() + while not (root / 'release').exists() and not (root / 'stop').exists(): time.sleep(.05) + chunk({'content':'After reload.'}) + chunk({}, 'stop') + self.wfile.write(b'data: [DONE]\\n\\n'); self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + (root / 'stream-broken').touch() +server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) +(root / 'port').write_text(str(server.server_port)) +def stop(): + while not (root / 'stop').exists(): time.sleep(.05) + server.shutdown() +threading.Thread(target=stop, daemon=True).start() +server.serve_forever() +server.server_close() +` + const child = spawn("wsl.exe", ["--distribution", distro, "--exec", "/usr/bin/env", "-i", + ...Object.entries(environment).map(([key, value]) => `${key}=${value}`), "/usr/bin/python3", "-", directory], + { windowsHide: true, stdio: ["pipe", "ignore", "pipe"] }) + let stderr = "" + child.stderr.on("data", chunk => { stderr += chunk }) + const closed = new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve) }) + // Attach immediately; startup/stop still await the original failure below. + void closed.catch(() => {}) + child.stdin.end(source) + const stop = async () => { + try { + await boundedFixtureOperation(async () => { + await writeFile(unc(`${directory}/release`), "") + await writeFile(unc(`${directory}/stop`), "") + await closed + }, Date.now() + 5_000, "local provider shutdown") + } catch (error) { + await stopFixtureChild(child, closed) + throw error + } + } + try { + let port + for (let i = 0; i < 100; i++) { + signal?.throwIfAborted() + port = Number(await boundedFixtureOperation(() => readFile(unc(`${directory}/port`), "utf8").catch(error => { + if (error.code !== "ENOENT") throw error + return "0" + }), Date.now() + 5_000, "provider startup", signal)) + if (port) break + if (child.exitCode !== null) throw new Error(`Fixture provider exited: ${stderr}`) + await delay(100) + } + assert.ok(port > 0, "Fixture provider failed to start") + return { + port, directory, stop, + waitForStream: async () => { + for (let i = 0; i < 200; i++) { + signal?.throwIfAborted() + if (await boundedFixtureOperation(() => readFile(unc(`${directory}/stream-open`)).then(() => true, error => { + if (error.code !== "ENOENT") throw error + return false + }), Date.now() + 5_000, "provider stream readiness", signal)) return + await delay(100) + } + throw new Error(`Fixture model stream did not start: ${stderr}`) + }, + release: () => writeFile(unc(`${directory}/release`), ""), + requests: async () => (await readFile(unc(`${directory}/requests.jsonl`), "utf8")).trim().split("\n").map(JSON.parse), + } + } catch (error) { await stop(); throw error } +} + +export async function prepareReloadSafety({ client, root, unc, provider }) { + const promptLocation = { directory: `${root}/reload-prompt` } + const resourcesLocation = { directory: `${root}/reload-resources` } + await mkdir(unc(promptLocation.directory)); await mkdir(unc(resourcesLocation.directory)) + const promptSession = await client.session.create({ location: promptLocation, title: "Held local-provider prompt" }) + const resourceSession = await client.session.create({ location: resourcesLocation, title: "Pending interactions" }) + await client.session.prompt({ sessionID: promptSession.id, text: "Complete one response." }) + await provider.waitForStream() + const form = await client.session.form.create({ sessionID: resourceSession.id, title: "Must remain pending", fields: [{ key: "answer", type: "string", required: true }] }) + const shell = (await client.shell.create({ location: resourcesLocation, command: "sleep 600" })).data + const pty = (await client.pty.create({ location: resourcesLocation, command: "/bin/sh", args: ["-c", "sleep 600"], title: "Must remain available" })).data + assert.ok((await client.session.form.list({ sessionID: resourceSession.id })).some(item => item.id === form.id)) + assert.equal((await client.shell.get({ location: resourcesLocation, id: shell.id })).data.status, "running") + assert.equal((await client.pty.get({ location: resourcesLocation, ptyID: pty.id })).data.status, "running") + const events = [] + const controller = new AbortController() + let consumerFailure + const consume = (async () => { + try { for await (const event of client.event.subscribe({ signal: controller.signal })) events.push(event) } + catch (error) { if (!controller.signal.aborted) consumerFailure = error } + })() + const finishConsumer = async () => { + controller.abort() + await consume + if (consumerFailure) throw consumerFailure + } + let info + try { + await delay(200) + if (consumerFailure) throw consumerFailure + info = await client.server.info() + if (consumerFailure) throw consumerFailure + } catch (error) { + try { await finishConsumer() } + finally { await provider.release() } + throw error + } + const before = { pid: info.pid, promptSessionID: promptSession.id, resourceSessionID: resourceSession.id, formID: form.id, shellID: shell.id, ptyID: pty.id } + return { + before, + async observe() { + try { + const result = async promise => { + try { return { value: await promise } } + catch (error) { return { error: { tag: error._tag, message: error.message, status: error.cause?.status, reason: error.reason } } } + } + const after = { + pid: (await client.server.info()).pid, + forms: await client.session.form.list({ sessionID: resourceSession.id }), + formReply: await result(client.session.form.reply({ sessionID: resourceSession.id, formID: form.id, answer: { answer: "Still here" } })), + shell: await result(client.shell.get({ location: resourcesLocation, id: shell.id })), + pty: await result(client.pty.get({ location: resourcesLocation, ptyID: pty.id })), + active: await client.session.active(), + } + await provider.release() + await client.session.wait({ sessionID: promptSession.id }, { signal: AbortSignal.timeout(20_000) }) + after.promptContext = await client.session.context({ sessionID: promptSession.id }) + after.primaryRequests = (await provider.requests()).filter(request => request.primary).length + after.events = events.filter(event => event.type === "form.cancelled" || event.type === "location.shutdown" || event.type.startsWith("session.execution.")) + assert.equal(after.pid, before.pid, "Reload keeps the daemon PID, which does not imply resource continuity") + assert.equal(after.active[promptSession.id]?.type, "running", "The original provider stream is still running after reload") + assert.equal(after.primaryRequests, 1, "A held model request must not be replayed") + assert.ok(after.promptContext.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text === "Before reload. After reload."))) + assert.ok(after.promptContext.some(message => message.type === "idle" && message.outcome === "succeeded")) + assert.ok(after.events.some(event => event.type === "form.cancelled" && event.data.id === form.id), "Characterization must observe native Form cancellation") + assert.equal(after.forms.length, 0) + assert.equal(after.formReply.error?.tag, "FormNotFoundError") + assert.equal(after.shell.error?.tag, "ShellNotFoundError") + assert.equal(after.pty.error?.tag, "PtyNotFoundError") + return { before, after, automaticReloadSafe: false } + } finally { await finishConsumer() } + }, + async dispose() { try { await finishConsumer() } finally { await provider.release() } }, + } +} diff --git a/scripts/fixtures/wsl-reload-safety.test.mjs b/scripts/fixtures/wsl-reload-safety.test.mjs new file mode 100644 index 000000000..b856db864 --- /dev/null +++ b/scripts/fixtures/wsl-reload-safety.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { prepareReloadSafety } from "./wsl-reload-safety.mjs" + +test("event failure before observer return is awaited and releases the held provider", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-reload-consumer-")) + let released = 0 + const client = { + session: { create: async () => ({ id: "fixture" }), prompt: async () => {}, + form: { create: async () => ({ id: "form" }), list: async () => [{ id: "form" }] } }, + shell: { create: async () => ({ data: { id: "shell" } }), get: async () => ({ data: { status: "running" } }) }, + pty: { create: async () => ({ data: { id: "pty" } }), get: async () => ({ data: { status: "running" } }) }, + event: { subscribe: async function* () { throw new Error("event stream timeout") } }, + server: { info: async () => ({ pid: 123 }) }, + } + try { + await assert.rejects(prepareReloadSafety({ client, root, unc: value => value, + provider: { waitForStream: async () => {}, release: async () => { released++ } }, + }), /event stream timeout/) + assert.equal(released, 1) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/scripts/test-opencode-setup-wsl.mjs b/scripts/test-opencode-setup-wsl.mjs new file mode 100644 index 000000000..e507096d4 --- /dev/null +++ b/scripts/test-opencode-setup-wsl.mjs @@ -0,0 +1,278 @@ +// Real Windows -> WSL official lifecycle, authenticated native connection and +// plugin provisioning. The wrapper forces every command into fresh fixture state. +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { randomUUID } from "node:crypto" +import { createServer } from "node:net" +import { watch } from "node:fs" +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { tsImport } from "tsx/esm/api" +import { OpenCode } from "@opencode/client" +import { Service } from "@opencode/client/service" +import { boundedFixtureOperation } from "./fixtures/wsl-fixture-bounds.mjs" + +const deadlineAt = Date.now() + 180_000 +const signal = AbortSignal.timeout(180_000) + +const [distro, cli, mode = "aliased", check = ""] = process.argv.slice(2) +if (process.platform !== "win32" || !distro || !cli?.startsWith("/")) throw new Error("Pass a WSL distro and absolute Linux CLI path on Windows") +assert.ok(["native", "mounted", "aliased"].includes(mode), "Config mode must be native, mounted or aliased") +assert.ok(check === "" || check === "reload-safety", "Optional check must be reload-safety") +const quote = value => `'${value.replaceAll("'", `'"'"'`)}'` +function wsl(script, cleanup = false) { + if (!cleanup) signal.throwIfAborted() + return new Promise((resolve, reject) => { + const child = execFile("wsl.exe", ["--distribution", distro, "--exec", "/bin/bash", "-s"], + { encoding: "utf8", timeout: cleanup ? 15_000 : Math.max(1, Math.min(60_000, deadlineAt - Date.now())), + ...(!cleanup ? { signal } : {}), maxBuffer: 1024 * 1024, windowsHide: true }, + (error, stdout, stderr) => error ? reject(new Error(`${error.message}\n${stderr}`)) : resolve(stdout.trim())) + child.stdin.end(`set -euo pipefail\n${script}\n`) + }) +} +const parent = path.join(os.tmpdir(), "opencode") +await mkdir(parent, { recursive: true }) +const mounted = await mkdtemp(path.join(parent, "codenomad-wsl-setup-")) +const mount = await wsl(`wslpath -au ${quote(mounted)}`) +const root = await wsl('mkdir -p "$HOME/.cache"\nmktemp -d "$HOME/.cache/codenomad-wsl-setup-XXXXXX"') +const password = randomUUID(), wrapper = `${root}/opencode-fixture` +const config = mode === "native" ? `${root}/config` : mode === "mounted" ? `${mount}/config` : `${root}/config-alias` +const unc = directory => `\\\\wsl.localhost\\${distro}${directory.replaceAll("/", "\\")}` +const configFile = mode === "native" ? unc(`${config}/opencode.json`) : path.join(mounted, "config/opencode.json") +// Stable CLI channels default to 49374 even with different XDG roots. Allocate +// an unused Windows port and verify it is unused in Linux before configuring it. +const reservation = createServer() +await new Promise((resolve, reject) => { reservation.once("error", reject); reservation.listen(0, "127.0.0.1", resolve) }) +const port = reservation.address().port +await new Promise(resolve => reservation.close(resolve)) +await wsl(`mkdir -p ${quote(`${mount}/config`)} ${quote(`${root}/config`)} ${quote(`${root}/home`)} ${quote(`${root}/repo`)} +${mode === "aliased" ? `ln -s ${quote(`${mount}/config`)} ${quote(config)}` : ""} +mkdir -m 700 ${quote(`${root}/runtime`)} +python3 -c ${quote(`import socket; s=socket.socket(); s.bind(('127.0.0.1', ${port})); s.close()`)}`) +const environment = { + PATH: "/usr/local/bin:/usr/bin:/bin", HOME: `${root}/home`, USERPROFILE: `${root}/home`, + XDG_DATA_HOME: `${root}/data`, XDG_CONFIG_HOME: `${root}/xdg-config`, XDG_STATE_HOME: `${root}/state`, + XDG_CACHE_HOME: `${root}/cache`, XDG_RUNTIME_DIR: `${root}/runtime`, OPENCODE_TEST_HOME: `${root}/home`, OPENCODE_CONFIG_DIR: config, + OPENCODE_DB: `${root}/fixture.db`, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: [`${root}/probe-plugin`] }), + OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", +} +let reloadProvider, reloadSafety, lifecycle, shared, automation, pruning, configWatcher +let started = false +const evidence = { root, mounted, mode, port } +async function until(predicate, message) { + const deadline = Math.min(deadlineAt, Date.now() + 30_000) + while (true) { + const options = { signal: AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))]) } + if (await boundedFixtureOperation(() => predicate(options), deadline, message, signal)) return + await delay(100, undefined, { signal }) + } +} +async function startOrRestart(restart = false) { + signal.throwIfAborted() + const startDeadline = Math.min(deadlineAt, Date.now() + 30_000) + try { return await (restart ? lifecycle.restart(startDeadline) : lifecycle.ensure(startDeadline)) } + catch (error) { + if (!error.message.startsWith("Cannot reach the WSL OpenCode service from Windows")) throw error + // Forwarding may trail Linux readiness. Only rediscover our guarded service. + let endpoint + await until(async () => { + try { endpoint = await lifecycle.discover(Math.min(deadlineAt, Date.now() + 15_000)); return Boolean(endpoint) } + catch (retry) { + if (!retry.message.startsWith("Cannot reach the WSL OpenCode service from Windows")) throw retry + return false + } + }, "Fixture WSL localhost forwarding did not become ready") + return endpoint + } +} +async function runFixture() { +if (check === "reload-safety") { + const { startReloadSafetyProvider } = await import("./fixtures/wsl-reload-safety.mjs") + reloadProvider = await startReloadSafetyProvider({ distro, root, environment, unc, signal }) + environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + plugins: [`${root}/probe-plugin`], model: "fixture/fixture", + providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `http://127.0.0.1:${reloadProvider.port}/v1`, apiKey: "fixture" }, models: { fixture: {} } } }, + }) +} +// Check the isolated registration and Linux process environment before any CLI +// command can probe or stop it. Never infer ownership from an endpoint or PID. +const guard = `import json, os, pathlib, socket +root = pathlib.Path(${JSON.stringify(root)}) +registration = root / 'state/opencode/service.json' +if registration.exists(): + info = json.loads(registration.read_text()) + assert info['url'] == 'http://127.0.0.1:${port}', 'Unexpected fixture endpoint' + env = pathlib.Path('/proc/%s/environ' % info['pid']).read_bytes().split(b'\\0') + for key, value in json.loads(${JSON.stringify(JSON.stringify(environment))}).items(): + assert (key + '=' + value).encode() in env, 'Fixture process ownership mismatch: ' + key +else: + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('127.0.0.1', ${port})) + s.close() +` +const script = `#!/bin/sh\nset -eu\n/usr/bin/python3 ${quote(`${root}/guard.py`)}\ncd ${quote(`${root}/repo`)}\nexec env -i ${Object.entries(environment).map(([key, value]) => `${key}=${quote(value)}`).join(" ")} ${quote(cli)} "$@"\n` +await wsl(`cat > ${quote(wrapper)} <<'CODENOMAD_FIXTURE' +${script}CODENOMAD_FIXTURE +cat > ${quote(`${root}/guard.py`)} <<'CODENOMAD_GUARD' +${guard}CODENOMAD_GUARD +chmod 700 ${quote(wrapper)}`) +await wsl(`mkdir ${quote(`${root}/probe-plugin`)} +cat > ${quote(`${root}/probe-plugin/index.ts`)} <<'CODENOMAD_PLUGIN' +import { appendFile } from 'node:fs/promises' +export default { id: 'wsl-fixture-probe', async setup(ctx) { + await appendFile(${JSON.stringify(`${root}/plugin-setup.log`)}, 'setup\\n') + await ctx.session.hook('http.request', event => event.request.headers.set('x-reload-kind', event.kind)) +} } +CODENOMAD_PLUGIN`) +await wsl(`${quote(wrapper)} service set port ${port} +${quote(wrapper)} service set password ${quote(password)}`) +await writeFile(configFile, "{}\n") +const { WslOpenCodeService } = await tsImport("../packages/server/src/workspaces/wsl-opencode-service.ts", import.meta.url) +const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url) +const { resolveDesktopPluginPaths } = await tsImport("../packages/server/src/opencode/desktop-plugin-paths.ts", import.meta.url) +const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await tsImport("../packages/server/src/opencode/desktop-plugin-lifecycle.ts", import.meta.url) +lifecycle = new WslOpenCodeService({ distro, binary: wrapper, timeoutMs: 30_000 }) +shared = new OpenCodeSharedService({ headers: Service.headers, makeClient: options => OpenCode.make({ ...options, + fetch: (input, init) => options.fetch(input, { ...init, + signal: AbortSignal.any([signal, AbortSignal.timeout(15_000), ...(init?.signal ? [init.signal] : [])]), + }), +}) }) +automation = new DesktopPluginLifecycle("automation") +pruning = new DesktopPluginLifecycle("session-pruning") + assert.equal(await lifecycle.discover(Math.min(deadlineAt, Date.now() + 15_000)), undefined, "Fresh fixture must never discover an existing service") + started = true + const endpoint = await startOrRestart() + assert.equal(endpoint.url, `http://127.0.0.1:${port}`) + const options = { kind: "lifecycle", identity: `wsl-fixture:${root}`, lifecycle } + const client = await shared.client(options, { deadlineAt }) + const before = await client.server.info() + evidence.started = { endpoint: endpoint.url, pid: before.pid, version: before.version } + console.log(`Authenticated isolated ${distro} service: ${endpoint.url}, PID ${before.pid}; ${root}`) + assert.equal(before.version, "2.0.11") + const connection = await shared.acquire() + const assertCurrent = () => { signal.throwIfAborted(); connection.assertCurrent() } + const paths = await resolveDesktopPluginPaths({ ...connection, assertCurrent }, { kind: "wsl", distro, binary: wrapper }, Math.min(deadlineAt, Date.now() + 15_000)) + evidence.paths = paths + if (mode === "native") assert.ok(paths.config.startsWith(`\\\\wsl.localhost\\${distro}\\`)) + else assert.equal(paths.config.toLowerCase(), path.dirname(configFile).toLowerCase(), "Mounted discovery roots, including symlink aliases, need Windows-native access paths") + assert.equal(await readFile(path.join(paths.config, "opencode.json"), "utf8"), "{}\n") + assert.ok(!paths.data.startsWith(`${paths.config}\\`), "Presence leases must stay outside the aliased/mounted config tree") + const location = { directory: `${root}/repo` } + await client.location.get({ location }) + await until(async request => (await client.plugin.list({ location }, request)).data.some(plugin => plugin.id === "wsl-fixture-probe" && plugin.state.status === "active"), "Probe plugin did not activate") + assert.ok(!(await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad.automation"), "Location must be loaded before desktop plugins are installed") + if (reloadProvider) { + const { prepareReloadSafety } = await import("./fixtures/wsl-reload-safety.mjs") + reloadSafety = await prepareReloadSafety({ client, root, unc, provider: reloadProvider }) + evidence.reloadSafety = { before: reloadSafety.before } + } + await prepareDesktopPluginPresence(paths, assertCurrent, { automation, pruning }) + await delay(1_500) + evidence.beforeExplicitReload = (await client.plugin.list({ location })).data + .filter(plugin => plugin.id.startsWith("codenomad")) + .map(plugin => ({ id: plugin.id, status: plugin.state.status })) + // This explicitly tests the informed recovery action. It is never evidence + // of automatic watcher discovery; pre-reload native state is recorded above. + await client.location.reload({ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) }) + if (reloadSafety) evidence.reloadSafety = await reloadSafety.observe() + await until(async request => { + const plugins = (await client.plugin.list({ location }, request)).data + evidence.plugins = plugins.filter(plugin => plugin.id.startsWith("codenomad") || plugin.id === "wsl-fixture-probe") + .map(plugin => ({ id: plugin.id, status: plugin.state.status })) + return ["codenomad.automation", "codenomad-session-pruning"].every(id => plugins.some(plugin => plugin.id === id && plugin.state.status === "active")) + }, "Late plugin discovery after native reload failed") + const changes = [] + const watcher = mode === "native" ? undefined : watch(path.dirname(configFile), { recursive: true }, (event, filename) => changes.push({ event, filename })) + configWatcher = watcher + if (watcher) { + try { + const probe = path.join(path.dirname(configFile), ".watch-probe") + await writeFile(probe, "") + await rm(probe) + await until(async () => changes.some(change => change.filename === ".watch-probe"), "Windows config observer did not receive its control event") + } catch (error) { watcher.close(); throw error } + } + await delay(2_500) + const setupBefore = await readFile(unc(`${root}/plugin-setup.log`), "utf8") + const leasePaths = (await Promise.all(["automation", "session-pruning"].map(async feature => { + const directory = path.join(paths.data, feature, "presence") + return (await readdir(directory)).filter(name => name.endsWith(".lease")).map(name => path.join(directory, name)) + }))).flat() + assert.equal(leasePaths.length, 2) + const leaseBefore = await Promise.all(leasePaths.map(async file => (await stat(file)).mtimeMs)) + changes.length = 0 + try { + // Windows ReadDirectoryChangesW cannot watch native WSL UNC directories. + // Observe Linux inotify there instead, including existing nested folders. + if (mode === "native") { + const events = await wsl(`python3 - ${quote(config)} ${quote(`${root}/runtime`)} <<'CODENOMAD_WATCH' +import ctypes, json, os, select, sys, time +libc = ctypes.CDLL(None, use_errno=True) +fd = libc.inotify_init1(os.O_NONBLOCK) +assert fd >= 0 +# Prove this observer is live before measuring the real heartbeat interval. +control = libc.inotify_add_watch(fd, os.fsencode(sys.argv[2]), 0x00000fce) +assert control >= 0 +probe = os.path.join(sys.argv[2], '.watch-probe') +open(probe, 'w').close() +os.unlink(probe) +assert select.select([fd], [], [], 2)[0] +os.read(fd, 1048576) +libc.inotify_rm_watch(fd, control) +os.read(fd, 1048576) +for directory, _, _ in os.walk(sys.argv[1]): + assert libc.inotify_add_watch(fd, os.fsencode(directory), 0x00000fce) >= 0 +deadline = time.monotonic() + 5 +events = [] +while time.monotonic() < deadline: + if select.select([fd], [], [], max(0, deadline-time.monotonic()))[0]: + events.append(os.read(fd, 1048576).hex()) +os.close(fd) +print(json.dumps(events)) +CODENOMAD_WATCH`) + changes.push(...JSON.parse(events)) + } else await delay(5_000) + } finally { watcher?.close() } + const leaseAfter = await Promise.all(leasePaths.map(async file => (await stat(file)).mtimeMs)) + assert.ok(leaseAfter.every((time, index) => time > leaseBefore[index]), "Both backend leases must actually heartbeat") + assert.deepEqual(changes, [], "Heartbeats must leave the entire watched config tree unchanged") + assert.equal(await readFile(unc(`${root}/plugin-setup.log`), "utf8"), setupBefore, "Heartbeats must not rerun plugin setup") + assert.equal((await client.server.info()).pid, before.pid) + evidence.heartbeat = { durationMs: 5_000, leaseBefore, leaseAfter, changes, stableSetup: true } + evidence.nativeWatcherFailures = (await readFile(unc(`${root}/data/opencode/log/opencode.log`), "utf8")) + .split("\n").filter(line => line.includes('message="failed to subscribe"')).slice(-4) + await automation.stop(); await pruning.stop(); await shared.shutdown() + await startOrRestart(true) + const replacement = await shared.client(options, { deadlineAt }) + evidence.restarted = { pid: (await replacement.server.info()).pid } + assert.notEqual(evidence.restarted.pid, before.pid) + assert.equal((await replacement.location.get({ location })).directory, location.directory) + evidence.passed = true + console.log(`PASS Windows→${distro} (${mode}): real service start/authentication/restart/reconnect, translated filesystem provisioning, late plugin discovery after explicit native reload and measured heartbeat stability; ${mounted}`) +} +try { + await boundedFixtureOperation(runFixture, deadlineAt, "WSL native fixture", signal) +} catch (error) { + evidence.error = error.message + throw error +} finally { + configWatcher?.close() + const cleanupDeadline = Date.now() + 20_000 + const cleanups = [ + ["reload observer", () => reloadSafety?.dispose()], ["local provider", () => reloadProvider?.stop()], + ["automation presence", () => automation?.stop()], ["pruning presence", () => pruning?.stop()], + ["native client", () => shared?.shutdown()], + ["isolated daemon", () => started ? wsl(`${quote(wrapper)} service stop`, true) : undefined], + ] + const results = await Promise.allSettled(cleanups.map(([label, operation]) => boundedFixtureOperation(operation, cleanupDeadline, label))) + evidence.cleanup = results.map((result, index) => ({ operation: cleanups[index][0], status: result.status, + ...(result.status === "rejected" ? { error: String(result.reason) } : {}) })) + await boundedFixtureOperation(() => writeFile(path.join(mounted, "evidence.json"), JSON.stringify(evidence, null, 2)), Date.now() + 5_000, "write evidence") + const failures = results.filter(result => result.status === "rejected").map(result => result.reason) + if (failures.length) throw new AggregateError(failures, "Isolated WSL fixture cleanup failed") +} From 75200b771a032ddc582078acb31a0caa5b4ac9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 17:45:37 +0200 Subject: [PATCH 09/14] fix(opencode): report setup failures without denying successful installation Use action-neutral setup failure copy in all ten locales. Connection, restart or configuration reload can fail after installation has already succeeded, so the recovery card must not claim that the installed version is unchanged. Add a browser regression that installs successfully, fails first activation, retains the installed-version state and connects on retry without reinstalling. All seven setup browser regressions pass. --- .../settings/opencode-update-card.tsx | 2 +- .../ui/src/lib/i18n/messages/de/settings.ts | 1 + .../ui/src/lib/i18n/messages/en/settings.ts | 1 + .../ui/src/lib/i18n/messages/es/settings.ts | 1 + .../ui/src/lib/i18n/messages/fr/settings.ts | 1 + .../ui/src/lib/i18n/messages/he/settings.ts | 1 + .../ui/src/lib/i18n/messages/ja/settings.ts | 1 + .../ui/src/lib/i18n/messages/ne/settings.ts | 1 + .../ui/src/lib/i18n/messages/ru/settings.ts | 1 + .../ui/src/lib/i18n/messages/tr/settings.ts | 1 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 1 + .../ui/tests/browser/opencode-setup.test.ts | 31 +++++++++++++++++++ 12 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index c235cb83d..2e0ee78e7 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -44,7 +44,7 @@ export const OpenCodeUpdateCard: Component = () => { } - +
diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index da73c0241..b6eca1969 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "Die OpenCode-Aktion konnte nicht abgeschlossen werden. Prüfen Sie den aktuellen Status oben und versuchen Sie es erneut.", "settings.opencode.setup.reload": "OpenCode-Konfiguration neu laden", "settings.opencode.setup.reloadDescription": "Wenn installierte Plugins nicht verfügbar sind, laden Sie die Konfiguration neu. Dabei werden alle geladenen Arbeitsorte für alle verbundenen Clients neu aufgebaut, ausstehende Berechtigungsanfragen und Formulare abgebrochen sowie Terminals und Hintergrundbefehle geschlossen. Aktive Arbeit kann unterbrochen werden.", "settings.opencode.setup.description": "OpenCode auf dem Server Ihrer Projekte installieren, aktualisieren und verbinden.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 342e8775d..a4c85e905 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "The OpenCode action could not be completed. Check the current status above and retry.", "settings.opencode.setup.reload": "Reload OpenCode configuration", "settings.opencode.setup.reloadDescription": "If installed plugins remain unavailable, reload configuration. This rebuilds every loaded location for all connected clients, cancels pending permissions and forms, and closes terminals and background commands. Active work may be interrupted.", "settings.opencode.setup.description": "Install, update and connect OpenCode on the server running your projects.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 8639e22af..116ac3f1d 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "No se pudo completar la acción de OpenCode. Comprueba el estado actual arriba y vuelve a intentarlo.", "settings.opencode.setup.reload": "Recargar la configuración de OpenCode", "settings.opencode.setup.reloadDescription": "Si los plugins instalados siguen sin estar disponibles, recarga la configuración. Esto reconstruye todas las ubicaciones cargadas para todos los clientes conectados, cancela los permisos y formularios pendientes y cierra los terminales y comandos en segundo plano. El trabajo activo puede interrumpirse.", "settings.opencode.setup.description": "Instala, actualiza y conecta OpenCode en el servidor que ejecuta tus proyectos.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index baab32fde..2c435b219 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "L’action OpenCode n’a pas pu être terminée. Vérifiez l’état actuel ci-dessus et réessayez.", "settings.opencode.setup.reload": "Recharger la configuration OpenCode", "settings.opencode.setup.reloadDescription": "Si les plugins installés restent indisponibles, rechargez la configuration. Cette action reconstruit tous les emplacements chargés pour tous les clients connectés, annule les demandes d’autorisation et formulaires en attente, et ferme les terminaux et commandes d’arrière-plan. Le travail actif peut être interrompu.", "settings.opencode.setup.description": "Installez, mettez à jour et connectez OpenCode sur le serveur qui exécute vos projets.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 81a314cff..4cfe8defc 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "לא ניתן היה להשלים את פעולת OpenCode. בדוק את המצב הנוכחי למעלה ונסה שוב.", "settings.opencode.setup.reload": "טעינה מחדש של תצורת OpenCode", "settings.opencode.setup.reloadDescription": "אם התוספים המותקנים עדיין אינם זמינים, טען מחדש את התצורה. פעולה זו בונה מחדש את כל המיקומים הטעונים עבור כל הלקוחות המחוברים, מבטלת בקשות הרשאה וטפסים ממתינים וסוגרת מסופים ופקודות ברקע. עבודה פעילה עלולה להיקטע.", "settings.opencode.setup.description": "התקנה, עדכון וחיבור של OpenCode בשרת שמריץ את הפרויקטים שלך.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f73f11819..7f53f2c87 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "OpenCode の操作を完了できませんでした。上の現在の状態を確認して、もう一度お試しください。", "settings.opencode.setup.reload": "OpenCode の設定を再読み込み", "settings.opencode.setup.reloadDescription": "インストール済みプラグインが利用できない場合は設定を再読み込みしてください。接続中のすべてのクライアントで読み込み済みの全ロケーションが再構築され、保留中の権限要求とフォームが取り消され、ターミナルとバックグラウンドコマンドが終了します。進行中の作業が中断される場合があります。", "settings.opencode.setup.description": "プロジェクトを実行するサーバーで OpenCode をインストール、更新、接続します。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 55ccd17d9..0395e727d 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "OpenCode कार्य पूरा गर्न सकिएन। माथिको हालको स्थिति जाँचेर फेरि प्रयास गर्नुहोस्।", "settings.opencode.setup.reload": "OpenCode कन्फिगरेसन पुनः लोड गर्नुहोस्", "settings.opencode.setup.reloadDescription": "स्थापित प्लगइनहरू अझै उपलब्ध छैनन् भने कन्फिगरेसन पुनः लोड गर्नुहोस्। यसले सबै जडित क्लाइन्टका लोड गरिएका सबै स्थानहरू पुनर्निर्माण गर्छ, विचाराधीन अनुमति अनुरोध तथा फारमहरू रद्द गर्छ र टर्मिनल तथा पृष्ठभूमि आदेशहरू बन्द गर्छ। सक्रिय काम अवरुद्ध हुन सक्छ।", "settings.opencode.setup.description": "तपाईंका परियोजनाहरू चलाउने सर्भरमा OpenCode स्थापना, अद्यावधिक र जडान गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index ad4c6a104..3dd41f22b 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "Не удалось завершить действие OpenCode. Проверьте текущее состояние выше и повторите попытку.", "settings.opencode.setup.reload": "Перезагрузить конфигурацию OpenCode", "settings.opencode.setup.reloadDescription": "Если установленные плагины недоступны, перезагрузите конфигурацию. Это пересоздаёт все загруженные расположения для всех подключённых клиентов, отменяет ожидающие запросы разрешений и формы, закрывает терминалы и фоновые команды. Активная работа может быть прервана.", "settings.opencode.setup.description": "Установите, обновите и подключите OpenCode на сервере, выполняющем ваши проекты.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index cf74bc527..0d1fb8a45 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "OpenCode işlemi tamamlanamadı. Yukarıdaki mevcut durumu kontrol edip yeniden deneyin.", "settings.opencode.setup.reload": "OpenCode yapılandırmasını yeniden yükle", "settings.opencode.setup.reloadDescription": "Yüklü eklentiler hâlâ kullanılamıyorsa yapılandırmayı yeniden yükleyin. Bu işlem tüm bağlı istemciler için yüklenmiş tüm konumları yeniden oluşturur, bekleyen izin istekleriyle formları iptal eder ve terminallerle arka plan komutlarını kapatır. Etkin çalışma kesintiye uğrayabilir.", "settings.opencode.setup.description": "Projelerinizi çalıştıran sunucuda OpenCode'u kurun, güncelleyin ve bağlanın.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 71f0b6e00..562beb65e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -1,4 +1,5 @@ export const settingsMessages = { + "settings.opencode.setup.actionFailed": "无法完成 OpenCode 操作。请检查上方的当前状态,然后重试。", "settings.opencode.setup.reload": "重新加载 OpenCode 配置", "settings.opencode.setup.reloadDescription": "如果已安装的插件仍不可用,请重新加载配置。这会为所有已连接的客户端重建所有已加载的位置,取消待处理的权限请求和表单,并关闭终端和后台命令。正在进行的工作可能会中断。", "settings.opencode.setup.description": "在运行项目的服务器上安装、更新并连接 OpenCode。", diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 78eb4e7e0..d37f3fb9b 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -192,3 +192,34 @@ test("configuration reload is an informed explicit action and never runs on setu assert.equal(await page.getByRole("dialog").count(), 0) } finally { await page.close() } }) + +test("activation failure after installation retains the installed version and retries connection", async () => { + const page = await browser.newPage() + let installed = false, connected = false, installations = 0, attempts = 0 + await page.route("**/api/**", route => { + const request = route.request() + if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") { + installed = true; installations++ + return route.fulfill({ json: { success: true, version: "2.0.11" } }) + } + if (request.url().endsWith("/api/opencode/service")) { + attempts++ + if (attempts === 1) return route.fulfill({ status: 502, json: { error: "service_activation_failed" } }) + connected = 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, canRestart: false, serviceState: connected ? "ready" : "stopped" } }) + }) + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 }) + await page.getByRole("button", { name: "Install and start OpenCode" }).click() + await page.getByText("The OpenCode action could not be completed. Check the current status above and retry.").waitFor() + assert.equal(await page.getByRole("button", { name: "Install and start OpenCode" }).count(), 0) + assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0) + await page.getByRole("button", { name: "Connect and continue" }).click() + await page.waitForFunction(() => !document.querySelector('[role="dialog"]')) + assert.equal(installations, 1) + assert.equal(attempts, 2) + } finally { await page.close() } +}) From d8f9b38793f516b6c496b74acf379f69be0b21f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 18:28:03 +0200 Subject: [PATCH 10/14] fix(opencode): preserve old daemons until explicit setup restart Do not treat a newer CLI's stopped status as proof that its older shared daemon is absent. Resolve native service metadata read-only through the selected execution host, match the channel's credential/configuration unambiguously, and authenticate historical metadata routes before admitting the registered endpoint. Repeat discovery inside ensure before starting. Fail closed for malformed or oversized records, ambiguous identity, authentication failures, timeouts and existing WSL registrations with forwarding errors. Distinguish fresh configuration with no native registration, preserve the shared loopback policy including 127.0.0.2, and translate mounted/aliased WSL metadata through the selected distro. Add lifecycle regressions and bounded, isolated packaged desktop acceptance. Both rebuilt Electron and Tauri retain an actual 2.0.3 daemon through installation until explicit restart, then activate 2.0.11 and resume the pending folder. Normal installation/reload and all three WSL modes pass. Final gatekeeper review reports no actionable findings; full server suite passes 621 tests with two skips. --- .../workspaces/host-opencode-service.test.ts | 15 +- .../native-service-registration.test.ts | 176 ++++++++++++++++ .../workspaces/native-service-registration.ts | 93 +++++++++ .../workspaces/native-service-start.test.ts | 11 +- .../src/workspaces/opencode-cli-service.ts | 56 ++++- .../workspaces/wsl-opencode-service.test.ts | 12 +- .../src/workspaces/wsl-opencode-service.ts | 3 + scripts/test-opencode-setup-desktop.mjs | 195 ++++++++++++++++++ 8 files changed, 545 insertions(+), 16 deletions(-) create mode 100644 packages/server/src/workspaces/native-service-registration.test.ts create mode 100644 packages/server/src/workspaces/native-service-registration.ts create mode 100644 scripts/test-opencode-setup-desktop.mjs diff --git a/packages/server/src/workspaces/host-opencode-service.test.ts b/packages/server/src/workspaces/host-opencode-service.test.ts index 0ff17849b..287a5ee9a 100644 --- a/packages/server/src/workspaces/host-opencode-service.test.ts +++ b/packages/server/src/workspaces/host-opencode-service.test.ts @@ -23,7 +23,7 @@ describe("HostOpenCodeService", () => { }) 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.deepEqual(calls.map(call => call.args), [["service", "stop"], ...stoppedDiscovery, ["service", "start"], ["service", "get", "password"]]) assert.equal(calls[0].options.env, undefined) }) @@ -38,15 +38,16 @@ describe("HostOpenCodeService", () => { }) assert.deepEqual(calls.map(({ args }) => args), [ - ["service", "status"], + ...stoppedDiscovery, + ...stoppedDiscovery, ["service", "start"], ["service", "get", "password"], ]) assert.equal(calls.every(({ options }) => options.shell === false), true) assert.equal(calls[0]?.options.env, undefined) - assert.equal(calls[2]?.options.env, undefined) - assert.equal(calls[1]?.options.env?.PROVIDER_TOKEN, "secret") - assert.equal(calls[1]?.options.env?.NODE_EXTRA_CA_CERTS, "/ca.pem") + assert.equal(calls.at(-1)?.options.env, undefined) + assert.equal(calls.at(-2)?.options.env?.PROVIDER_TOKEN, "secret") + assert.equal(calls.at(-2)?.options.env?.NODE_EXTRA_CA_CERTS, "/ca.pem") }) it("authenticates strict bounded status and rejects malformed output", async () => { @@ -201,6 +202,7 @@ describe("HostOpenCodeService", () => { const service = createService([], { TOKEN: secret }, { execFile: async (_file, args) => { if (args[args.length - 1] === "status") return { stdout: "stopped\n", stderr: "" } + if (args[args.length - 1] !== "start") return { stdout: "password\n", stderr: "" } throw Object.assign(new Error(secret), { code: 7, stdout: secret, stderr: secret }) }, }) @@ -265,6 +267,9 @@ function createService( return { stdout: "password\n", stderr: "" } }, fetch: async () => Response.json({ version: "2.0.4", pid: 123, urls: [url] }), + readRegistration: async () => undefined, ...overrides, }) } + +const stoppedDiscovery = [["service", "status"], ["service", "get", "password"], ["debug", "paths", "state"], ["debug", "paths", "config"]] diff --git a/packages/server/src/workspaces/native-service-registration.test.ts b/packages/server/src/workspaces/native-service-registration.test.ts new file mode 100644 index 000000000..6bc934347 --- /dev/null +++ b/packages/server/src/workspaces/native-service-registration.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" +import { readNativeServiceRegistration, wslServiceMetadataPath } from "./native-service-registration" +import { HostOpenCodeService } from "./host-opencode-service" +import { WslOpenCodeService } from "./wsl-opencode-service" +import { runtimeIdentity } from "../opencode/compatibility/runtime" + +const url = "http://127.0.0.1:4321" +const refused = () => Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("refused"), { code: "ECONNREFUSED" }) }) + +test("native channel selection reads only credential-matched service metadata, rejects ambiguous or malformed registrations", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "native-registration-")) + const stateDirectory = path.join(root, "state"), configDirectory = path.join(root, "config") + await mkdir(stateDirectory); await mkdir(configDirectory) + const read = () => readNativeServiceRegistration({ stateDirectory, configDirectory, password: "selected" }) + const config = (name: string, value: unknown) => writeFile(path.join(configDirectory, name), JSON.stringify(value)) + const registration = (value: unknown) => writeFile(path.join(stateDirectory, "service-local.json"), JSON.stringify(value)) + try { + await config("service.json", { password: "other" }) + await config("service-local.json", { password: "selected" }) + assert.equal(await read(), undefined, "no guessed stable/default port") + await registration({ url, pid: 7, password: "selected" }) + assert.deepEqual(await read(), { url, pid: 7 }) + await config("service-another.json", { password: "selected" }) + await assert.rejects(read(), /uniquely identify/) + await rm(path.join(configDirectory, "service-another.json")) + for (const value of [{ url, pid: 0, password: "selected" }, { url, pid: 7, password: "wrong" }, [], "secret"] ) { + await registration(value) + await assert.rejects(read(), /Invalid native service/) + } + await writeFile(path.join(stateDirectory, "service-local.json"), "x".repeat(65537)) + await assert.rejects(read(), /Invalid native service metadata/) + await rm(path.join(stateDirectory, "service-local.json")) + await config("service-local.json", { password: "selected", hostname: "::1", port: 1234 }) + assert.deepEqual(await read(), { url: "http://[::1]:1234" }) + await config("service-local.json", { password: "selected", hostname: "127.0.0.2", port: 1234 }) + assert.deepEqual(await read(), { url: "http://127.0.0.2:1234" }) + let starts = 0, probes = 0 + const alternateLoopback = "http://127.0.0.2:1234" + const service = new HostOpenCodeService({ binary: process.execPath, timeoutMs: 1000 }, { + execFile: async (_file, args) => { + const operation = args.join(" ") + if (operation === "service start") { starts++; return { stdout: alternateLoopback, stderr: "" } } + return { stdout: operation === "service status" ? "stopped" : operation === "debug paths state" ? stateDirectory + : operation === "debug paths config" ? configDirectory : "selected", stderr: "" } + }, + fetch: async input => { + assert.equal(String(input), `${alternateLoopback}/api/status`) + if (++probes === 1) throw refused() + return Response.json({ version: "2.0.11", pid: 8, urls: [alternateLoopback] }) + }, + }) + assert.equal((await service.ensure()).url, alternateLoopback) + assert.equal(starts, 1, "native config-only 127/8 loopback must permit stopped-service startup") + for (const hostname of ["192.0.2.1", "127.0.0.2/path", "127.0.0.2@localhost", "127.0.0.2\n", "localhost?x"]) { + await config("service-local.json", { password: "selected", hostname, port: 1234 }) + await assert.rejects(read()) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test("WSL metadata resolves mounted aliases through the selected distro, with no traversal or shell interpretation", async () => { + const calls: unknown[] = [] + assert.equal(await wslServiceMetadataPath("Ubuntu", "/home/alias", Date.now() + 1000, async (distro, command, args) => { + calls.push({ distro, command, args }) + return command === "realpath" ? "/mnt/c/fixture/config\n" : "C:\\fixture\\config\n" + }), "C:\\fixture\\config") + assert.deepEqual(calls, [{ distro: "Ubuntu", command: "realpath", args: ["-m", "--", "/home/alias"] }, + { distro: "Ubuntu", command: "wslpath", args: ["-aw", "/mnt/c/fixture/config"] }]) + for (const value of ["relative", "/../../etc", "/home/../other", "/home\\other", "/a\nb"]) await assert.rejects(wslServiceMetadataPath("Ubuntu", value, Date.now() + 1000)) + await assert.rejects(wslServiceMetadataPath("Ubuntu/other", "/home", Date.now() + 1000)) +}) + +function harness(input: { wsl?: boolean; registration?: boolean; configOnly?: boolean; fetch?: typeof fetch; hangRead?: boolean; timeout?: number } = {}) { + const commands: string[][] = [] + let starts = 0, registrationReads = 0 + const dependencies = { + execFile: async (_file: string, args: string[]) => { + const command = input.wsl ? args.slice(4) : args + commands.push(command) + const operation = command.join(" ") + if (operation === "service start") { starts++; return { stdout: url, stderr: "" } } + return { stdout: operation === "service status" ? "stopped" : operation === "service get password" ? "selected" : "/native/root", stderr: "" } + }, + readRegistration: async (options: Parameters[0]) => { + registrationReads++ + if (input.wsl) assert.equal(typeof options.mapPath, "function") + if (input.hangRead) return new Promise(() => {}) + return input.registration === false ? undefined : input.configOnly ? { url } : { url, pid: 7 } + }, + fetch: input.fetch ?? (async () => Response.json({ version: "2.0.3", pid: 7, urls: [url] })), + } + const options = { binary: process.execPath, timeoutMs: input.timeout ?? 500 } + const service = input.wsl ? new WslOpenCodeService({ ...options, distro: "Ubuntu" }, dependencies) : new HostOpenCodeService(options, dependencies) + return { service, commands, starts: () => starts, registrationReads: () => registrationReads } +} + +test("stopped from the new CLI still authenticates old daemon on host and WSL, including ensure rediscovery", async () => { + for (const wsl of [false, true]) { + const requests: string[] = [] + const fixture = harness({ wsl, fetch: async (input, init) => { + requests.push(String(input)) + assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:selected").toString("base64")}`) + assert.equal(init?.redirect, "error") + return String(input).endsWith("/api/status") ? new Response(null, { status: 404 }) : Response.json({ healthy: true, version: "2.0.3", pid: 7 }) + } }) + assert.equal(runtimeIdentity((await fixture.service.discover())!)?.version, "2.0.3") + assert.equal(runtimeIdentity(await fixture.service.ensure())?.version, "2.0.3") + assert.equal(fixture.starts(), 0) + assert.equal(fixture.registrationReads(), 2) + assert.deepEqual(requests, [`${url}/api/status`, `${url}/api/health`, `${url}/api/status`, `${url}/api/health`]) + } +}) + +test("only absent records or refused sockets allow start; errors and occupied incompatible listeners fail closed", async () => { + const absent = harness({ registration: false }) + await absent.service.ensure() + assert.equal(absent.starts(), 1) + let attempts = 0 + const stale = harness({ fetch: async () => { + if (++attempts === 1) throw refused() + return Response.json({ version: "2.0.11", pid: 8, urls: [url] }) + } }) + await stale.service.ensure() + assert.equal(stale.starts(), 1) + for (const response of [ + async () => new Response(null, { status: 401 }), + async () => new Response(null, { status: 404 }), + async () => new Response(null, { status: 503 }), + async () => new Response("{invalid"), + async () => Response.json({ version: "2.0.11", pid: 8, urls: [url] }), + async () => { throw Object.assign(new Error("reset"), { code: "ECONNRESET" }) }, + async () => new Promise(() => {}), + async () => new Response(new ReadableStream({ start() {} })), + ]) { + const fixture = harness({ fetch: response, timeout: 30 }) + await assert.rejects(fixture.service.ensure()) + assert.equal(fixture.starts(), 0) + } + const hungRead = harness({ hangRead: true, timeout: 20 }) + await assert.rejects(hungRead.service.ensure(), /timed out/) + assert.equal(hungRead.starts(), 0) + const wsl = harness({ wsl: true, fetch: async () => { throw refused() } }) + await assert.rejects(wsl.service.ensure(), /WSL localhost forwarding/) + assert.equal(wsl.starts(), 0, "Windows refusal cannot establish Linux daemon absence") + let probes = 0 + const freshWsl = harness({ wsl: true, configOnly: true, fetch: async () => { + if (++probes === 1) throw refused() + return Response.json({ version: "2.0.11", pid: 8, urls: [url] }) + } }) + await freshWsl.service.ensure() + assert.equal(freshWsl.starts(), 1, "native registration ENOENT permits first start at an unoccupied configured port") + for (const response of [new Response(null, { status: 401 }), new Response("foreign listener")]) { + const occupiedWsl = harness({ wsl: true, configOnly: true, fetch: async () => response }) + await assert.rejects(occupiedWsl.service.ensure()) + assert.equal(occupiedWsl.starts(), 0, "registration absence does not admit an occupied incompatible endpoint") + } +}) + +test("ensure rediscovers a daemon that appeared after an earlier absent discovery", async () => { + let reads = 0, starts = 0 + const service = new HostOpenCodeService({ binary: process.execPath, timeoutMs: 500 }, { + execFile: async (_file, args) => { + if (args.at(-1) === "start") starts++ + return { stdout: args.at(-1) === "status" ? "stopped" : "selected", stderr: "" } + }, + readRegistration: async () => ++reads === 1 ? undefined : { url, pid: 7 }, + fetch: async () => Response.json({ version: "2.0.3", pid: 7, urls: [url] }), + }) + assert.equal(await service.discover(), undefined) + assert.equal(runtimeIdentity(await service.ensure())?.version, "2.0.3") + assert.equal(starts, 0) +}) diff --git a/packages/server/src/workspaces/native-service-registration.ts b/packages/server/src/workspaces/native-service-registration.ts new file mode 100644 index 000000000..d419e9e36 --- /dev/null +++ b/packages/server/src/workspaces/native-service-registration.ts @@ -0,0 +1,93 @@ +import { open, readdir } from "node:fs/promises" +import path from "node:path" +import { assertLoopbackServiceUrl } from "./service-state" +import { resolveDesktopPluginWslPath, type WslPathExecutor } from "../opencode/desktop-plugin-wsl-paths" + +const MAX_BYTES = 64 * 1024 +const MAX_CONFIGS = 64 +const serviceFile = /^service(?:-[a-zA-Z0-9._-]+)?\.json$/ + +export interface NativeServiceRegistration { + url: string + /** Present for a validated native registration; absent only after native + * registration ENOENT, when returning a configured endpoint to probe. */ + pid?: number +} + +/** Read-only recovery for CLI 2.0.11's info-only service discovery. These paths + * are exclusively for native service records, NEVER plugin/config provisioning. + * The selected CLI's credential uniquely identifies its channel's config file; + * ambiguity fails closed rather than guessing a channel, default port or PID. + */ +export async function readNativeServiceRegistration(input: { + stateDirectory: string + configDirectory: string + password: string + mapPath?: (nativePath: string) => string | Promise +}): Promise { + const map = input.mapPath ?? (value => value) + for (const directory of [input.stateDirectory, input.configDirectory]) { + if ((!path.posix.isAbsolute(directory) && !path.win32.isAbsolute(directory)) || /[\x00-\x1f\x7f]/.test(directory)) { + throw new Error("OpenCode returned an invalid native service directory") + } + } + const stateDirectory = await map(input.stateDirectory) + const configDirectory = await map(input.configDirectory) + const join = (root: string, name: string) => (root.startsWith("/") ? path.posix : path.win32).join(root, name) + const names = (await readdir(configDirectory)).filter(name => serviceFile.test(name)) + if (names.length > MAX_CONFIGS) throw new Error("Too many native service configurations") + const matches: Array<{ name: string; config: Record }> = [] + for (const name of names) { + const config = await readRecord(join(configDirectory, name)) + if (config?.password === input.password) matches.push({ name, config }) + } + if (matches.length !== 1) throw new Error("Cannot uniquely identify the selected OpenCode service channel") + const { name, config } = matches[0]! + const registration = await readRecord(join(stateDirectory, name)) + if (!registration) { + // A configured endpoint can still have an occupied listener without a + // registration. Authenticate it before allowing the CLI to start anything. + if (config.port === undefined) return undefined + if (!Number.isInteger(config.port) || Number(config.port) < 1 || Number(config.port) > 65535) throw new Error("Invalid native service port") + const host = config.hostname ?? "127.0.0.1" + if (typeof host !== "string" || !host || /[^a-zA-Z0-9.:[\]-]/.test(host)) throw new Error("Invalid native service hostname") + const url = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${config.port}` + assertLoopbackServiceUrl(url) + return { url } + } + if (typeof registration.url !== "string" || !Number.isSafeInteger(registration.pid) || Number(registration.pid) <= 0 + || registration.password !== input.password) throw new Error("Invalid native service registration or credential mismatch") + return { url: registration.url, pid: Number(registration.pid) } +} + +async function readRecord(file: string): Promise | undefined> { + const handle = await open(file, "r").catch(error => { + if (error?.code === "ENOENT") return undefined + throw new Error("Cannot read native service metadata") + }) + if (!handle) return undefined + try { + const buffer = Buffer.alloc(MAX_BYTES + 1) + let size = 0 + while (size < buffer.length) { + const read = await handle.read(buffer, size, buffer.length - size, null) + if (!read.bytesRead) break + size += read.bytesRead + } + if (size > MAX_BYTES) throw new Error("Native service metadata exceeds limit") + const record: unknown = JSON.parse(buffer.subarray(0, size).toString("utf8")) + if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("Invalid native service metadata") + return record as Record + } catch { + // Native records contain credentials: never include parsing/file contents. + throw new Error("Invalid native service metadata") + } finally { await handle.close() } +} + +export async function wslServiceMetadataPath(distro: string, nativePath: string, deadlineAt: number, execute?: WslPathExecutor): Promise { + if (!distro || /[\\/\x00-\x1f]/.test(distro) || !nativePath.startsWith("/") || /[\\\x00-\x1f]/.test(nativePath) + || nativePath.split("/").some(part => part === ".." || part === ".")) throw new Error("Invalid WSL native service metadata path") + // Reuse the bounded realpath/wslpath filesystem translator, including mounted + // and aliased roots. This does not discover or provision any plugin paths. + return (await resolveDesktopPluginWslPath(nativePath, distro, deadlineAt, () => {}, execute)).host +} diff --git a/packages/server/src/workspaces/native-service-start.test.ts b/packages/server/src/workspaces/native-service-start.test.ts index 9a8c1b8b0..6a2105d3e 100644 --- a/packages/server/src/workspaces/native-service-start.test.ts +++ b/packages/server/src/workspaces/native-service-start.test.ts @@ -24,6 +24,7 @@ test("desktop start alone crosses the native bridge for host and WSL lifecycles" return { stdout: args.at(-1) === "status" ? "stopped\n" : "password\n", stderr: "" } }, fetch: async () => Response.json({ version: "fixture", pid: 42, urls: ["http://127.0.0.1:4321"] }), + readRegistration: async () => undefined, } const startupEnvironment = { TEST_START_TOKEN: "test-token" } const service = kind === "host" @@ -35,9 +36,10 @@ test("desktop start alone crosses the native bridge for host and WSL lifecycles" assert.equal(requests[0].method, "opencode.service.start") assert.deepEqual(requests[0].params.args.slice(-2), ["service", "start"]) assert.equal(typeof requests[0].params.cwd, "string") - assert.equal(local.length, 2) + assert.equal(local.length, 9) assert.equal(local[0].at(-1), "status") - assert.equal(local[1].at(-1), "password") + assert.equal(local.at(-1)?.at(-1), "password") + assert.equal(local.some(args => args.at(-1) === "start"), false) if (kind === "host") { assert.equal(requests[0].params.env.TEST_START_TOKEN, "test-token") assert.equal(requests[0].params.env.OPENCODE_DB, undefined) @@ -58,8 +60,11 @@ test("a failed native start never falls back inside backend containment", async return { stdout: "stopped\n", stderr: "" } }, startFile: async () => { throw new Error("native host unavailable") }, + readRegistration: async () => undefined, }) assert.equal(await service.discover(), undefined) await assert.rejects(service.ensure(), /OpenCode start failed/) - assert.deepEqual(local, [["service", "status"]]) + assert.deepEqual(local, [...discovery, ...discovery]) }) + +const discovery = [["service", "status"], ["service", "get", "password"], ["debug", "paths", "state"], ["debug", "paths", "config"]] diff --git a/packages/server/src/workspaces/opencode-cli-service.ts b/packages/server/src/workspaces/opencode-cli-service.ts index 769c4c938..bf9db0972 100644 --- a/packages/server/src/workspaces/opencode-cli-service.ts +++ b/packages/server/src/workspaces/opencode-cli-service.ts @@ -6,7 +6,8 @@ import { assertLoopbackServiceUrl } from "./service-state" import { isOpenCodeServiceCommandUnavailable } from "./opencode-cli-compatibility" import type { OpenCodeServiceLifecycle } from "./opencode-service" import type { SpawnSpec } from "./spawn" -import { rememberRuntime } from "../opencode/compatibility/runtime" +import { rememberRuntime, runtimeIdentity } from "../opencode/compatibility/runtime" +import { readNativeServiceRegistration } from "./native-service-registration" export const MAX_SERVICE_OUTPUT_BYTES = 64 * 1024 const MAX_ERROR_CHARS = 1_024 @@ -31,6 +32,7 @@ export interface OpenCodeCliServiceDependencies { execFile: (file: string, args: string[], options: ServiceExecOptions) => Promise startFile?: OpenCodeCliServiceDependencies["execFile"] fetch: typeof globalThis.fetch + readRegistration: typeof readNativeServiceRegistration } export interface OpenCodeCliServiceOptions { @@ -39,6 +41,8 @@ export interface OpenCodeCliServiceOptions { command: (args: string[], start: boolean) => SpawnSpec beforeHealth?: (endpoint: Endpoint, deadlineAt: number) => Promise unreachableMessage?: (url: string) => string + serviceMetadataPath?: (nativePath: string, deadlineAt: number) => string | Promise + registrationRefusalIsAbsence?: boolean } export class OpenCodeCliService implements OpenCodeServiceLifecycle { @@ -50,16 +54,18 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { dependencies: Partial = {}, ) { this.timeoutMs = Math.max(1, options.timeoutMs) - this.dependencies = { execFile: executeFile, fetch: globalThis.fetch, ...dependencies } + this.dependencies = { execFile: executeFile, fetch: globalThis.fetch, readRegistration: readNativeServiceRegistration, ...dependencies } } async discover(deadlineAt = Date.now() + this.timeoutMs): Promise { const status = this.singleLine(await this.run(["service", "status"], false, deadlineAt), "status") - if (status === "stopped") return undefined + if (status === "stopped") return this.discoverRegistration(deadlineAt) return this.endpoint(status, deadlineAt) } async ensure(deadlineAt = Date.now() + this.timeoutMs): Promise { + const existing = await this.discover(deadlineAt) + if (existing) return existing const url = this.singleLine(await this.run(["service", "start"], true, deadlineAt), "start") return this.endpoint(url, deadlineAt) } @@ -84,6 +90,35 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { return endpoint } + private async discoverRegistration(deadlineAt: number): Promise { + const password = this.singleLine(await this.run(["service", "get", "password"], false, deadlineAt), "password") + if (!password) throw new Error(`${this.options.label} OpenCode service returned an empty password`) + const stateDirectory = this.singleLine(await this.run(["debug", "paths", "state"], false, deadlineAt), "state directory") + const configDirectory = this.singleLine(await this.run(["debug", "paths", "config"], false, deadlineAt), "config directory") + const registration = await this.withDeadline(this.dependencies.readRegistration({ stateDirectory, configDirectory, password, + mapPath: this.options.serviceMetadataPath ? nativePath => this.options.serviceMetadataPath!(nativePath, deadlineAt) : undefined }), deadlineAt, "registration discovery") + if (!registration) return undefined + const endpoint: Endpoint = { url: this.assertServiceUrl(registration.url), auth: { type: "basic", username: "opencode", password } } + try { + await this.options.beforeHealth?.(endpoint, deadlineAt) + await this.validateStatus(endpoint, deadlineAt) + } catch (error) { + // Only an OS-level refused connection proves a stale registration. A + // timeout, HTTP error, foreign listener or malformed metadata must block start. + if (error instanceof RefusedServiceConnection) { + // A config-only candidate has already proved native registration absence + // on the selected execution host (ENOENT). Do not mistake its configured + // port for a running WSL daemon. Real registrations always carry a PID; + // their Windows refusal still cannot establish Linux daemon absence. + if (registration.pid === undefined || this.options.registrationRefusalIsAbsence !== false) return undefined + throw new Error(this.options.unreachableMessage?.(endpoint.url) ?? error.message) + } + throw error + } + if (registration.pid !== undefined && runtimeIdentity(endpoint)?.pid !== registration.pid) throw new Error("Native service registration identity changed") + return endpoint + } + private async run(args: string[], start: boolean, deadlineAt: number): Promise { const commandLabel = args.join(" ") const timeout = this.remaining(deadlineAt, commandLabel) @@ -178,8 +213,9 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { redirect: "error", signal: AbortSignal.timeout(timeout), }), deadlineAt, `${kind} validation`) - } catch { + } catch (error) { const message = this.options.unreachableMessage?.(endpoint.url) + if (isConnectionRefused(error)) throw new RefusedServiceConnection(message) throw new Error(message ?? `Cannot reach the ${this.options.label} OpenCode service at ${endpoint.url}`) } } @@ -239,6 +275,18 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle { } } +class RefusedServiceConnection extends Error { + constructor(message?: string) { super(message ?? "Cannot reach OpenCode service: connection refused") } +} + +function isConnectionRefused(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const value = error as { code?: unknown; cause?: unknown; errors?: unknown } + if (value.code === "ECONNREFUSED") return true + if (Array.isArray(value.errors) && value.errors.length) return value.errors.every(isConnectionRefused) + return value.cause !== undefined && value.cause !== error && isConnectionRefused(value.cause) +} + function isServiceStatusResponse(value: unknown): boolean { if (!value || typeof value !== "object") return false const status = value as { version?: unknown; pid?: unknown; urls?: unknown } diff --git a/packages/server/src/workspaces/wsl-opencode-service.test.ts b/packages/server/src/workspaces/wsl-opencode-service.test.ts index 71f9635c4..f09b88a3c 100644 --- a/packages/server/src/workspaces/wsl-opencode-service.test.ts +++ b/packages/server/src/workspaces/wsl-opencode-service.test.ts @@ -35,7 +35,7 @@ describe("WslOpenCodeService", () => { it("discovers stopped and running services with exact CLI arguments and no shell", async () => { const stopped = harness({ status: "stopped\n" }) assert.equal(await stopped.service.discover(), undefined) - assert.equal(stopped.calls.length, 1) + assert.equal(stopped.calls.length, 4) assert.deepEqual(stopped.calls[0]?.args, [ "--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status", ]) @@ -74,6 +74,7 @@ describe("WslOpenCodeService", () => { const endpoint = await test.service.ensure() assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ + ...stoppedDiscovery, ["service", "start"], ["service", "get", "password"], ]) @@ -99,7 +100,7 @@ describe("WslOpenCodeService", () => { assert.equal(await test.service.discover(), undefined) await test.service.ensure() assert.deepEqual(test.calls.map((call) => call.args), [ - ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status"], + ...[...stoppedDiscovery, ...stoppedDiscovery].map(args => ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", ...args]), [ "--distribution", "Ubuntu", "--exec", "env", "NODE_EXTRA_CA_CERTS=/ca.pem", "PROVIDER_TOKEN=value with spaces", @@ -357,7 +358,7 @@ describe("WslOpenCodeService", () => { assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ ["service", "status"], ["service", "get", "password"], - ["service", "start"], + ["service", "status"], ["service", "get", "password"], ]) }) @@ -375,9 +376,10 @@ function harness( calls.push({ file, args, options }) const operation = args[args.length - 1] const key = operation === "status" || operation === "start" ? operation : "password" - return { stdout: output[key] ?? "", stderr: "" } + return { stdout: output[key] ?? (key === "status" ? "stopped\n" : "secret\n"), stderr: "" } }, fetch: async () => Response.json({ version: "2.0.4", pid: 123, urls: [url] }), + readRegistration: async () => undefined, ...overrides, } return { @@ -390,3 +392,5 @@ function harness( }, dependencies), } } + +const stoppedDiscovery = [["service", "status"], ["service", "get", "password"], ["debug", "paths", "state"], ["debug", "paths", "config"]] diff --git a/packages/server/src/workspaces/wsl-opencode-service.ts b/packages/server/src/workspaces/wsl-opencode-service.ts index a216dfb5f..2122b2148 100644 --- a/packages/server/src/workspaces/wsl-opencode-service.ts +++ b/packages/server/src/workspaces/wsl-opencode-service.ts @@ -4,6 +4,7 @@ import { type ServiceExecOptions, } from "./opencode-cli-service" import { daemonProcessEnvironment } from "./host-opencode-service" +import { wslServiceMetadataPath } from "./native-service-registration" const DEFAULT_TIMEOUT_MS = 30_000 @@ -24,6 +25,8 @@ export class WslOpenCodeService extends OpenCodeCliService { super({ label: "WSL", timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + serviceMetadataPath: (nativePath, deadlineAt) => wslServiceMetadataPath(options.distro, nativePath, deadlineAt), + registrationRefusalIsAbsence: false, command: (args, start) => ({ command: "wsl.exe", args: [ diff --git a/scripts/test-opencode-setup-desktop.mjs b/scripts/test-opencode-setup-desktop.mjs new file mode 100644 index 000000000..5381623a3 --- /dev/null +++ b/scripts/test-opencode-setup-desktop.mjs @@ -0,0 +1,195 @@ +// Windows release acceptance. All CLI commands and app launches use fresh synthetic +// profiles. CDP is discovered from the host's native dynamic instrumentation. +import assert from "node:assert/strict" +import { spawn, execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdtemp, mkdir, readFile, writeFile, readdir } from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { fileURLToPath } from "node:url" +import { setTimeout as delay } from "node:timers/promises" +import { chromium } from "playwright" +import { createServer } from "node:net" +import { boundedFixtureOperation } from "./fixtures/wsl-fixture-bounds.mjs" +import { stopFixtureChild } from "./native-fixture-guards.mjs" + +assert.equal(process.platform, "win32", "This fixture validates Windows desktop artifacts") +const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const root = await mkdtemp(path.join(os.tmpdir(), "opencode", "codenomad-setup-desktop-")) +console.log(`Evidence: ${root}`) +const requested = process.argv[2] ?? "both" +const bootstrapCli = process.argv[3] +assert.ok(bootstrapCli && path.isAbsolute(bootstrapCli), "Pass host (both/electron/tauri) and absolute fixture CLI used only to configure an isolated service port") +const oldDaemon = process.argv.includes("--old-daemon") +const resumeFolder = process.argv.includes("--resume-folder") +const artifacts = { + electron: path.join(workspace, "packages/electron-app/release/win-unpacked/CodeNomad.exe"), + tauri: path.join(workspace, "packages/tauri-app/target/release/codenomad-tauri.exe"), +} +const results = [] +async function until(fn, message, timeout = 90_000) { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { const value = await boundedFixtureOperation(fn, deadline, message); if (value) return value; await delay(200) } + throw new Error(message) +} +async function findPort(directory) { + for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) { + const name = path.join(directory, entry.name) + if (entry.name === "DevToolsActivePort") return Number((await readFile(name, "utf8")).split(/\r?\n/)[0]) + if (entry.isDirectory()) { const port = await findPort(name); if (port) return port } + } +} +for (const [host, executable] of Object.entries(artifacts)) { + if (requested !== "both" && requested !== host) continue + const profile = path.join(root, host) + await mkdir(profile) + const project = path.join(profile, "synthetic-project") + await mkdir(project) + const config = path.join(profile, "config.yaml") + await writeFile(config, JSON.stringify({ server: { opencodeBinary: "opencode2" }, ui: { locale: "en" } })) + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(PATH|OPENCODE_.*|XDG_.*|CLI_.*|CODENOMAD_.*|NODE_.*|ELECTRON_.*|WEBVIEW2_.*|npm_.*|HOME|USERPROFILE|APPDATA|LOCALAPPDATA)$/i.test(key))) + Object.assign(env, { + HOME: profile, USERPROFILE: profile, APPDATA: path.join(profile, "AppData/Roaming"), LOCALAPPDATA: path.join(profile, "AppData/Local"), + XDG_CONFIG_HOME: path.join(profile, "xdg-config"), XDG_DATA_HOME: path.join(profile, "xdg-data"), + // Native service start intentionally strips XDG_STATE_HOME. Match its + // synthetic HOME default so status/stop read the same isolated service record. + XDG_STATE_HOME: path.join(profile, ".local/state"), XDG_CACHE_HOME: path.join(profile, "xdg-cache"), + OPENCODE_TEST_HOME: profile, OPENCODE_CONFIG_DIR: path.join(profile, "opencode-config"), + OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_CONFIG_CONTENT: "{}", OPENCODE_DISABLE_MODELS_FETCH: "1", + CLI_CONFIG: config, CLI_LOG_LEVEL: "debug", ELECTRON_ENABLE_LOGGING: "1", PATH: `${process.env.SystemRoot}\\System32`, + npm_config_cache: path.join(profile, "npm-cache"), npm_config_userconfig: path.join(profile, "npmrc"), + }) + for (const key of ["APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "OPENCODE_CONFIG_DIR"]) await mkdir(env[key], { recursive: true }) + const reservation = createServer() + await new Promise(resolve => reservation.listen(0, "127.0.0.1", resolve)) + const servicePort = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const record = { host, executable, profile, servicePort, oldDaemon, sha256: createHash("sha256").update(await readFile(executable)).digest("hex"), checks: [] } + const resources = path.join(path.dirname(executable), "resources") + record.packagedFiles = {} + for (const relative of ["server/dist/opencode-update/service.js", "server/dist/workspaces/opencode-cli-service.js", "server/dist/workspaces/native-service-registration.js", "server/public/index.html", "node/win32-x64/node.exe", "node/win32-x64/node_modules/npm/package.json"]) { + record.packagedFiles[relative] = createHash("sha256").update(await readFile(path.join(resources, relative))).digest("hex") + } + record.sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace, encoding: "utf8", timeout: 10_000 }).trim() + results.push(record) + let output = "", browser, page, cli, child, stopped, childError + const runCli = (binary, args, timeout = 30_000) => execFileSync(binary, args, { env, cwd: profile, encoding: "utf8", timeout, maxBuffer: 1024 * 1024 }).trim() + const statuses = [] + const observerErrors = [], observerTasks = new Set() + const observe = observedPage => observedPage.on("response", response => { + if (!response.url().endsWith("/api/opencode/update") && !response.url().endsWith("/api/opencode/service")) return + const task = boundedFixtureOperation(async () => { + statuses.push({ url: response.url(), method: response.request().method(), request: response.request().postDataJSON(), status: response.status(), body: await response.json() }) + }, Date.now() + 10_000, "setup response observation").catch(error => observerErrors.push(error.message)).finally(() => observerTasks.delete(task)) + observerTasks.add(task) + }) + try { + runCli(bootstrapCli, ["service", "set", "port", String(servicePort)]) + if (oldDaemon) { + runCli(bootstrapCli, ["service", "start"], 60_000) + record.oldVersion = runCli(bootstrapCli, ["--version"]) + record.oldService = runCli(bootstrapCli, ["service", "status"]) + assert.equal(record.oldService, `http://127.0.0.1:${servicePort}`) + } + child = spawn(executable, resumeFolder ? ["--folder", project] : [], { cwd: profile, env, stdio: ["ignore", "pipe", "pipe"] }) + // Native-parent-started services may inherit a pipe after the desktop exits. + // Observe process exit separately; the isolated service is stopped below. + stopped = new Promise(resolve => child.once("exit", resolve)) + child.on("error", error => { childError = error }) + const append = chunk => { output = (output + chunk).slice(-4 * 1024 * 1024) } + child.stdout.on("data", append); child.stderr.on("data", append) + const port = await until(async () => { + if (childError) throw childError + if (child.exitCode !== null) throw new Error(`${host} exited ${child.exitCode}: ${output}`) + return await findPort(profile) + }, `${host}: dynamic CDP not found`) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: 30_000 }) + page = await until(() => browser.contexts().flatMap(context => context.pages()).find(candidate => /^https?:\/\/127\.0\.0\.1/.test(candidate.url())), `${host}: application page not found`) + page.setDefaultTimeout(90_000) + observe(page) + for (const context of browser.contexts()) context.on("page", observe) + await page.getByRole("dialog").waitFor() + await page.getByText(/^(OpenCode is not installed\.|OpenCode n’est pas installé\.)$/).waitFor() + await page.screenshot({ path: path.join(profile, "01-missing.png") }) + record.checks.push("real missing setup screen, minimum 2.0.11") + await page.getByRole("button", { name: /^(Close|Fermer)$/ }).click() + await page.locator('[role="status"] button').click() + await page.getByRole("dialog").waitFor() + record.checks.push("dismiss and persistent recovery reentry") + await page.getByRole("button", { name: /^(Install and start OpenCode|Installer et démarrer OpenCode)$/ }).click() + await until(async () => { + const versions = await readdir(path.join(profile, ".local/share/codenomad/opencode/selected")).catch(() => []) + if (!versions.length) return false + cli = path.join(profile, ".local/share/codenomad/opencode", versions[0], "node_modules/@opencode/cli/bin/opencode.exe") + return cli + }, `${host}: bundled npm install failed`, 300_000) + record.checks.push("bundled Node/npm installed real CLI with system Node and opencode absent from PATH") + if (oldDaemon) { + const restart = page.getByRole("button", { name: /^(Restart shared service|Redémarrer le service partagé)$/ }) + await until(async () => await restart.isVisible() || statuses.some(item => item.url.endsWith("/api/opencode/service") && item.method === "POST"), "Old daemon recovery did not settle") + assert.ok(await restart.isVisible(), "Old running daemon was bypassed: service activation occurred before explicit restart") + await page.screenshot({ path: path.join(profile, "02-explicit-restart.png") }) + assert.ok(!statuses.some(item => item.url.endsWith("/api/opencode/service") && item.method === "POST"), "install must not restart the old daemon") + record.checks.push("old running daemon detected after install, restart deferred for explicit action") + await restart.click() + } + await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 120_000 }) + await page.screenshot({ path: path.join(profile, "02-installed-connected.png") }) + record.cliVersion = runCli(cli, ["--version"]) + record.service = runCli(cli, ["service", "status"]) + record.checks.push("native-parent service activation, supported daemon admission, setup closes") + if (resumeFolder) { + record.workspaces = await until(async () => { + const data = await page.evaluate(async () => (await fetch("/api/workspaces", { signal: AbortSignal.timeout(10_000) })).json()) + return JSON.stringify(data).toLowerCase().includes("synthetic-project") && data + }, "Pending folder did not resume") + await page.screenshot({ path: path.join(profile, "03-folder-resumed.png") }) + record.checks.push("pending native --folder intent resumed after setup") + } else { + assert.ok(!statuses.some(item => item.request?.reload), "configuration reload must never happen automatically") + await page.getByRole("button", { name: "OpenCode", exact: true }).click() + const preferences = await until(() => browser.contexts().flatMap(context => context.pages()).find(candidate => candidate.url().includes("preferences")), "Native preferences page did not open") + preferences.setDefaultTimeout(90_000) + const reload = preferences.getByRole("button", { name: /^(Reload OpenCode configuration|Recharger la configuration OpenCode)$/ }) + await reload.waitFor() + await preferences.screenshot({ path: path.join(profile, "03-reload-warning.png") }) + const [response] = await Promise.all([ + preferences.waitForResponse(response => response.url().endsWith("/api/opencode/service") && response.request().postDataJSON()?.reload === true, { timeout: 60_000 }), + reload.click(), + ]) + assert.equal(response.status(), 200) + record.checks.push("native preferences explicit configuration reload, warning visible, authenticated reload succeeds; no automatic reload") + } + await boundedFixtureOperation(() => Promise.all(observerTasks), Date.now() + 12_000, "response observers") + assert.deepEqual(observerErrors, []) + record.pass = true + } catch (error) { + record.error = error.stack + if (page) { + await page.screenshot({ path: path.join(profile, "failure.png"), timeout: 5_000 }).catch(() => {}) + record.body = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "") + } + console.error(`${host}: ${error.message}`) + } finally { + const cleanupErrors = [] + if (browser) await boundedFixtureOperation(() => browser.close(), Date.now() + 5_000, "CDP cleanup").catch(error => cleanupErrors.push(error.message)) + // Kill only the exact fixture PID and its descendants, never installed hosts. + if (child?.pid && child.exitCode === null) { + try { execFileSync(path.join(process.env.SystemRoot, "System32/taskkill.exe"), ["/pid", String(child.pid), "/t", "/f"], { stdio: "pipe", timeout: 10_000 }) } + // taskkill can report a child that exited during its tree walk. The + // bounded exit check below determines whether our desktop actually exited. + catch (error) { record.taskkillNotice = error.message } + await stopFixtureChild(child, stopped).catch(error => cleanupErrors.push(error.message)) + } + try { record.cleanup = runCli(cli ?? bootstrapCli, ["service", "stop"]) } catch (error) { cleanupErrors.push(error.message) } + child?.stdout?.destroy(); child?.stderr?.destroy() + await boundedFixtureOperation(() => Promise.all(observerTasks), Date.now() + 12_000, "final response observers").catch(error => observerErrors.push(error.message)) + if (cleanupErrors.length || observerErrors.length) { record.pass = false; record.cleanupErrors = cleanupErrors; record.observerErrors = observerErrors } + await writeFile(path.join(profile, "host.log"), output) + await writeFile(path.join(profile, "setup-responses.json"), JSON.stringify(statuses, null, 2)) + await writeFile(path.join(root, "results.json"), JSON.stringify(results, null, 2)) + } +} +console.log(JSON.stringify(results, null, 2)) +assert.ok(results.length && results.every(result => result.pass), `Desktop acceptance failed; evidence: ${root}`) From 4a77d162eb34b2acd3d16bc6b189f459c5572787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 18:28:15 +0200 Subject: [PATCH 11/14] docs(opencode): record final native acceptance and gatekeeper closure Record completed historical migrations, bounded installation/reload fixtures, canonical WSL provisioning, cross-selection service-action fencing and packaged Electron/Tauri acceptance. Document the native watcher and reload semantics, read-only old-daemon registration fallback, final evidence roots and reproducible isolated commands. Close local review gates after every actionable finding was corrected and independently re-reviewed. Record the final 621-pass server suite and seven browser regressions while keeping final-head remote CI distinct from earlier green test jobs. --- .../references/sdk-critical-behaviors.md | 2 +- AGENTS.md | 3 +- dev-docs/OPENCODE_V2_POST_BETA.md | 116 +++++++++++++++--- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 1a3dca75a..903427a31 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -33,7 +33,7 @@ | Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | | Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | | Interactive PTYs | Separate native `pty.*` API | -| Service status/start/password | CodeNomad adapter using the selected host or WSL CLI; daemon stop remains external | +| Service status/start/password | CodeNomad adapter using the selected host or WSL CLI; only the explicit setup restart may stop/start the shared daemon | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | diff --git a/AGENTS.md b/AGENTS.md index 762d06f2f..953383e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +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. +- 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. Configuration reload is also explicit: native `location.reload` rebuilds every loaded location and cancels pending Forms/permissions, so never use it as an automatic watcher fallback. 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. +- A selected CLI's `service status` can report `stopped` for a live older daemon. Preserve the bounded, read-only registration fallback in `workspaces/native-service-registration.ts` and authenticate historical metadata before allowing start; `ensure()` repeats discovery. Native service lookup is separate from plugin-root discovery, which must still use the connected daemon's `config.get`. Never write service registration/configuration files from the backend. - 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`. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 2416e519d..7256c3f45 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -1,8 +1,9 @@ # OpenCode V2 stable-runtime transition **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. +in #728. Local/native acceptance includes both rebuilt Windows desktop hosts. +The autonomous correction/review loop finished with no remaining actionable +findings; remote CI for the final pushed diff is a separate release result. ## Release contract @@ -18,6 +19,12 @@ desktop acceptance are separate release gates. - 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. +- CLI `service status: stopped` is not sufficient absence evidence: a current + CLI can miss an older daemon's metadata route. The lifecycle performs bounded, + read-only native registration discovery through the selected execution host, + authenticates the registered endpoint against historical metadata, and repeats + discovery before `ensure()` may start. This lookup never supplies plugin roots + and never writes registration, port or database files. ## Setup and recovery @@ -33,32 +40,50 @@ Default host installations use bundled Node and the npm distributed in its pinne 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. +executable, then publishes a versioned installation and an immutable version +receipt. The highest published version wins; old `current` markers remain readable. 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; +Installation failures retain the previous selection. Concurrent requests coalesce; another backend's same-version installation is accepted only after verification. +Exclusive receipt creation is idempotent on Windows; a slower old-version install +cannot downgrade the selection published by another backend. 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. -Installation and service activation are separate. An old running daemon requires +Installation and service activation are separate. A below-minimum 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. +For an admitted but older daemon, `restart_available` keeps activation optional: +non-disruptive reconnect updates backend executable ownership so opening additional +workspaces remains possible while restart is deferred. Executable selection and +workspace execution-host eligibility are rechecked before service mutation. + +Mounted Windows discovery directories and Linux aliases are canonicalized through +the selected distro, then translated for host filesystem access. Native import +URLs and lease paths stay Linux-native; canonical outside-root storage is retained. +This does not repair OpenCode 2.0.11's mount/symlink watcher limitations. The existing +setup card therefore offers explicit **Reload OpenCode configuration** recovery. +Its copy explains that native reload rebuilds every loaded location and cancels +pending permissions/Forms and closes terminals/background commands for all clients. Reads, provisioning and ordinary +connect never invoke this disruptive operation automatically. Conflicting service +actions are refused while an explicit action is running. ## Historical migration `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 +historical 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. -Local Windows acceptance for **2.0.3 → 2.0.11** covers: +Local Windows and Linux (Ubuntu/WSL) acceptance for +**0.0.0-beta-19271 / 2.0.3 → 2.0.11** covers: - Three source location identities, including two workspace identities at one directory, with independently addressable session IDs and forks. @@ -66,6 +91,12 @@ Local Windows acceptance for **2.0.3 → 2.0.11** covers: 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. +- Historical provider/model configuration and a synthetic persisted provider + credential connection, including its identity/label and active state. +- Pending session/global Forms, compared against a same-version restart control. + These seeds lose pending Forms on an ordinary native restart too. Migration + matches that native durability rule, lists no stale Forms, and supports newly + created reply/cancel flows. This is not a claim of preserving ephemeral Forms. - **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. @@ -73,8 +104,8 @@ Local Windows acceptance for **2.0.3 → 2.0.11** covers: 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. +The migration fixture compares seeded provider connections and pending Forms; +it does not claim exhaustive coverage of every provider's OAuth implementation. ## Retirement disposition @@ -98,13 +129,68 @@ 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. -Open release gates, not completed local claims: +2026-09-20 follow-up evidence: -- 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. +| Check | Result | +| --- | --- | +| Complete final server suite | 621 passed, 2 skipped; no failures/cancellations. | +| Final managed-setup/API regressions | 22 covered in the final server suite, including real manager reconnect/reload, cross-selection serialization and concurrent receipt publication. | +| Setup browser regressions | 7 passed, including optional activation, explicit required restart, informed configuration reload and activation failure after successful installation. | +| Server/UI TypeScript | Passed. | +| Native old-seed migration | Both beta-19271 and 2.0.3 passed against 2.0.11 on Windows and Linux. | +| No-system-Node install | Passed Windows with packaged Node 24.20.0 and Linux with isolated Node/npm; POSIX lifecycle shell stays available without a system Node on PATH. | +| Feature gatekeeper | Three passes; five actionable findings corrected and regression-tested; final feature pass reports none. | +| Acceptance/CI gatekeeper | Added whole-run/request deadlines, bounded child termination and cursor-cycle rejection; three failure-guard tests pass and re-review reports none. | +| WSL path gatekeeper | Fixed stale overlapping-claim ownership and POSIX URL escaping; 19 path/install/lifecycle tests pass and re-review reports none. | +| Explicit reload gatekeeper | Fixed cross-binary serialization at shared-service authority scope; 16 focused service/route/manager tests pass and re-review reports none. | +| Real Windows→WSL | Native Linux/UNC, Windows mount and symlink-to-mount modes passed authenticated provisioning, heartbeat stability, explicit reload, restart and reconnect; all fixture daemons stopped. | +| Native reload effects | Active synthetic-provider stream completed with one request/no replay and unchanged daemon PID; pending Form was cancelled and original Shell/PTY became unavailable. | +| WSL fixture gatekeeper | Whole-run/request/cleanup bounds and immediate detached-consumer error handling are covered by seven guard regressions; final re-review reports none. | +| Windows release hosts | Rebuilt Electron and Tauri passed actual missing installation, bundled-Node setup, 2.0.3 daemon preservation until explicit restart, 2.0.11 activation, pending-folder continuation, and explicit configuration reload. Captures inspected and artifact/resource hashes retained. | +| Final discovery/desktop gatekeeper | Fresh WSL absence, alternate loopback, metadata translation and old-daemon detection are covered; 35 focused lifecycle tests pass. Independent final review reports no actionable findings and verifies the final artifact hashes/evidence. | +| Remote CI at `419fe6a6` | All test jobs passed: full tests, runtime contracts, cross-platform installation/migration and native pruning, Windows/macOS Tauri. Distribution builds were still running at the single follow-up check; later changes require their own CI result. | + +Remaining release result: + +- Green remote CI for the final pushed diff. Earlier test jobs at `419fe6a6` + passed across all configured platforms; that is not a substitute for the final + head's CI. No continuous CI monitoring is performed. + +The final discovery cross-check also covers a configured but absent WSL service, +registered-service forwarding failures, mounted/aliased metadata access and valid +non-default loopback addresses such as `127.0.0.2`. Registration absence and an +existing registered daemon remain distinct; configuration alone is not evidence +of a running process. + +### Reproduce desktop and WSL acceptance + +Windows desktop fixtures launch only the specified worktree's built artifacts, +use isolated native profiles/service ports and dynamically discover instrumentation: + +```text +node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_CURRENT_CLI +node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_CURRENT_CLI --resume-folder +node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_OLD_CLI --old-daemon --resume-folder +node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI native +node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI mounted +node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI aliased reload-safety +``` + +Final Windows desktop evidence directories (under the approved local temporary +`opencode/` directory) are `codenomad-setup-desktop-O0ScgO` (old daemon/restart/folder), +`codenomad-setup-desktop-FiVAai` (installation/reload), and +`codenomad-setup-desktop-Vk2RV8` (installation/folder). Each contains `results.json`, +artifact/resource hashes, per-host logs/API responses and screenshots. Final WSL +discovery runs are `codenomad-wsl-setup-6RvOIs`, `codenomad-wsl-setup-CqxLn8` and +`codenomad-wsl-setup-KSOQ1V`; the detailed reload characterization with final +consumer-error handling is `codenomad-wsl-setup-9tivGc`. + +Autonomous gatekeeper review led to regression fixes for optional +activation, stale-selection restart, cross-backend downgrade, same-version +Windows publication, non-disruptive workspace reconnect, fixture timeout/error +cleanup, WSL canonical path/ownership/URL handling, cross-selection service-action +serialization and old-daemon/fresh-WSL discovery. Every reported actionable finding +was corrected, revalidated and independently re-reviewed; the final pass is clear. Do not remove the remaining historical authority checks based solely on this synthetic seed. Keep the compatibility audit in From e76e4484aba218ec885d3bbdb9b088ec5795315f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 23:46:54 +0200 Subject: [PATCH 12/14] fix(opencode): ground compatibility retirement in native contracts Replace the arbitrary 2.0.11 floor with the 2.0.7 native step-timestamp boundary, keeping 2.0.11 as the separately recommended qualification target. Unlisted, prerelease and custom runtimes negotiate authenticated API requirements; preserve complete CLI labels so unknown builds neither fail solely by name nor enable unintended automatic upgrades. Finish retiring pre-2.0.4 location/worktree/credential/Form serialization and obsolete catalog events while preserving internal identity, import/cursor authorization, pruning claims and connection fences. Remove the unused global package-manager installer and clean generated server output before packaging so deleted modules cannot survive incremental builds. Distinguish missing setup, technical incompatibility and optional updates in the shared UI, including minimum rationale and optional restart wording. Retain a usable daemon through older discovery CLIs, check reload capability independently, and suppress installation when bundled npm is unavailable. Validated 622 server tests, 7 browser scenarios, real reducers and fixture guards, production native suites on 2.0.7 and 2.0.10, and synthetic 2.0.3-to-2.0.7 history migration. Independent gatekeeper re-review closed the production version-parser finding. CI now qualifies the source-defined minimum and latest; rebuilt desktop acceptance is recorded separately. --- .github/workflows/pr-build.yml | 17 +- .../codenomad-architecture-guide/SKILL.md | 4 +- .../references/sdk-api-reference.md | 2 +- .../references/sdk-critical-behaviors.md | 2 +- .../references/sdk-integration-patterns.md | 2 +- AGENTS.md | 2 +- MIGRATION_V2.md | 11 +- dev-docs/OPENCODE_V2_COMPATIBILITY.md | 26 +-- dev-docs/OPENCODE_V2_POST_BETA.md | 121 +++++++++--- packages/server/package.json | 3 +- packages/server/src/api-types.ts | 5 +- .../opencode-update/legacy-package.test.ts | 81 -------- .../src/opencode-update/legacy-package.ts | 29 --- .../managed-installation.test.ts | 5 +- .../opencode-update/managed-installation.ts | 2 + .../src/opencode-update/service.test.ts | 103 ++++++++-- .../server/src/opencode-update/service.ts | 160 ++++++--------- .../compatibility/contract-fixture.ts | 2 + .../opencode/compatibility/location.test.ts | 186 ++++++++---------- .../src/opencode/compatibility/location.ts | 47 ++--- .../opencode/compatibility/negotiate.test.ts | 21 +- .../src/opencode/compatibility/negotiate.ts | 15 +- .../compatibility/proxy-locations.test.ts | 66 ++++--- .../opencode/compatibility/proxy-locations.ts | 37 ++-- .../src/opencode/compatibility/runtime.ts | 6 +- .../opencode/compatibility/transport.test.ts | 7 +- .../src/opencode/compatibility/transport.ts | 8 +- .../src/opencode/runtime-support.test.ts | 16 +- .../server/src/opencode/runtime-support.ts | 32 ++- .../src/opencode/session-pruning/plugin.ts | 2 +- .../opencode/session-pruning/tui-reload.ts | 7 +- .../__tests__/instance-proxy-legacy.test.ts | 14 +- packages/server/src/server/http-server.ts | 25 ++- .../server/routes/binary-validation.test.ts | 4 +- .../server/src/server/routes/workspaces.ts | 2 +- .../src/server/routes/worktrees.test.ts | 8 +- .../workspaces/__tests__/binary-probe.test.ts | 4 +- .../__tests__/location-context-fixture.ts | 7 + .../server/src/workspaces/instance-events.ts | 1 - .../opencode-connection-generation.test.ts | 4 +- .../project-session-families.test.ts | 5 +- packages/server/src/workspaces/spawn.ts | 6 +- .../worktree-session-evacuation.test.ts | 6 +- .../settings/opencode-update-card.tsx | 15 +- .../ui/src/lib/i18n/messages/de/settings.ts | 8 +- .../ui/src/lib/i18n/messages/en/settings.ts | 8 +- .../ui/src/lib/i18n/messages/es/settings.ts | 8 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 8 +- .../ui/src/lib/i18n/messages/he/settings.ts | 8 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 8 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 8 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 8 +- .../ui/src/lib/i18n/messages/tr/settings.ts | 8 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 8 +- .../ui/src/stores/instance-invalidation.ts | 1 - packages/ui/src/stores/opencode-setup.ts | 4 +- .../ui/tests/browser/opencode-setup.test.ts | 37 ++-- scripts/test-opencode-setup-desktop.mjs | 34 +++- 58 files changed, 712 insertions(+), 572 deletions(-) delete mode 100644 packages/server/src/opencode-update/legacy-package.test.ts delete mode 100644 packages/server/src/opencode-update/legacy-package.ts create mode 100644 packages/server/src/workspaces/__tests__/location-context-fixture.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 294190d05..05f8ba084 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -138,6 +138,10 @@ jobs: needs: authorize if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} runs-on: windows-latest + strategy: + fail-fast: false + matrix: + runtime: [minimum, latest] timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -150,11 +154,16 @@ jobs: - run: npm ci --ignore-scripts - run: npm run build:pruning --workspace @neuralnomads/codenomad - run: npm run build:automation --workspace @neuralnomads/codenomad - - name: Qualify the pinned client against the latest stable runtime + - name: Qualify the pinned client against the technical minimum and latest stable shell: pwsh run: | $sandbox = Join-Path $env:RUNNER_TEMP 'opencode-contract-runtime' - npm install --prefix $sandbox '@opencode/cli@latest' --ignore-scripts --no-audit --no-fund + $version = '${{ matrix.runtime }}' + if ($version -eq 'minimum') { + $version = node --import tsx --input-type=module -e "import { MINIMUM_OPENCODE_VERSION } from './packages/server/src/opencode/runtime-support.ts'; console.log(MINIMUM_OPENCODE_VERSION)" + if ($LASTEXITCODE) { exit $LASTEXITCODE } + } + npm install --prefix $sandbox "@opencode/cli@$version" --ignore-scripts --no-audit --no-fund if ($LASTEXITCODE) { exit $LASTEXITCODE } $cli = Join-Path $sandbox 'node_modules/@opencode/cli-windows-x64/bin/opencode.exe' & $cli --version @@ -424,7 +433,9 @@ jobs: if ($LASTEXITCODE) { exit $LASTEXITCODE } Join-Path $seed "node_modules/$($entry.scope)/cli-$platform-$arch/bin/$($entry.command)$extension" } - foreach ($version in @('2.0.11', 'latest')) { + $minimum = node --import tsx --input-type=module -e "import { MINIMUM_OPENCODE_VERSION } from './packages/server/src/opencode/runtime-support.ts'; console.log(MINIMUM_OPENCODE_VERSION)" + if ($LASTEXITCODE) { exit $LASTEXITCODE } + foreach ($version in @($minimum, '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 } diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 464877aa8..ade24e693 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -16,9 +16,9 @@ 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. +- Runtime requirements live in `opencode/runtime-support.ts`; each blocking requirement needs demonstrated API/behavior evidence. Distinguish technically incompatible, recommended/tested and unverified versions. Setup uses bundled Node/npm and a versioned user prefix; shared-daemon restart is explicit and uses the existing native-parent launch bridge. Retire obsolete wire translations while retaining current identity/authority checks. See `dev-docs/OPENCODE_V2_POST_BETA.md` for precise retirement boundaries and validation evidence. -- 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. +- 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, but never infer the minimum from that version or from the dependency pin. Record technical minimum requirements and tested scenarios 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. - 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. - The server uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index fa30a9302..0cc1efb7c 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -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. -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. +Runtime requirements live in `packages/server/src/opencode/runtime-support.ts`: minimum 2.0.7 for native step-start timestamps, independently of recommended/tested 2.0.11. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Admission precedes functional requests/plugin provisioning; unlisted versions, including custom/prerelease/future labels, require authenticated bounded API recognition. Legacy request/response/event and live location translations are retired. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_POST_BETA.md` for precise boundaries, setup and migration evidence. - Promise client: `import { OpenCode } from "@opencode/client"` - Service authentication headers: `import { Service } from "@opencode/client/service"` diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 903427a31..9456aadc2 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -7,7 +7,7 @@ - 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. -- 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. +- The technical minimum is 2.0.7: native `session.step.started.data.started` is consumed directly after removing its older fallback. Recommendation/qualification 2.0.11 is independent. Unknown version labels, prereleases and future majors require bounded authenticated API recognition, including session environment support; they are not rejected solely by label. 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 diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index 0acc24ec9..872639e86 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -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 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 technical minimum is 2.0.7 for the native step-start timestamp, with 2.0.11 independently recommended/tested; old request/response/event and live location translations are retired. Preserve historical `workspaceID` validation internally: obsolete public selectors must be rejected rather than erased from imports, cursors, pending Forms or move rollback. Native 2.0.3→2.0.7/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. diff --git a/AGENTS.md b/AGENTS.md index 953383e3b..429d3ff77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ ## 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. Configuration reload is also explicit: native `location.reload` rebuilds every loaded location and cancels pending Forms/permissions, so never use it as an automatic watcher fallback. 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. +- OpenCode minimum requirements must follow demonstrated technical dependencies, never the latest published or solely tested version. Keep required, recommended/tested and unverified versions distinct in `opencode/runtime-support.ts` and setup diagnostics. Validate authenticated daemon metadata/contract before client use/plugin provisioning. Setup uses bundled Node/npm and a versioned user prefix; a running daemon restart is a separate explicit action. Configuration reload is also explicit: native `location.reload` rebuilds every loaded location and cancels pending Forms/permissions, so never use it as an automatic watcher fallback. Retire old wire translations without removing current identity/ownership checks. See `dev-docs/OPENCODE_V2_POST_BETA.md` for version boundaries and isolated validation evidence. - A selected CLI's `service status` can report `stopped` for a live older daemon. Preserve the bounded, read-only registration fallback in `workspaces/native-service-registration.ts` and authenticate historical metadata before allowing start; `ensure()` repeats discovery. Native service lookup is separate from plugin-root discovery, which must still use the connected daemon's `config.get`. Never write service registration/configuration files from the backend. - 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`. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index bd296ba41..8ef9b338d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -10,11 +10,11 @@ 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 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. +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). The technical minimum is **2.0.7**, which supplies the native `session.step.started.data.started` timestamp required by the current reducer after removing the fallback. **2.0.11** is recommended and release-tested. Unlisted/custom/prerelease/future versions require authenticated API recognition; an unfamiliar label alone is not incompatibility. 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. +Setup and Preferences reuse a card with distinct missing/incompatible/optional-update actions and separate minimum/recommended version information. Default host installation uses CodeNomad's bundled Node/npm, including when system Node is absent, into `~/.local/share/codenomad/opencode`. Node's archive already included npm; packaging now retains it. Custom/WSL binaries retain explicit execution-host instructions. Updating files does not update an already-running daemon: restart is separate and explicit, and remains optional for a usable daemon. 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. @@ -23,9 +23,8 @@ The isolated 2.0.3→2.0.11 migration fixture preserves session IDs, complete pa Qualification targets the latest published stable runtime. Maintain the [shared compatibility reference](dev-docs/OPENCODE_V2_COMPATIBILITY.md) in place; keep detailed acceptance results in PRs and CI logs. -Each CodeNomad release sets its minimum supported OpenCode version to the latest -stable available when that CodeNomad release is published. Record that minimum -in the release notes and keep it fixed for that release. +Record the technical dependency establishing the minimum in release notes. +Do not raise it merely because a newer stable version is published or tested. ### Stable 2.0.4 contract (PR #695) @@ -55,7 +54,7 @@ shared daemon is never stopped by this lifecycle. - Use native locations and `SessionInfo.location` as the authority for workspace, session, file, event, Shell, PTY, and Git worktree ownership. - Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. -- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. Stable V2's event union no longer includes `session.message.content.updated`; pruning uses its reviewed RPC invalidation and authoritative native rereads. +- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. The current durable event union still includes `session.message.content.updated`; retain its authoritative reread path even though the old public mutation API was removed. Pruning uses its reviewed RPC invalidation and authoritative native rereads. - Use `@opencode/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. - Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains independent of pending interruption state. - Replace shell-mode prompts with native `session.shell`. diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 958d0befd..79d3da76e 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -2,11 +2,11 @@ ## Current qualification policy -Qualify against the latest published stable OpenCode runtime (`@opencode/cli@latest`). -For each CodeNomad release, the minimum supported OpenCode version is the latest -stable OpenCode version available at the time of that CodeNomad release. Record -the resolved version in the release notes; that minimum stays fixed for that -CodeNomad release and is reassessed at the next release. +Qualify against the technical minimum and the latest published stable OpenCode +runtime (`@opencode/cli@latest`). A blocking minimum must follow a demonstrated +API/behavior dependency; the latest publication and client/plugin pin are not +sufficient reasons. Keep minimum requirements, recommended/tested versions and +unverified versions distinct. CI records the resolved runtime version. Pin client/plugin dependencies together to the release target and qualify them before publishing CodeNomad. Retained @@ -14,12 +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. +PR #696's corrected technical minimum is **2.0.7**, when native step-start events +gain the `data.started` field consumed by the current Solid reducer. **2.0.11** +is the recommended release-tested target, not the minimum. Unlisted versions, +including prereleases/custom labels/future majors, undergo authenticated contract +recognition rather than being refused solely for their label. Missing canonical +APIs or session environment support produce a concrete incompatibility reason. +The bundled Node/npm installer and explicit daemon restart are distinct actions. +Pre-2.0.4 wire adapters and the pre-2.0.7 timestamp fallback are retired, including +remaining legacy live location serializers. Historical internal identity, +current import/cursor authorization, cancellation and connection 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. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 7256c3f45..4254d0865 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -1,24 +1,35 @@ # OpenCode V2 stable-runtime transition **Implementation:** PR #696, based on #695 and the client/plugin 2.0.11 alignment -in #728. Local/native acceptance includes both rebuilt Windows desktop hosts. -The autonomous correction/review loop finished with no remaining actionable -findings; remote CI for the final pushed diff is a separate release result. +in #728. The first implementation was re-reviewed after the user challenged its +arbitrary version floor and incomplete cleanup. Earlier test/review results below +describe that earlier diff, not approval of its scope or the current correction. ## Release contract -- 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. +- The technical minimum is **2.0.7**. Published 2.0.4–2.0.6 events lack + `session.step.started.data.started`; 2.0.7 introduces it. The pinned Solid + reducer consumes that field directly for assistant-message creation times. + Retiring the timestamp fallback therefore requires this native event contract. +- **2.0.11 is recommended and release-tested**, independently of that minimum. + Being the latest publication or matching the client/plugin pin is not a + technical reason to reject 2.0.7–2.0.10. +- Stable versions below 2.0.7 and historical `0.0.0-beta-*` publications are + refused with a timestamp-contract explanation and HTTP 426. Unlisted versions, + future majors and custom/prerelease labels are unverified rather than rejected + by their label; they undergo authenticated bounded API recognition. - 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 +- Authenticated daemon metadata, not an older selected discovery CLI, controls + connection admission. Admission precedes functional requests 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. +- API recognition checks canonical session/permission/Form/inbox shapes and + the required `PUT /api/session/{sessionID}/environment` input. Missing required + APIs are reported separately from an untested version. Optional configuration + reload follows its own route presence. No speculative write establishes support. +- Native 2.0.7–2.0.10 tests with the 2.0.11 client/plugin passed prompt, Shell, + environment replacement and RPC scenarios. Their 113 paths / 136 operations + match 2.0.11. The 2.0.7/2.0.8 provider/model component schemas differ; these + results are not a claim of universal settings-shape interchangeability. - CLI `service status: stopped` is not sufficient absence evidence: a current CLI can miss an older daemon's metadata route. The lifecycle performs bounded, read-only native registration discovery through the selected execution host, @@ -28,21 +39,26 @@ findings; remote CI for the final pushed diff is a separate release result. ## Setup and recovery -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. +The global setup dialog and Preferences reuse `OpenCodeUpdateCard` and its store. +This is an implementation reuse choice, not a user requirement that all failures +have the same screen. Missing installations, known incompatibility and optional +updates expose different actions. Installed/running/minimum/recommended versions +and the technical reason are shown separately, with 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. Default host installations use bundled Node and the npm distributed in its pinned -official archive. Both Electron and Tauri resources include npm's complete +official archive. Previously the packaging script retained only the Node binary +and discarded npm; the archive itself already contained npm. Both hosts now 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 an immutable version receipt. The highest published version wins; old `current` markers remain readable. -No administrator rights, system Node, global npm install or application-resource +The old unused global npm/pnpm/bun/yarn installer and legacy-package removal path +are deleted. If npm is unavailable beside the server runtime, automatic installation +is not advertised. 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 selection. Concurrent requests coalesce; another backend's same-version installation is accepted only after verification. @@ -53,12 +69,12 @@ 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. -Installation and service activation are separate. A below-minimum running daemon requires +Installation and service activation are separate. A technically incompatible 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. +OpenCode. Unknown version labels/newer daemons cannot be downgraded by this action. For an admitted but older daemon, `restart_available` keeps activation optional: non-disruptive reconnect updates backend executable ownership so opening additional workspaces remains possible while restart is deferred. Executable selection and @@ -111,14 +127,19 @@ it does not claim exhaustive coverage of every provider's OAuth implementation. | Area | Implementation | | --- | --- | -| 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. | +| Audited beta/old-stable live-support list | Removed. Reviewed 2.0.7–2.0.11 contracts are recognized; others negotiate. | +| Legacy routes, methods and payload translation | `compatibility/requests.ts` removed; old V2/beta and 2.0.0–2.0.3 HTTP family. | +| Legacy HTTP inbox/status conversions | Removed from transport; pre-2.0.4 response shapes. Discovery remains separate. | +| Permission-event rename | Removed; pre-2.0.4 event contract. | +| Step timestamp fallback | Removed; absent through 2.0.6, native field available from 2.0.7. This establishes the technical minimum. | +| Legacy live location/worktree/credential/Form serialization | Removed, including workspace selector injection and legacy-positive request/import/cursor branches. | +| `catalog.updated` alias | Removed; current catalogs use resource-specific events. `session.message.content.updated` remains in the current durable event union and is retained. | | 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. | +| Historical location/context, import/cursor and pruning checks | Retained for internal identity and current directory authorization. Obsolete public selectors are rejected unconditionally. Original cursor bytes and independent import-history ownership are preserved. | | Legacy schema recognition | Retained to diagnose and reject a retired contract; no legacy serializer remains. | +| Existing CodeNomad plugin-entry/presence migration | Retained for upgrades/concurrent backends, independently of OpenCode runtime support. | +| Query-only pruning preview and TUI refresh synchronization | Retained: plugin 2.0.11 still lacks `session.message`, and publication races remain current. Stale beta-only comments were corrected. | ## Validation and release gates @@ -129,7 +150,9 @@ 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. -2026-09-20 follow-up evidence: +### Earlier implementation evidence (through `4a77d162`) + +These results predate the technical-floor correction and are retained as history: | Check | Result | | --- | --- | @@ -150,7 +173,7 @@ The existing cross-platform native pruning gate remains in place. | Final discovery/desktop gatekeeper | Fresh WSL absence, alternate loopback, metadata translation and old-daemon detection are covered; 35 focused lifecycle tests pass. Independent final review reports no actionable findings and verifies the final artifact hashes/evidence. | | Remote CI at `419fe6a6` | All test jobs passed: full tests, runtime contracts, cross-platform installation/migration and native pruning, Windows/macOS Tauri. Distribution builds were still running at the single follow-up check; later changes require their own CI result. | -Remaining release result: +Earlier remaining release result: - Green remote CI for the final pushed diff. Earlier test jobs at `419fe6a6` passed across all configured platforms; that is not a substitute for the final @@ -171,6 +194,7 @@ use isolated native profiles/service ports and dynamically discover instrumentat node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_CURRENT_CLI node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_CURRENT_CLI --resume-folder node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_OLD_CLI --old-daemon --resume-folder +node scripts/test-opencode-setup-desktop.mjs both ABSOLUTE_2_0_10_CLI --compatible-daemon --resume-folder node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI native node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI mounted node scripts/test-opencode-setup-wsl.mjs DISTRO ABSOLUTE_LINUX_CLI aliased reload-safety @@ -202,6 +226,43 @@ synthetic seed. Keep the compatibility audit in 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. +- 2026-09-20 initial implementation: minimum/client/plugin baseline 2.0.11. + **The user rejected the minimum rationale and the assistant's attribution of + implementation choices to an agreed scope.** That version-only policy is superseded. +- 2026-09-20 correction: the user accepts retiring older runtimes if a technical + reason is demonstrated. Minimum 2.0.7 follows the native step timestamp; + recommendation 2.0.11 remains separate. Complete the obsolete serializers' + retirement, keep current authority checks, and make optional updates non-blocking. + +## Correction evidence + +- Direct native checks on 2.0.7, 2.0.8, 2.0.9, 2.0.10 and 2.0.11 control: + 13 scenarios each passed, covering authenticated schema, config discovery, + current plugin/RPC loading, real local shell/environment snapshot replacement, + two-session isolation, synthetic provider prompt/wait/context and stable PID. + Evidence: approved temporary `opencode/pr696-native-207-210/REPORT.md` and + `results-2.0.7_2.0.8_2.0.9_2.0.10_2.0.11.json`. +- Corrected final server suite: 622 passed, 2 skipped; server/UI typechecks passed. +- Production-boundary native suites passed on 2.0.7: session environment, + locations/worktrees/relay, full pruning/proxy with rendered UI, and automation. + Environment and full pruning/proxy also passed on 2.0.10. Both versions passed + real compaction and pruning of pre-compaction history. CodeNomad does not emit + the differing provider/model compaction-settings shapes; its raw config editor + saves user-authored runtime-specific text. Evidence: `pr696-native-207-210/ACCEPTANCE.md`. +- Native 2.0.3→2.0.7 migration passed with 215-message histories, distinct old + identities, forks, provider configuration/connections and same-version Form + durability controls. Evidence: `codenomad-history-migration-IoFhtY`. +- Seven browser setup scenarios passed. Rendered captures distinguish minimum + 2.0.7 from recommendation 2.0.11 and keep 2.0.10 usable with optional update. + Evidence: approved temporary `opencode/696-rigorous-browser/`. +- CI now runs runtime qualification at the source-defined technical minimum and + latest, and migration targets read the same minimum rather than duplicating it. +- The independent review found a real CLI-parser mismatch: custom labels were + lost and `+build` suffixes truncated. Production parsing and HTTP/updater tests + now preserve those labels, with no automatic update/downgrade for unorderable + versions. Re-review closed all source/docs/CI findings; a separate final review + also approved the compatible-daemon fixture and generated-output clean step. +- Server builds clean generated `dist` before compilation; desktop acceptance + checks that deleted adapters/installers do not survive incremental packaging. +- Rebuilt-host acceptance and final remote CI are recorded in the PR after + completion; earlier green tests are not substituted for those results. diff --git a/packages/server/package.json b/packages/server/package.json index 8c7089e3f..815b6fcda 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,7 +17,8 @@ "codenomad": "dist/bin.js" }, "scripts": { - "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run build:pruning && npm run build:automation", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "npm run clean && npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run build:pruning && npm run build:automation", "build:pruning": "node ./scripts/build-session-pruning.mjs", "build:automation": "node ./scripts/build-automation-plugin.mjs", "build:ui": "npm run build --prefix ../ui", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index c29c29668..c8626dbc4 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -379,10 +379,13 @@ export interface OpenCodeUpdateStatus { canUpgrade: boolean checkError?: "update_check_failed" minimumVersion: string + recommendedVersion: string + versionAssessment: "tested" | "untested" | "incompatible" + incompatibilityReason?: "step_timestamp" | "canonical_api" | "session_environment" state: "missing" | "update_required" | "ready" | "error" binaryPath: string daemonVersion?: string - serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "error" + serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "incompatible" | "error" canReload?: boolean serviceError?: string target: "host" | "wsl" diff --git a/packages/server/src/opencode-update/legacy-package.test.ts b/packages/server/src/opencode-update/legacy-package.test.ts deleted file mode 100644 index 4549b8766..000000000 --- a/packages/server/src/opencode-update/legacy-package.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs" -import os from "node:os" -import path from "node:path" -import test from "node:test" -import childProcess from "node:child_process" -import { EventEmitter } from "node:events" -import { syncBuiltinESMExports } from "node:module" -import { legacyOpenCodeRemoval } from "./legacy-package" -import { installOpenCodeCli } from "./service" - -test("identifies old npm and pnpm shims without removing the current package", (t) => { - const root = mkdtempSync(path.join(os.tmpdir(), "codenomad-update-shim-")) - t.after(() => rmSync(root, { recursive: true, force: true })) - const shim = path.join(root, "opencode2.cmd") - writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n') - assert.deepEqual(legacyOpenCodeRemoval(shim, "npm"), { - command: "npm", args: ["uninstall", "-g", "@opencode-ai/cli"], - }) - writeFileSync(shim, '"%~dp0%\\global\\5\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n') - assert.deepEqual(legacyOpenCodeRemoval(shim, "pnpm"), { - command: "pnpm", args: ["remove", "-g", "@opencode-ai/cli"], - }) - writeFileSync(shim, '"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n') - assert.equal(legacyOpenCodeRemoval(shim, "npm"), undefined) - writeFileSync(shim, '# unrelated wrapper named opencode2\necho "custom"\n') - assert.equal(legacyOpenCodeRemoval(shim, "npm"), undefined) - assert.equal(legacyOpenCodeRemoval(path.join(root, "missing"), "npm"), undefined) -}) - -test("recognizes a linked native entry point without reading its executable contents", (t) => { - const root = mkdtempSync(path.join(os.tmpdir(), "codenomad-update-link-")) - t.after(() => rmSync(root, { recursive: true, force: true })) - const bin = path.join(root, "node_modules", "@opencode-ai", "cli", "bin") - mkdirSync(bin, { recursive: true }) - const target = path.join(bin, "opencode2.exe") - writeFileSync(target, Buffer.alloc(70 * 1024)) - const link = path.join(root, "linked-bin") - symlinkSync(bin, link, process.platform === "win32" ? "junction" : "dir") - assert.deepEqual(legacyOpenCodeRemoval(path.join(link, "opencode2.exe"), "bun"), { - command: "bun", args: ["remove", "-g", "@opencode-ai/cli"], - }) - assert.deepEqual(legacyOpenCodeRemoval(path.join(link, "opencode2.exe"), "yarn"), { - command: "yarn", args: ["global", "remove", "@opencode-ai/cli"], - }) - const standalone = path.join(root, "opencode2.exe") - writeFileSync(standalone, Buffer.alloc(70 * 1024)) - assert.equal(legacyOpenCodeRemoval(standalone, "npm"), undefined) -}) - -test("migration waits for legacy removal and aborts installation when removal fails", async (t) => { - const root = mkdtempSync(path.join(os.tmpdir(), "codenomad-update-migration-")) - t.after(() => rmSync(root, { recursive: true, force: true })) - const shim = path.join(root, "opencode2.cmd") - writeFileSync(shim, '"%dp0%\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n') - const calls: string[][] = [] - let removalExitCode = 0 - t.mock.method(childProcess, "spawn", (command: string, args: string[]) => { - calls.push([command, ...args]) - const child = new EventEmitter() - queueMicrotask(() => child.emit("exit", args.includes("uninstall") ? removalExitCode : 0, null)) - return child - }) - syncBuiltinESMExports() - t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports() }) - const env = { npm_config_prefix: root, npm_config_cache: path.join(root, "cache") } - const binary = { path: shim, label: "fixture" } - assert.deepEqual(await installOpenCodeCli(binary, "2.0.10", env), { success: true, version: "2.0.10" }) - assert.deepEqual(calls, [ - ["npm", "uninstall", "-g", "@opencode-ai/cli"], - ["npm", "install", "-g", "@opencode/cli@2.0.10"], - ]) - calls.length = 0 - removalExitCode = 1 - assert.equal((await installOpenCodeCli(binary, "2.0.10", env)).success, false) - assert.deepEqual(calls, [["npm", "uninstall", "-g", "@opencode-ai/cli"]]) - calls.length = 0 - writeFileSync(shim, '"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n') - assert.equal((await installOpenCodeCli(binary, "2.0.10", env)).success, true) - assert.deepEqual(calls, [["npm", "install", "-g", "@opencode/cli@2.0.10"]]) -}) diff --git a/packages/server/src/opencode-update/legacy-package.ts b/packages/server/src/opencode-update/legacy-package.ts deleted file mode 100644 index e5893fdb2..000000000 --- a/packages/server/src/opencode-update/legacy-package.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readFileSync, realpathSync, statSync } from "node:fs" -import type { OpenCodePackageManager } from "./service" - -const LEGACY_PACKAGE = "@opencode-ai/cli" -const LEGACY_BIN = /(?:^|[\\/])@opencode-ai[\\/]cli[\\/]bin[\\/]opencode2(?:\.exe)?(?:["'\s]|$)/ - -/** Migrate only an entry point belonging to the old V2 package. npm otherwise - * rejects the new package's opencode2 alias with EEXIST. Do not use --force to - * overwrite commands belonging to an unrelated installation. */ -export function legacyOpenCodeRemoval( - binaryPath: string, - manager: OpenCodePackageManager, -): { command: string; args: string[] } | undefined { - let legacy = false - try { - const resolved = realpathSync(binaryPath) - legacy = LEGACY_BIN.test(resolved) - // npm/pnpm Windows entry points are small scripts, not symlinks. Never read - // a whole native executable merely to identify its package. - if (!legacy && statSync(resolved).size <= 64 * 1024) { - legacy = LEGACY_BIN.test(readFileSync(resolved, "utf8")) - } - } catch { - return undefined - } - if (!legacy) return undefined - if (manager === "yarn") return { command: "yarn", args: ["global", "remove", LEGACY_PACKAGE] } - return { command: manager, args: [manager === "npm" ? "uninstall" : "remove", "-g", LEGACY_PACKAGE] } -} diff --git a/packages/server/src/opencode-update/managed-installation.test.ts b/packages/server/src/opencode-update/managed-installation.test.ts index 1a929b101..ce1f66df9 100644 --- a/packages/server/src/opencode-update/managed-installation.test.ts +++ b/packages/server/src/opencode-update/managed-installation.test.ts @@ -26,7 +26,10 @@ test("failed install never changes the selected version; verified install publis 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/) + await assert.rejects(installManagedOpenCode("2.0.6", options), /opencode_update_required/) + for (const version of ["../outside", "3.0.0-dev.1", "custom-build"]) { + await assert.rejects(installManagedOpenCode(version, options), /exact stable/) + } assert.equal(readManagedExecutable(root), binary) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/server/src/opencode-update/managed-installation.ts b/packages/server/src/opencode-update/managed-installation.ts index 1d2b2d51e..8496e2346 100644 --- a/packages/server/src/opencode-update/managed-installation.ts +++ b/packages/server/src/opencode-update/managed-installation.ts @@ -53,6 +53,8 @@ export async function installManagedOpenCode(version: string, options: { probe?: typeof probeBinaryVersion env?: NodeJS.ProcessEnv } = {}): Promise { + // An exact stable npm spec is an installation constraint, not runtime policy. + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("Installation requires an exact stable OpenCode version") assertSupportedOpenCode(version) const root = options.root ?? managedInstallRoot() const node = options.node ?? process.execPath diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index c443742cf..28dd179a4 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict" import test from "node:test" -import { OpenCodeUpdateService, OpenCodeUpdateError, buildOpenCodeUpgradeCommand, - resolveLatestOpenCodeVersion, detectOpenCodePackageManager, compareOpenCodeVersionStrings, +import { OpenCodeUpdateService, OpenCodeUpdateError, + resolveLatestOpenCodeVersion, 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" +import { probeBinaryVersion } from "../workspaces/spawn" function deps(overrides: Partial = {}): OpenCodeUpdateServiceDeps { let version = "0.0.0-beta-19271" @@ -15,12 +16,13 @@ function deps(overrides: Partial = {}): OpenCodeUpdat resolveLatestVersion: async () => minimum, canUpgradeBinary: () => true, upgradeBinary: async (_binary, target) => { version = target; return { success: true, version: target } }, + inspectRuntime: async () => ({ reload: true }), ...overrides, } } 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"]) { + for (const initial of [undefined, "0.0.0-beta-19271", "2.0.6"]) { let version = initial const service = new OpenCodeUpdateService(deps({ probeBinary: () => version ? { valid: true, version } : { valid: false, missing: true }, @@ -68,7 +70,7 @@ test("installation re-resolves the executable and rejects false success", async })) assert.equal((await service.upgrade()).version, minimum) const wrong = new OpenCodeUpdateService(deps({ - probeBinary: () => ({ valid: true, version: "2.0.10" }), + probeBinary: () => ({ valid: true, version: "2.0.6" }), upgradeBinary: async (_binary, target) => ({ success: true, version: target }), })) await assert.rejects(wrong.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed") @@ -76,7 +78,7 @@ test("installation re-resolves the executable and rejects false success", async test("coalesces overlapping installations and never downgrades a newer version", async () => { let upgrades = 0 - let version = "2.0.10" + let version = "2.0.6" let release!: () => void const gate = new Promise(resolve => { release = resolve }) const service = new OpenCodeUpdateService(deps({ @@ -94,7 +96,7 @@ test("coalesces overlapping installations and never downgrades a newer version", }) test("stale daemon needs explicit restart; status and installation never restart it", async () => { - let daemonVersion = "2.0.10", restarts = 0, reconnects = 0 + let daemonVersion = "2.0.6", restarts = 0, reconnects = 0 const endpoint = () => { const value: Endpoint = { url: "http://127.0.0.1:9876" } rememberRuntime(value, { version: daemonVersion, pid: 123, discovery: "info" }) @@ -138,7 +140,7 @@ test("activation coalesces repeated clicks and refuses a changed selection befor 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), + lifecycle: async () => ({ discover: async () => endpoint("2.0.6"), ensure: async () => endpoint(minimum), restart: async () => { restarts++; await gate; return endpoint(minimum) } }), reconnect: async () => { reconnects++ }, })) @@ -151,18 +153,19 @@ test("activation coalesces repeated clicks and refuses a changed selection befor assert.equal(reconnects, 0) }) -test("an unsupported latest or newer daemon cannot trigger a downgrade or restart", async () => { +test("an unverified newer major is not blocked but cannot be downgraded by 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", + resolveLatestVersion: async () => minimum, 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/) + assert.equal((await service.start()).serviceState, "ready") + assert.equal((await service.getStatus()).versionAssessment, "untested") await assert.rejects(service.start(true), /not an older runtime/) }) @@ -217,7 +220,7 @@ test("explicit configuration reload is admitted, fenced and serialized with serv release() await assert.rejects(first, /selection changed/) assert.equal(reloads, 0) - version = "2.0.10" + version = "2.0.6" await assert.rejects(service.reload(), /opencode_update_required/) assert.equal(reloads, 0) }) @@ -248,10 +251,78 @@ test("an in-flight native reload holds shared-service authority across binary ch await service.reload() }) -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") +test("beta version comparison remains numeric", () => { assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true) }) + +test("2.0.7 through 2.0.10 remain usable; recommendation only offers an optional update", async () => { + for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10"]) { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version, pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version }), resolveLatestVersion: async () => "2.0.11", + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { throw new Error("must retain daemon") } }), + })) + const status = await service.start() + assert.equal(status.minimumVersion, "2.0.7") + assert.equal(status.recommendedVersion, "2.0.11") + assert.equal(status.state, "ready") + assert.equal(status.serviceState, "ready") + assert.equal(status.versionAssessment, "untested", "native smoke coverage is not full release qualification") + assert.equal(status.incompatibilityReason, undefined) + assert.equal(status.canUpgrade, true) + assert.equal(status.canRestart, false) + } +}) + +test("a current daemon remains usable through an older selected discovery CLI", async () => { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: "2.0.11", pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: "2.0.3" }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { throw new Error("must retain daemon") } }), + })) + assert.equal((await service.start()).state, "ready") + assert.equal((await service.getStatus()).versionAssessment, "tested") +}) + +test("custom labels are unverified, not an update demand; optional reload follows capabilities", async () => { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: "custom-build", pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: "custom-build" }), + inspectRuntime: async () => ({ reload: false }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { throw new Error("must retain daemon") } }), + reload: async () => { throw new Error("unavailable operation must not be called") }, + })) + const status = await service.start() + assert.equal(status.state, "ready") + assert.equal(status.serviceState, "ready") + assert.equal(status.versionAssessment, "untested") + assert.equal(status.canReload, false) + assert.equal(status.canUpgrade, false) + await assert.rejects(service.upgrade(), /custom OpenCode version/) + await assert.rejects(service.reload(), /does not expose configuration reload/) +}) + +test("production stdout parsing preserves custom labels through discovery and activation", async () => { + for (const [stdout, label] of [["custom-build\n", "custom-build"], ["opencode v2.0.7+custom-build\n", "2.0.7+custom-build"], + ["opencode2 vendor-build\n", "vendor-build"], ["", "unknown"]]) { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: label!, pid: 123, discovery: "info" }) + let discoveries = 0, inspected = 0 + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => probeBinaryVersion(process.execPath, () => ({ status: 0, stdout })), + lifecycle: async () => ({ discover: async () => { discoveries++; return endpoint }, ensure: async () => { throw new Error("must retain daemon") } }), + inspectRuntime: async () => { inspected++; return { reload: false } }, + })) + const status = await service.start() + assert.equal(status.currentVersion, label) + assert.equal(status.state, "ready") + assert.equal(status.versionAssessment, "untested") + assert.equal(status.canUpgrade, false) + assert.equal(status.canRestart, false) + assert.ok(discoveries > 0 && inspected > 0, "successful custom labels reach authenticated contract inspection") + await assert.rejects(service.upgrade(), /custom OpenCode version/) + } +}) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 843d6e984..54a1e9a3b 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process" import { fetch } from "undici" import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types" import type { SettingsService } from "../settings/service" @@ -6,14 +5,14 @@ import { BinaryResolver, type ResolvedBinary } from "../settings/binaries" import type { WorkspaceManager } from "../workspaces/manager" 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 { assertSupportedOpenCode, isBelowOpenCodeMinimum, MINIMUM_OPENCODE_VERSION, RECOMMENDED_OPENCODE_VERSION, UnsupportedOpenCodeError } from "../opencode/runtime-support" +import { contractProfile, runtimeIdentity } from "../opencode/compatibility/runtime" +import { createRuntimeTransport } from "../opencode/compatibility/transport" +import type { Endpoint } from "@opencode/client/service" +import { bundledNpm, 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>() @@ -31,6 +30,7 @@ export interface OpenCodeUpdateServiceDeps { reconnect?: (binary: ResolvedBinary) => Promise admitActivation?: (binary: ResolvedBinary) => void reload?: (binary: ResolvedBinary, assertCurrent: () => void) => Promise + inspectRuntime?: (endpoint: Endpoint) => Promise<{ reload: boolean }> } export class OpenCodeUpdateError extends Error { @@ -64,12 +64,14 @@ export class OpenCodeUpdateService { 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 + : currentVersion && !isBelowOpenCodeMinimum(currentVersion) ? "ready" : "update_required" + const updateAvailable = latestVersion && (!currentVersion || comparableVersion(currentVersion)) + ? !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, + canUpgrade: !invalid && Boolean(updateAvailable) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary), + minimumVersion: MINIMUM_OPENCODE_VERSION, recommendedVersion: RECOMMENDED_OPENCODE_VERSION, + versionAssessment: "untested", state, binaryPath: binary.path, target: parseWslUncPath(binary.path) ? "wsl" : "host", canRestart: false, ...(!latestVersion ? { checkError: "update_check_failed" as const } : {}), } @@ -79,19 +81,42 @@ export class OpenCodeUpdateService { const endpoint = await lifecycle.discover() const identity = endpoint && runtimeIdentity(endpoint) status.daemonVersion = identity?.version - const olderDaemon = identity && currentVersion && /^(?:0|1|2)\./.test(identity.version) + const olderDaemon = identity && currentVersion && comparableVersion(identity.version) && comparableVersion(currentVersion) && compareOpenCodeVersionStrings(currentVersion, identity.version) > 0 - status.serviceState = !endpoint ? "stopped" : olderDaemon - ? supportsOpenCodeVersion(identity!.version) ? "restart_available" : "restart_required" - : identity && supportsOpenCodeVersion(identity.version) ? "ready" : "error" + status.serviceState = "stopped" + if (endpoint) { + try { + const capabilities = await this.inspectRuntime(endpoint) + status.serviceState = olderDaemon ? "restart_available" : "ready" + // The running daemon, not an older discovery CLI, serves requests. + status.state = "ready" + status.canReload = Boolean(this.deps.reload) && capabilities.reload + } catch (error) { + if (!(error instanceof UnsupportedOpenCodeError)) throw error + status.incompatibilityReason = error.reason + status.serviceState = olderDaemon ? "restart_required" : "incompatible" + } + } status.canRestart = Boolean(lifecycle.restart) && state === "ready" && (status.serviceState === "restart_required" || status.serviceState === "restart_available") - status.canReload = Boolean(this.deps.reload) && state === "ready" && Boolean(identity && supportsOpenCodeVersion(identity.version)) } catch { status.serviceState = "error"; status.serviceError = "service_check_failed" } } + const effectiveVersion = status.daemonVersion ?? currentVersion + if (!status.incompatibilityReason && effectiveVersion && isBelowOpenCodeMinimum(effectiveVersion)) status.incompatibilityReason = "step_timestamp" + status.versionAssessment = status.incompatibilityReason ? "incompatible" + : effectiveVersion === RECOMMENDED_OPENCODE_VERSION ? "tested" : "untested" return status } + private async inspectRuntime(endpoint: Endpoint): Promise<{ reload: boolean }> { + const identity = runtimeIdentity(endpoint) + if (!identity) throw new Error("OpenCode did not report an authenticated runtime version") + assertSupportedOpenCode(identity.version) + if (this.deps.inspectRuntime) return this.deps.inspectRuntime(endpoint) + await createRuntimeTransport(endpoint).profile() + return { reload: identity.contract?.reload ?? contractProfile(identity) === "modern" } + } + start(restart = false): Promise { return this.runServiceAction(restart ? "restart" : "start") } @@ -117,13 +142,13 @@ export class OpenCodeUpdateService { private async activate(binary: ResolvedBinary, action: ServiceAction): Promise { const restart = action === "restart" 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 (!previous || restart) assertSupportedOpenCode(installedVersion) if (restart && previous) { const identity = runtimeIdentity(previous) - if (!identity || !/^(?:0|1|2)\./.test(identity.version) + if (!identity || !comparableVersion(identity.version) || !comparableVersion(installedVersion) || compareOpenCodeVersionStrings(installedVersion, identity.version) <= 0) { throw new Error("The shared daemon is not an older runtime eligible for this update") } @@ -136,7 +161,9 @@ export class OpenCodeUpdateService { if (action === "reload") { const identity = previous && runtimeIdentity(previous) if (!identity || !this.deps.reload) throw new Error("OpenCode configuration reload unavailable") - assertSupportedOpenCode(identity.version) + const capabilities = await this.inspectRuntime(previous!) + if (!capabilities.reload) throw new Error("OpenCode does not expose configuration reload") + assertCurrent() // Explicit daemon-wide mutation: never use this as an automatic watcher // fallback, because native reload cancels pending Forms and permissions. await this.deps.reload(binary, assertCurrent) @@ -146,7 +173,7 @@ export class OpenCodeUpdateService { 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) + await this.inspectRuntime(endpoint!) if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation") await this.deps.reconnect?.(binary) return this.getStatus() @@ -170,6 +197,10 @@ export class OpenCodeUpdateService { const latestVersion = await this.readLatestVersion() assertSupportedOpenCode(latestVersion) + if (currentVersion && !comparableVersion(currentVersion)) { + throw new OpenCodeUpdateError("unsupported_binary", "Cannot order this custom OpenCode version for an automatic update") + } + if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) { return { success: true, version: currentVersion } } @@ -187,7 +218,7 @@ export class OpenCodeUpdateService { throw new OpenCodeUpdateError("upgrade_failed", result.error) } const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path) - if (!supportsOpenCodeVersion(installedVersion) || compareOpenCodeVersionStrings(installedVersion, latestVersion) < 0) { + if (isBelowOpenCodeMinimum(installedVersion) || !/^\d+\.\d+\.\d+$/.test(installedVersion) || compareOpenCodeVersionStrings(installedVersion, latestVersion) < 0) { throw new OpenCodeUpdateError( "upgrade_verification_failed", `OpenCode reported ${result.version}, but the configured binary is ${installedVersion} instead of ${latestVersion}`, @@ -208,11 +239,12 @@ export class OpenCodeUpdateService { throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid") } const result = probe ?? await this.deps.probeBinary(binaryPath) - const version = stripTagPrefix(result.version) - if (!result.valid || !version) { + if (!result.valid) { throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version") } - return version + // Successful unknown/empty labels are diagnostic information. Admission is + // established against the authenticated daemon, not inferred from this label. + return result.version?.trim() || "unknown" } private async readLatestVersion(): Promise { @@ -229,8 +261,6 @@ export class OpenCodeUpdateService { } } -export type OpenCodePackageManager = "npm" | "pnpm" | "bun" | "yarn" - export function compareOpenCodeVersionStrings(left: string, right: string): number { const leftBeta = stripTagPrefix(left)?.match(/^0\.0\.0-beta-(\d+)$/) const rightBeta = stripTagPrefix(right)?.match(/^0\.0\.0-beta-(\d+)$/) @@ -238,80 +268,6 @@ export function compareOpenCodeVersionStrings(left: string, right: string): numb return compareVersionStrings(left, right) } -export function detectOpenCodePackageManager( - binaryPath: string, - env: NodeJS.ProcessEnv = process.env, -): OpenCodePackageManager { - const pathSource = binaryPath.toLowerCase() - const launchSource = `${env.npm_config_user_agent ?? ""}\n${env.npm_execpath ?? ""}`.toLowerCase() - if (pathSource.includes("pnpm")) return "pnpm" - if (/[\\/]\.bun[\\/]/.test(pathSource)) return "bun" - if (pathSource.includes("yarn")) return "yarn" - if (/[\\/]npm[\\/]/.test(pathSource)) return "npm" - if (launchSource.includes("pnpm")) return "pnpm" - if (/(^|[\s/])bun(?:$|[\s/])/.test(launchSource)) return "bun" - if (launchSource.includes("yarn")) return "yarn" - return "npm" -} - -export function buildOpenCodeUpgradeCommand( - version: string, - packageManager: OpenCodePackageManager, -): { command: string; args: string[] } { - const packageSpec = `${OPENCODE_PACKAGE_NAME}@${version}` - if (packageManager === "pnpm") { - return { command: "pnpm", args: ["add", "-g", `--allow-build=${OPENCODE_PACKAGE_NAME}`, packageSpec] } - } - if (packageManager === "bun") { - return { command: "bun", args: ["install", "-g", "--trust", packageSpec] } - } - if (packageManager === "yarn") { - return { command: "yarn", args: ["global", "add", packageSpec] } - } - return { command: "npm", args: ["install", "-g", packageSpec] } -} - -export async function installOpenCodeCli( - binary: ResolvedBinary, - version: string, - env: NodeJS.ProcessEnv = process.env, -): Promise { - const manager = detectOpenCodePackageManager(binary.path, env) - const removal = legacyOpenCodeRemoval(binary.path, manager) - if (removal) { - const result = await runPackageManager(removal, version, env) - if (!result.success) return result - } - return runPackageManager(buildOpenCodeUpgradeCommand(version, manager), version, env) -} - -function runPackageManager( - upgrade: { command: string; args: string[] }, - version: string, - env: NodeJS.ProcessEnv, -): Promise { - return new Promise((resolve) => { - const child = spawn(upgrade.command, upgrade.args, { - env, - shell: process.platform === "win32", - stdio: "ignore", - windowsHide: true, - }) - child.once("error", (error) => resolve({ success: false, error: error.message })) - child.once("exit", (code, signal) => { - if (signal) { - resolve({ success: false, error: `OpenCode update stopped by signal ${signal}` }) - return - } - if (code !== 0) { - resolve({ success: false, error: `OpenCode update exited with code ${code ?? "unknown"}` }) - return - } - resolve({ success: true, version }) - }) - }) -} - type RegistryFetch = ( url: string, init: Parameters[1], @@ -346,7 +302,7 @@ export function createOpenCodeUpdateService( resolveLatestVersion: resolveLatestOpenCodeVersion, canUpgradeBinary: () => { const configured = settings.getOwner("config", "server").opencodeBinary - return !configured || configured === "opencode" || configured === "opencode2" + return (!configured || configured === "opencode" || configured === "opencode2") && Boolean(bundledNpm()) }, upgradeBinary: async (_binary, version) => { await installManagedOpenCode(version) @@ -358,3 +314,7 @@ export function createOpenCodeUpdateService( reload: (binary, assertCurrent) => workspaceManager.reloadConfigurationAfterSetup(binary.path, assertCurrent), }) } + +function comparableVersion(version: string): boolean { + return /^\d+\.\d+\.\d+$/.test(version) || /^0\.0\.0-beta-\d+$/.test(version) +} diff --git a/packages/server/src/opencode/compatibility/contract-fixture.ts b/packages/server/src/opencode/compatibility/contract-fixture.ts index 2407f3675..f04270eab 100644 --- a/packages/server/src/opencode/compatibility/contract-fixture.ts +++ b/packages/server/src/opencode/compatibility/contract-fixture.ts @@ -15,6 +15,8 @@ export const legacyContractFixture = { export const modernContractFixture = { paths: { + "/api/location/reload": { post: {} }, + "/api/session/{sessionID}/environment": { put: { requestBody: { content: { "application/json": { schema: { properties: { variables: { type: "object", additionalProperties: { type: "string" } } } } } } } } }, "/api/session/{sessionID}": { patch: {} }, "/api/form": { get: {} }, "/api/session/{sessionID}/form/{formID}": { delete: {} }, diff --git a/packages/server/src/opencode/compatibility/location.test.ts b/packages/server/src/opencode/compatibility/location.test.ts index 65cf51b7f..1d7085ed7 100644 --- a/packages/server/src/opencode/compatibility/location.test.ts +++ b/packages/server/src/opencode/compatibility/location.test.ts @@ -8,132 +8,110 @@ import { moveSessionToLocation, readLocationContext, readLocationRef, sameLocation, } from "./location" -test("identity context survives real modern generated move serialization", async () => { - const original = { directory: "/work/100% 工作", workspaceID: "workspace-1" } +test("generated calls preserve obsolete identity for rejection before upstream dispatch", async () => { + const endpoint = { url: "http://localhost:1234" } + rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" }) + const location = { directory: "/work/100% 工作", workspaceID: "workspace-1" } let calls = 0 - const client = OpenCode.make({ baseUrl: "http://localhost:1234", fetch: async (input, init) => { + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++ - const request = new Request(input, init) - const before = await request.json() - assert.deepEqual(before, { directory: original.directory }) - const headers = new Headers(request.headers) - const after = applyLocationContext(new URL(request.url), request.method, before, headers, "legacy") - assert.deepEqual(after, original) - assert.equal(headers.has(LOCATION_CONTEXT_HEADER), false) return new Response(null, { status: 204 }) - } }) - await moveSessionToLocation(client, "s", original) - assert.equal(calls, 1) + }) }) + const options = locationRequestOptions(location) + await assert.rejects(moveSessionToLocation(client, "s", location)) + 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, 0) }) -test("context parser refuses malformed, duplicate, unknown and modern identity selectors", () => { - const encoded = locationRequestOptions({ directory: "/repo", workspaceID: "w" })!.headers[LOCATION_CONTEXT_HEADER] - assert.deepEqual(readLocationContext(encoded, "legacy"), { directory: "/repo", workspaceID: "w" }) - for (const profile of ["modern", "unknown"] as const) assert.throws(() => readLocationContext(encoded, profile), /Unsupported/) - assert.throws(() => readLocationContext([encoded, encoded], "legacy")) - assert.throws(() => readLocationContext("%", "legacy")) - assert.throws(() => readLocationContext(encodeURIComponent('{"directory":"/repo","workspace":"w"}'), "legacy")) +test("context parser accepts explicit directories and refuses malformed or obsolete selectors", () => { + const location = { directory: "/repo" } + const encoded = locationRequestOptions(location, { includeDirectory: true })!.headers[LOCATION_CONTEXT_HEADER] + assert.deepEqual(readLocationContext(encoded), location) + assert.equal(locationRequestOptions(location), undefined) + const obsolete = locationRequestOptions({ ...location, workspaceID: "w" })!.headers[LOCATION_CONTEXT_HEADER] + assert.throws(() => readLocationContext(obsolete), /Unsupported/) + assert.throws(() => readLocationContext([encoded, encoded])) + assert.throws(() => readLocationContext("%")) + assert.throws(() => readLocationContext(encodeURIComponent('{"directory":"/repo","workspace":"w"}'))) assert.throws(() => readLocationRef({ directory: "/repo", workspaceID: null })) assert.equal(sameLocation({ directory: "/repo", workspaceID: "one" }, { directory: "/repo", workspaceID: "two" }), false) }) -test("global Forms and catalog requests preserve explicit identity without forwarding private headers", () => { - const ref = { directory: "/工作/100% ready", workspaceID: "worktree" } - const options = locationRequestOptions(ref)! +test("global Forms and catalog contexts validate directory scope and strip private headers", () => { + const ref = { directory: "/工作/100% ready" } + const options = locationRequestOptions(ref, { includeDirectory: true })! const headers = new Headers({ ...options.headers, "x-opencode-directory": encodeURIComponent(ref.directory) }) - applyLocationContext(new URL("http://localhost/api/session/global/form/f/reply"), "POST", { answer: {} }, headers, "legacy") - assert.equal(headers.get("x-opencode-workspace"), "worktree") + const answer = { answer: {} } + assert.equal(applyLocationContext(new URL("http://localhost/api/session/global/form/f/reply"), "POST", answer, headers), answer) + assert.equal(headers.has("x-opencode-workspace"), false) assert.equal(headers.has(LOCATION_CONTEXT_HEADER), false) const url = new URL("http://localhost/api/form") url.searchParams.set("location[directory]", ref.directory) - applyLocationContext(url, "GET", undefined, new Headers(options.headers), "legacy") - assert.equal(url.searchParams.get("location[workspace]"), "worktree") + applyLocationContext(url, "GET", undefined, new Headers(options.headers)) + assert.equal(url.searchParams.has("location[workspace]"), false) const foreign = new URL("http://localhost/api/form?location[directory]=/other") - assert.throws(() => applyLocationContext(foreign, "GET", undefined, new Headers(options.headers), "legacy"), /does not match/) - assert.throws(() => applyLocationContext(url, "GET", undefined, new Headers(options.headers), "modern"), /Unsupported/) + assert.throws(() => applyLocationContext(foreign, "GET", undefined, new Headers(options.headers)), /does not match/) + const foreignForm = new Headers({ ...options.headers, "x-opencode-directory": "/other" }) + assert.throws(() => applyLocationContext(new URL("http://localhost/api/session/global/form/f"), "DELETE", undefined, foreignForm), /does not match/) + url.searchParams.set("location[workspace]", "old") + assert.throws(() => applyLocationContext(url, "GET", undefined, new Headers(options.headers)), /Unsupported/) +}) + +test("move, creation and import validate context without reconstructing legacy body fields", () => { + const headers = () => new Headers(locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })!.headers) + for (const [pathname, body] of [ + ["/api/session/s/move", { directory: "/repo" }], + ["/api/session", { location: { directory: "/repo" } }], + ["/api/experimental/session/import", { location: { directory: "/repo" }, messages: [] }], + ] as const) { + assert.equal(applyLocationContext(new URL(`http://localhost${pathname}`), "POST", body, headers()), body) + const obsolete = "location" in body ? { ...body, location: { directory: "/repo", workspaceID: "old" } } : { ...body, workspaceID: "old" } + assert.throws(() => applyLocationContext(new URL(`http://localhost${pathname}`), "POST", obsolete, headers()), /Unsupported/) + } + const cursorUrl = new URL("http://localhost/api/session?cursor=native&directory=/repo") + assert.throws(() => applyLocationContext(cursorUrl, "GET", undefined, headers()), /Cursor already carries/) }) -test("retired runtimes cannot forward historical selectors through generated calls", async () => { +test("directory-only credential context remains global", async () => { const endpoint = { url: "http://localhost:1234" } - rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" }) - const location = { directory: "/repo", workspaceID: "wrk_one" } - const calls: URL[] = [] + rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" }) const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { const request = new Request(input, init) assert.equal(request.headers.has(LOCATION_CONTEXT_HEADER), false) - const url = new URL(request.url) - calls.push(url) - if (url.pathname === "/api/session") { - assert.equal(url.searchParams.get("directory"), "/repo") - assert.equal(url.searchParams.get("workspace"), "wrk_one") - assert.equal(url.searchParams.has("location[directory]"), false) - return Response.json({ data: [], cursor: {} }) - } - assert.equal(url.searchParams.get("location[directory]"), "/repo") - assert.equal(url.searchParams.get("location[workspace]"), "wrk_one") - return url.pathname.startsWith("/api/rpc/") ? Response.json({ output: true }) : new Response(null, { status: 204 }) + assert.equal(new URL(request.url).search, "") + return new Response(null, { status: 204 }) }) }) - const options = locationRequestOptions(location) - 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, 0, "conflicting cursor context never dispatches") + await client.credential.remove({ credentialID: "credential" }, locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })) }) -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) => { - const request = new Request(input, init) - assert.equal(request.headers.has(LOCATION_CONTEXT_HEADER), false) - assert.equal(new URL(request.url).search, version === "2.0.3" ? "?location%5Bdirectory%5D=%2Frepo" : "") - return new Response(null, { status: 204 }) - }) }) - await client.credential.remove({ credentialID: "credential" }, locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })) - } -}) -test("worktree refresh retains canonical project requests and adapts legacy directory context", () => { - const location = { directory: "/repo", workspaceID: "native" } - const legacyUrl = new URL("http://localhost/api/worktree/refresh") - const headers = new Headers(locationRequestOptions(location)?.headers) - assert.equal(applyLocationContext(legacyUrl, "POST", { projectID: "project" }, headers, "legacy"), undefined) - assert.equal(legacyUrl.searchParams.get("location[directory]"), "/repo") - assert.equal(legacyUrl.searchParams.get("location[workspace]"), "native") - const modernUrl = new URL("http://localhost/api/worktree/refresh") - const modernHeaders = new Headers(locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })?.headers) - assert.deepEqual(applyLocationContext(modernUrl, "POST", { projectID: "project" }, modernHeaders, "modern"), { projectID: "project" }) - assert.equal(modernUrl.search, "") -}) - -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 }> = [] - const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { - const request = new Request(input, init) - requests.push({ method: request.method, url: new URL(request.url), body: request.body ? await request.json() : undefined }) - if (request.method === "GET") return Response.json([{ directory: "/repo", strategy: "git" }]) - if (request.method === "POST") return Response.json({ directory: "/repo/.codenomad/worktrees/new" }) - return new Response(null, { status: 204 }) - }) }) - const options = locationRequestOptions({ directory: "/repo" }, { includeDirectory: true }) - await client.worktree.list({ projectID: "project" }, options) - await client.worktree.create({ projectID: "project", from: "/source", branch: "revision", directory: "/repo/.codenomad/worktrees", name: "new" }, options) - await client.worktree.remove({ projectID: "project", directory: "/repo/.codenomad/worktrees/new", force: false }, options) - const legacy = version === "2.0.3" - for (const request of requests) { - assert.equal(request.url.searchParams.get("location[directory]"), legacy ? "/repo" : null) - if (request.method === "GET") assert.equal(request.url.searchParams.get("projectID"), legacy ? null : "project") - else assert.equal(request.body.projectID, legacy ? undefined : "project") - } - assert.equal(requests[1].body.branch, "revision") - assert.equal(requests[1].body.from, "/source") - assert.equal(requests[2].body.directory, "/repo/.codenomad/worktrees/new") - assert.equal(requests[2].body.force, false) +test("generated worktree methods preserve project payloads without location serialization", async () => { + const endpoint = { url: "http://localhost:4321", auth: { type: "basic" as const, username: "fixture", password: "fixture" } } + rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" }) + const requests: Array<{ method: string; url: URL; body: any }> = [] + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { + const request = new Request(input, init) + assert.equal(request.headers.has(LOCATION_CONTEXT_HEADER), false) + requests.push({ method: request.method, url: new URL(request.url), body: request.body ? await request.json() : undefined }) + if (request.method === "GET") return Response.json([{ directory: "/repo", strategy: "git" }]) + if (request.method === "POST" && !request.url.endsWith("/refresh")) return Response.json({ directory: "/repo/.codenomad/worktrees/new" }) + return new Response(null, { status: 204 }) + }) }) + const options = locationRequestOptions({ directory: "/repo" }, { includeDirectory: true }) + await client.worktree.list({ projectID: "project" }, options) + await client.worktree.create({ projectID: "project", from: "/source", branch: "revision", directory: "/repo/.codenomad/worktrees", name: "new" }, options) + await client.worktree.remove({ projectID: "project", directory: "/repo/.codenomad/worktrees/new", force: false }, options) + await client.worktree.refresh({ projectID: "project" }, options) + for (const request of requests) { + assert.equal(request.url.searchParams.has("location[directory]"), false) + assert.equal(request.url.searchParams.has("location[workspace]"), false) + if (request.method === "GET") assert.equal(request.url.searchParams.get("projectID"), "project") + else assert.equal(request.body.projectID, "project") } + assert.equal(requests[1].body.branch, "revision") + assert.equal(requests[1].body.from, "/source") + assert.equal(requests[2].body.directory, "/repo/.codenomad/worktrees/new") + assert.equal(requests[2].body.force, false) }) diff --git a/packages/server/src/opencode/compatibility/location.ts b/packages/server/src/opencode/compatibility/location.ts index 52d139841..d7f136b96 100644 --- a/packages/server/src/opencode/compatibility/location.ts +++ b/packages/server/src/opencode/compatibility/location.ts @@ -1,5 +1,4 @@ import type { LocationRef, OpenCodeClient } from "@opencode/client" -import type { ContractProfile } from "./runtime" import { readLocationRef } from "../session-pruning/location" export { readLocationRef, sameLocation } from "../session-pruning/location" @@ -15,7 +14,7 @@ export function locationRequestOptions(location: LocationRef, options?: { includ } } -export function readLocationContext(value: string | string[] | undefined, profile: ContractProfile): LocationRef | undefined { +export function readLocationContext(value: string | string[] | undefined): LocationRef | undefined { if (value === undefined) return undefined if (typeof value !== "string" || value.length > 16_384) throw new Error("Invalid location context") const input: unknown = JSON.parse(decodeURIComponent(value)) @@ -23,7 +22,7 @@ export function readLocationContext(value: string | string[] | undefined, profil throw new Error("Invalid location context") } const location = readLocationRef(input) - if (location.workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + if (location.workspaceID !== undefined) throw new Error("Unsupported workspace selector") return location } @@ -31,60 +30,48 @@ export async function moveSessionToLocation(client: OpenCodeClient, sessionID: s await client.session.move({ sessionID, directory: location.directory }, locationRequestOptions(location)) } -// The selected transport calls this AFTER authorization. Only the explicitly -// supported wire slots are adapted; this is not a general header-to-body merge. -export function applyLocationContext(url: URL, method: string, body: unknown, headers: Headers, profile: ContractProfile): unknown { - const location = readLocationContext(headers.get(LOCATION_CONTEXT_HEADER) ?? undefined, profile) +// The transport calls this AFTER authorization. Validate the directory context +// against its public wire slot without injecting obsolete identity selectors. +export function applyLocationContext(url: URL, method: string, body: unknown, headers: Headers): unknown { + const location = readLocationContext(headers.get(LOCATION_CONTEXT_HEADER) ?? undefined) headers.delete(LOCATION_CONTEXT_HEADER) if (!location) return body + if (["workspace", "location[workspace]", "workspaceID", "location[workspaceID]"].some(key => url.searchParams.has(key)) + || headers.has("x-opencode-workspace")) throw new Error("Unsupported workspace selector") const input = body && typeof body === "object" && !Array.isArray(body) ? body as Record : undefined if ((url.pathname === "/api/worktree" && ["GET", "POST", "DELETE"].includes(method)) || (method === "POST" && url.pathname === "/api/worktree/refresh")) { - if (profile === "modern") return body - url.searchParams.delete("projectID") - url.searchParams.set("location[directory]", location.directory) - if (location.workspaceID !== undefined) url.searchParams.set("location[workspace]", location.workspaceID) - if (!input || url.pathname.endsWith("/refresh")) return undefined - const { projectID: _projectID, ...payload } = input - return payload + return body } const assertDirectory = (directory: unknown) => { if (directory !== location.directory) throw new Error("Location context does not match request") } if (method === "POST" && /^\/api\/session\/[^/]+\/move\/?$/.test(url.pathname)) { assertDirectory(input?.directory) - if (input?.workspaceID !== undefined && input.workspaceID !== location.workspaceID) throw new Error("Conflicting workspace selector") - return { ...input, workspaceID: location.workspaceID } + if (input?.workspaceID !== undefined) throw new Error("Unsupported workspace selector") + return body } - if (method === "POST" && /^\/api\/(?:experimental\/)?session(?:\/import)?\/?$/.test(url.pathname)) { + if (method === "POST" && /^\/api\/(?:session|experimental\/session\/import)\/?$/.test(url.pathname)) { const destination = readLocationRef(input?.location) assertDirectory(destination.directory) - if (destination.workspaceID !== undefined && destination.workspaceID !== location.workspaceID) throw new Error("Conflicting workspace selector") - return { ...input, location: { ...destination, workspaceID: location.workspaceID } } + if (destination.workspaceID !== undefined) throw new Error("Unsupported workspace selector") + return body } - if (/^\/api\/session\/global\/form(?:\/[^/]+(?:\/(?:reply|cancel|state))?)?\/?$/.test(url.pathname)) { + if (/^\/api\/session\/global\/form(?:\/[^/]+(?:\/reply)?)?\/?$/.test(url.pathname)) { assertDirectory(decodeURIComponent(headers.get("x-opencode-directory") ?? "")) - if (location.workspaceID !== undefined) headers.set("x-opencode-workspace", location.workspaceID) return body } if (method === "GET" && /^\/api\/session\/?$/.test(url.pathname)) { // The cursor is the complete native query, not a location selector to amend. if (url.searchParams.has("cursor")) throw new Error("Cursor already carries session list scope") assertDirectory(url.searchParams.get("directory")) - if (url.searchParams.has("workspace")) throw new Error("Conflicting workspace selector") - if (location.workspaceID !== undefined) url.searchParams.set("workspace", location.workspaceID) return body } if (/^\/api\/credential\/[^/]+(?:\/activate)?\/?$/.test(url.pathname)) { - // Modern credential mutations are global; legacy invalidation is scoped. - // The generated modern client has no location query to preserve for us. - if (profile !== "legacy") return body - if (url.searchParams.has("location[directory]")) assertDirectory(url.searchParams.get("location[directory]")) - url.searchParams.set("location[directory]", location.directory) + // Credential mutations are global; context must not create location scope. + return body } const directory = url.searchParams.get("location[directory]") assertDirectory(directory) - if (url.searchParams.has("location[workspace]")) throw new Error("Conflicting workspace selector") - if (location.workspaceID !== undefined) url.searchParams.set("location[workspace]", location.workspaceID) return body } diff --git a/packages/server/src/opencode/compatibility/negotiate.test.ts b/packages/server/src/opencode/compatibility/negotiate.test.ts index da3848e11..4cc0e830b 100644 --- a/packages/server/src/opencode/compatibility/negotiate.test.ts +++ b/packages/server/src/opencode/compatibility/negotiate.test.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict" import { test } from "node:test" import { createRuntimeFetch } from "./transport" import { rememberRuntime } from "./runtime" -import { modernContractFixture } from "./contract-fixture" +import { legacyContractFixture, modernContractFixture } from "./contract-fixture" +import { UnsupportedOpenCodeError } from "../runtime-support" test("unknown contracts perform authenticated bounded read negotiation before any mutation", async () => { for (const kind of ["unauthorized", "unrecognized", "oversized"] as const) { @@ -28,6 +29,24 @@ test("unknown contracts perform authenticated bounded read negotiation before an } }) +test("unknown labels with an obsolete contract or missing environment fail for a concrete API reason", async () => { + const missingEnvironment = structuredClone(modernContractFixture) + delete (missingEnvironment.paths as Record)["/api/session/{sessionID}/environment"] + for (const [schema, reason] of [[legacyContractFixture, "canonical_api"], [missingEnvironment, "session_environment"]] as const) { + const endpoint = { url: "http://127.0.0.1:4321" } + rememberRuntime(endpoint, { version: "custom-build", pid: 1, discovery: "info" }) + let calls = 0 + const transport = createRuntimeFetch(endpoint, async input => { + calls++ + assert.equal(new URL(String(input)).pathname, "/openapi.json") + return Response.json(schema) + }) + await assert.rejects(transport(`${endpoint.url}/api/session/s/prompt`, { method: "POST", body: '{"text":"fixture"}' }), + (error: unknown) => error instanceof UnsupportedOpenCodeError && error.reason === reason) + assert.equal(calls, 1, "no speculative prompt or environment mutation") + } +}) + 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: "2.0.100", pid: 1, discovery: "info" }) diff --git a/packages/server/src/opencode/compatibility/negotiate.ts b/packages/server/src/opencode/compatibility/negotiate.ts index b6ce090e2..a6126de1d 100644 --- a/packages/server/src/opencode/compatibility/negotiate.ts +++ b/packages/server/src/opencode/compatibility/negotiate.ts @@ -1,5 +1,6 @@ import { Service, type Endpoint } from "@opencode/client/service" import { runtimeIdentity, type ContractProfile } from "./runtime" +import { UnsupportedOpenCodeError } from "../runtime-support" type ObjectValue = Record function object(value: unknown): ObjectValue { @@ -40,8 +41,8 @@ export async function negotiateRuntime(endpoint: Endpoint, fetcher: typeof fetch ? object(document.components?.schemas?.[ref.slice("#/components/schemas/".length)]) : schema } const has = (path: string, method: string) => Boolean(object(paths[path])[method]) - const body = (path: string, field: string) => { - const operation = object(object(paths[path]).post) + const body = (path: string, field: string, method = "post") => { + const operation = object(object(paths[path])[method]) return field in object(resolve(operation.requestBody?.content?.["application/json"]?.schema).properties) } const session = "/api/session/{sessionID}" @@ -56,9 +57,15 @@ export async function negotiateRuntime(endpoint: Endpoint, fetcher: typeof fetch && body(`${session}/fork`, "boundary") && body(`${session}/command`, "command") && body(`${session}/permission/{requestID}/reply`, "reply") && "timeCreated" in inbox && !("time" in inbox) - if (modern === legacy) throw new Error(`Unsupported OpenCode contract: ${runtimeIdentity(endpoint)?.version ?? "unknown"}`) + if (modern === legacy) throw new UnsupportedOpenCodeError(runtimeIdentity(endpoint)?.version ?? "unknown", "canonical_api") + if (modern && !body(`${session}/environment`, "variables", "put")) { + throw new UnsupportedOpenCodeError(runtimeIdentity(endpoint)?.version ?? "unknown", "session_environment") + } const profile = modern ? "modern" : "legacy" const identity = runtimeIdentity(endpoint) - if (identity?.contract) identity.contract.profile = profile + if (identity?.contract) { + identity.contract.profile = profile + identity.contract.reload = has("/api/location/reload", "post") + } return profile } diff --git a/packages/server/src/opencode/compatibility/proxy-locations.test.ts b/packages/server/src/opencode/compatibility/proxy-locations.test.ts index c54339a67..f1c97d4e4 100644 --- a/packages/server/src/opencode/compatibility/proxy-locations.test.ts +++ b/packages/server/src/opencode/compatibility/proxy-locations.test.ts @@ -2,43 +2,59 @@ import assert from "node:assert/strict" import { test } from "node:test" import { decodeSessionListScope, prepareLocationImport, readRequestLocations } from "./proxy-locations" -test("request selectors preserve legacy authority and retain modern rejection", () => { - const url = new URL("http://localhost/api/form?location[directory]=/repo&location[workspace]=one") - assert.deepEqual(readRequestLocations(url, undefined, "/root", "legacy"), { - directories: ["/repo"], locations: [{ directory: "/repo", workspaceID: "one" }], invalid: false, +test("request selectors preserve directories and reject every obsolete workspace slot", () => { + const url = new URL("http://localhost/api/form?location[directory]=/repo") + assert.deepEqual(readRequestLocations(url, undefined, "/root"), { + directories: ["/repo"], locations: [{ directory: "/repo" }], invalid: false, }) - assert.equal(readRequestLocations(url, undefined, "/root", "modern").invalid, true) - url.searchParams.append("location[workspace]", "two") - assert.equal(readRequestLocations(url, undefined, "/root", "legacy").invalid, true) - for (const body of [{ location: { directory: "/repo", workspaceID: "one" } }, { directory: "/repo", workspaceID: "one" }]) { - const result = readRequestLocations(new URL("http://localhost/api/session"), body, "/root", "legacy") - assert.deepEqual(result.locations, [{ directory: "/repo", workspaceID: "one" }]) - assert.equal(readRequestLocations(new URL("http://localhost/api/session"), body, "/root", "modern").invalid, true) + for (const key of ["workspace", "location[workspace]", "workspaceID", "location[workspaceID]"]) { + const obsolete = new URL(url) + obsolete.searchParams.set(key, "one") + assert.equal(readRequestLocations(obsolete, undefined, "/root").invalid, true, key) + } + url.searchParams.append("location[directory]", "/other") + assert.equal(readRequestLocations(url, undefined, "/root").invalid, true) + for (const workspaceID of ["one", null]) { + for (const body of [{ location: { directory: "/repo", workspaceID } }, { directory: "/repo", workspaceID }]) { + assert.equal(readRequestLocations(new URL("http://localhost/api/session"), body, "/root").invalid, true) + } } }) -test("imports authorize each full historical identity without rewriting it", () => { +test("imports authorize every historical directory without rewriting it to the destination", () => { const body = { - location: { directory: "/root", workspaceID: "root" }, - info: { location: { directory: "/repo", workspaceID: "one" } }, - messages: [{ type: "location-switched", location: { directory: "/repo", workspaceID: "two" }, previous: { location: { directory: "/repo", workspaceID: "one" } } }], + location: { directory: "/root" }, + info: { location: { directory: "/repo" } }, + messages: [{ type: "location-switched", location: { directory: "/repo/moved" }, previous: { location: { directory: "/repo/original" } } }], } - const result = prepareLocationImport(body, "/default", "legacy") + const result = prepareLocationImport(body, "/default") assert.equal(result.invalid, false) - assert.deepEqual(result.locations.map(location => location.workspaceID), ["root", "one", "two", "one"]) + assert.deepEqual(result.directories, ["/root", "/repo", "/repo/moved", "/repo/original"]) assert.deepEqual(result.body, body) assert.notEqual(result.body, body) - assert.equal(prepareLocationImport(body, "/default", "modern").invalid, true) + for (const select of [ + (copy: typeof body) => copy.location, + (copy: typeof body) => copy.info.location, + (copy: typeof body) => copy.messages[0].location, + (copy: typeof body) => copy.messages[0].previous.location, + ]) { + const copy = structuredClone(body) + Object.assign(select(copy), { workspaceID: "historical" }) + const before = structuredClone(copy) + assert.equal(prepareLocationImport(copy, "/default").invalid, true) + assert.deepEqual(copy, before, "rejected history must not be rewritten") + } }) -test("cursor parsing preserves native scope while rejecting foreign-schema authority", () => { - const value = { directory: "/repo", workspace: "one", anchor: { id: "s", time: 1, direction: "next" } } +test("cursor parsing preserves native scope while rejecting obsolete and foreign-schema authority", () => { + const value = { directory: "/repo", anchor: { id: "s", time: 1, direction: "next" } } const cursor = Buffer.from(JSON.stringify(value)).toString("base64url") - assert.deepEqual(decodeSessionListScope(cursor, "legacy"), { directory: "/repo", workspaceID: "one" }) - assert.equal(decodeSessionListScope(cursor, "modern"), null) + assert.deepEqual(decodeSessionListScope(cursor), { directory: "/repo" }) const withFields = (fields: object) => Buffer.from(JSON.stringify({ ...value, ...fields })).toString("base64url") - assert.equal(decodeSessionListScope(withFields({ project: "other" }), "legacy"), null) - assert.equal(decodeSessionListScope(withFields({ workspaceID: "other" }), "legacy"), null) - assert.equal(decodeSessionListScope(withFields({ directory: undefined }), "legacy"), null) + for (const fields of [{ workspace: "one" }, { workspaceID: "one" }, { workspace: null }, { project: "other" }, { directory: undefined }]) { + assert.equal(decodeSessionListScope(withFields(fields)), null) + } + assert.deepEqual(decodeSessionListScope(withFields({ directory: undefined, project: "owned", subpath: "src" })), { project: "owned", subpath: "src" }) + assert.equal(decodeSessionListScope(withFields({ directory: undefined, project: "owned", subpath: "../foreign" })), null) assert.deepEqual(JSON.parse(Buffer.from(cursor, "base64url").toString()), value, "the native token is not rewritten") }) diff --git a/packages/server/src/opencode/compatibility/proxy-locations.ts b/packages/server/src/opencode/compatibility/proxy-locations.ts index dcc2aaf81..187e60fca 100644 --- a/packages/server/src/opencode/compatibility/proxy-locations.ts +++ b/packages/server/src/opencode/compatibility/proxy-locations.ts @@ -1,6 +1,5 @@ import path from "node:path" import type { LocationRef } from "@opencode/client" -import type { ContractProfile } from "./runtime" import { readLocationRef } from "./location" export interface RequestLocations { @@ -13,27 +12,25 @@ function object(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !Buffer.isBuffer(value) } -// Call on the canonical request before route translation. Public legacy query -// selectors use "workspace"; stored/body locations use "workspaceID". -export function readRequestLocations(url: URL, body: unknown, defaultDirectory: string, profile: ContractProfile): RequestLocations { +// Public requests are directory-scoped. Reject obsolete selectors explicitly so +// the generated client's field selection cannot silently discard authority. +export function readRequestLocations(url: URL, body: unknown, defaultDirectory: string): RequestLocations { const result: RequestLocations = { directories: [], locations: [], invalid: false } const add = (directory: unknown, workspaceID?: unknown) => { try { const ref = readLocationRef({ directory, workspaceID }) - if (workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + if (workspaceID !== undefined) throw new Error("Unsupported workspace selector") result.directories.push(ref.directory) result.locations.push(ref) } catch { result.invalid = true } } - for (const key of ["directory", "location[directory]", "workspace", "location[workspace]"]) { + for (const key of ["directory", "location[directory]"]) { if (url.searchParams.getAll(key).length > 1) result.invalid = true } - if (url.searchParams.has("workspaceID") || url.searchParams.has("location[workspaceID]")) result.invalid = true - for (const [directoryKey, workspaceKey] of [["directory", "workspace"], ["location[directory]", "location[workspace]"]]) { + if (["workspace", "location[workspace]", "workspaceID", "location[workspaceID]"].some(key => url.searchParams.has(key))) result.invalid = true + for (const directoryKey of ["directory", "location[directory]"]) { const directory = url.searchParams.get(directoryKey) ?? undefined - const workspaceID = url.searchParams.get(workspaceKey) ?? undefined - if (workspaceID !== undefined && workspaceKey === "workspace" && url.pathname.replace(/\/$/, "") !== "/api/session") result.invalid = true - if (directory !== undefined || workspaceID !== undefined) add(directory ?? defaultDirectory, workspaceID) + if (directory !== undefined) add(directory) } if (object(body)) { if ("workspace" in body) result.invalid = true @@ -49,7 +46,7 @@ export function readRequestLocations(url: URL, body: unknown, defaultDirectory: // Preserve the export/history identities; never rewrite every historical // location to the import destination. Each is independently authorized by the // caller and subsequently directory-translated at the existing proxy boundary. -export function prepareLocationImport(body: unknown, directory: string, profile: ContractProfile): RequestLocations & { body: unknown } { +export function prepareLocationImport(body: unknown, directory: string): RequestLocations & { body: unknown } { const result: RequestLocations & { body: unknown } = { body, directories: [], locations: [], invalid: false } if (!object(body)) return { ...result, invalid: true } const addLocation = (owner: Record, key: string) => { @@ -57,7 +54,7 @@ export function prepareLocationImport(body: unknown, directory: string, profile: if (!object(input) || "workspace" in input) { result.invalid = true; return } try { const location = readLocationRef({ ...input, directory: input.directory ?? directory }) - if (location.workspaceID !== undefined && profile !== "legacy") throw new Error("Unsupported workspace selector") + if (location.workspaceID !== undefined) throw new Error("Unsupported workspace selector") owner[key] = location result.directories.push(location.directory) result.locations.push(location) @@ -82,16 +79,13 @@ export interface SessionListScope { directory?: string project?: string subpath?: string - workspaceID?: string } -export function decodeSessionListScope(cursor: string, profile: ContractProfile): SessionListScope | null { +export function decodeSessionListScope(cursor: string): SessionListScope | null { if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null try { const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) - if (!object(value) || value.workspaceID !== undefined) return null - const workspaceID = value.workspace - if (workspaceID !== undefined && (profile !== "legacy" || typeof workspaceID !== "string" || !workspaceID.trim())) return null + if (!object(value) || value.workspaceID !== undefined || value.workspace !== undefined) return null const anchor = value.anchor if (!object(anchor) || typeof anchor.id !== "string" || !anchor.id || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) @@ -99,15 +93,14 @@ export function decodeSessionListScope(cursor: string, profile: ContractProfile) if (value.search !== undefined && typeof value.search !== "string") return null if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null if (value.parentID !== undefined && value.parentID !== null && typeof value.parentID !== "string") return null - const legacy = workspaceID === undefined ? {} : { workspaceID: workspaceID as string } if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { - return { directory: value.directory, ...legacy } + return { directory: value.directory } } if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { - if (value.subpath === undefined) return { project: value.project, ...legacy } + if (value.subpath === undefined) return { project: value.project } if (typeof value.subpath === "string" && !path.posix.isAbsolute(value.subpath) && !path.win32.isAbsolute(value.subpath) && !value.subpath.split(/[\\/]/).includes("..")) { - return { project: value.project, subpath: value.subpath, ...legacy } + return { project: value.project, subpath: value.subpath } } } // Workspace-only/unscoped tokens cannot prove directory authority. diff --git a/packages/server/src/opencode/compatibility/runtime.ts b/packages/server/src/opencode/compatibility/runtime.ts index a6f700d56..c471beb14 100644 --- a/packages/server/src/opencode/compatibility/runtime.ts +++ b/packages/server/src/opencode/compatibility/runtime.ts @@ -5,7 +5,7 @@ export interface RuntimeIdentity { pid: number discovery: "status" | "health" | "info" /** Filled only by a successful, authenticated schema negotiation. */ - contract?: { profile?: Exclude } + contract?: { profile?: Exclude; reload?: boolean } } // Keep authenticated daemon metadata out of public endpoints and credentials. @@ -22,6 +22,8 @@ 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 (identity.version === "2.0.11") return "modern" + // Publication contracts reviewed from the native timestamp boundary through + // the qualification target. Unlisted versions still negotiate their schema. + if (/^2\.0\.(?:7|8|9|10|11)$/.test(identity.version)) return "modern" return "unknown" } diff --git a/packages/server/src/opencode/compatibility/transport.test.ts b/packages/server/src/opencode/compatibility/transport.test.ts index e7e5e9026..ef3c5679b 100644 --- a/packages/server/src/opencode/compatibility/transport.test.ts +++ b/packages/server/src/opencode/compatibility/transport.test.ts @@ -6,7 +6,8 @@ import { createRuntimeFetch } from "./transport" import { modernContractFixture } from "./contract-fixture" test("current and future supported releases preserve info, authentication and bounded negotiation", async () => { - for (const version of ["2.0.11", "2.0.100"]) { + const reviewed = ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11"] + for (const version of [...reviewed, "2.0.100", "3.0.0", "2.1.0-dev.1", "custom-build"]) { 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[] = [] @@ -19,12 +20,12 @@ test("current and future supported releases preserve info, authentication and bo }) const client = OpenCode.make({ baseUrl: endpoint.url, fetch }) assert.equal((await client.server.info()).paths.tmp, "/native/tmp") - assert.deepEqual(seen, version === "2.0.11" ? ["/api/info"] : ["/openapi.json", "/api/info"]) + assert.deepEqual(seen, reviewed.includes(version) ? ["/api/info"] : ["/openapi.json", "/api/info"]) } }) 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"]) { + for (const version of ["2.0.0", "2.0.3", "2.0.4", "2.0.6", "0.0.0-beta-19271"]) { 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") }) diff --git a/packages/server/src/opencode/compatibility/transport.ts b/packages/server/src/opencode/compatibility/transport.ts index f67446276..e61aad822 100644 --- a/packages/server/src/opencode/compatibility/transport.ts +++ b/packages/server/src/opencode/compatibility/transport.ts @@ -1,6 +1,6 @@ import { Service, type Endpoint } from "@opencode/client/service" import { contractProfile, runtimeIdentity } from "./runtime" -import { assertSupportedOpenCode } from "../runtime-support" +import { assertSupportedOpenCode, UnsupportedOpenCodeError } from "../runtime-support" import { negotiateRuntime } from "./negotiate" import { applyLocationContext, LOCATION_CONTEXT_HEADER } from "./location" @@ -22,10 +22,10 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch signal?.throwIfAborted() lifetime.throwIfAborted() if (identity) assertSupportedOpenCode(identity.version) - if (profile === "legacy") throw new Error("Unsupported OpenCode runtime contract") + if (profile === "legacy") throw new UnsupportedOpenCodeError(identity?.version ?? "unknown", "canonical_api") if (profile !== "unknown") return profile negotiation ??= negotiateRuntime(endpoint, fetcher, lifetime).then(value => { - if (value !== "modern") throw new Error("Unsupported OpenCode runtime contract") + if (value !== "modern") throw new UnsupportedOpenCodeError(identity?.version ?? "unknown", "canonical_api") profile = value return value }).catch(error => { negotiation = undefined; throw error }) @@ -51,7 +51,7 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch 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) + body = applyLocationContext(url, request.method, body, headers) if (body !== undefined && !object(body)) throw new Error("Invalid OpenCode request body") options.body = body === undefined ? undefined : JSON.stringify(body) headers.delete("content-length") diff --git a/packages/server/src/opencode/runtime-support.test.ts b/packages/server/src/opencode/runtime-support.test.ts index cb8147b5d..b63f08221 100644 --- a/packages/server/src/opencode/runtime-support.test.ts +++ b/packages/server/src/opencode/runtime-support.test.ts @@ -1,26 +1,30 @@ 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 { isBelowOpenCodeMinimum, UnsupportedOpenCodeError, MINIMUM_OPENCODE_VERSION, RECOMMENDED_OPENCODE_VERSION } 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("native timestamp minimum is distinct from recommendation and unfamiliar version labels", () => { + assert.equal(MINIMUM_OPENCODE_VERSION, "2.0.7") + assert.equal(RECOMMENDED_OPENCODE_VERSION, "2.0.11") + for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11", "2.0.12", "2.1.0", "2.0.11-dev.1", "3.0.0", "custom-build"]) { + assert.equal(isBelowOpenCodeMinimum(version), false, version) + } + for (const version of ["2.0.0", "2.0.4", "2.0.5", "2.0.6", "0.0.0-beta-19507"]) assert.equal(isBelowOpenCodeMinimum(version), true, version) }) 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" }) + rememberRuntime(endpoint, { version: "2.0.6", 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") + && error.actualVersion === "2.0.6" && error.minimumVersion === "2.0.7" && error.reason === "step_timestamp") assert.equal(constructed, 0) assert.equal(prepared, 0) }) diff --git a/packages/server/src/opencode/runtime-support.ts b/packages/server/src/opencode/runtime-support.ts index 5d8f6a881..f52c03e0b 100644 --- a/packages/server/src/opencode/runtime-support.ts +++ b/packages/server/src/opencode/runtime-support.ts @@ -1,25 +1,41 @@ -/** Fixed for this CodeNomad release; never derive admission from npm at runtime. */ -export const MINIMUM_OPENCODE_VERSION = "2.0.11" +/** + * 2.0.7 introduces session.step.started.data.started. The current Solid reducer + * consumes this native timestamp directly; earlier releases need a retired + * event adapter. This is a technical floor, not the latest qualified release. + */ +export const MINIMUM_OPENCODE_VERSION = "2.0.7" +/** Qualification target, independent of the technically required minimum. */ +export const RECOMMENDED_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) +export function isBelowOpenCodeMinimum(version: string): boolean { + // These historical V2 beta publications predate the stable event contract. + if (/^0\.0\.0-beta-\d+$/.test(version)) return true + const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/.exec(version) + // Custom/prerelease labels are unverified, not automatically incompatible. + // Their authenticated API contract is inspected before functional requests. 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!)) + return major! < minimumMajor! || (major === minimumMajor && (minor! < minimumMinor! + || (minor === minimumMinor && patch! < minimumPatch!))) } +export type OpenCodeIncompatibility = "step_timestamp" | "canonical_api" | "session_environment" + 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`) + constructor(readonly actualVersion: string, readonly reason: OpenCodeIncompatibility = "step_timestamp") { + super(`${OPENCODE_UPDATE_REQUIRED}: OpenCode ${actualVersion}; ${reason === "step_timestamp" + ? `requires the native session.step.started.data.started timestamp introduced in ${MINIMUM_OPENCODE_VERSION}` + : reason === "session_environment" ? "requires PUT /api/session/{sessionID}/environment with variables" + : "does not expose the canonical session, permission, Form and inbox API contract"}`) this.name = "UnsupportedOpenCodeError" } } export function assertSupportedOpenCode(version: string): void { - if (!supportsOpenCodeVersion(version)) throw new UnsupportedOpenCodeError(version) + if (isBelowOpenCodeMinimum(version)) throw new UnsupportedOpenCodeError(version) } diff --git a/packages/server/src/opencode/session-pruning/plugin.ts b/packages/server/src/opencode/session-pruning/plugin.ts index 758061b57..765f02318 100644 --- a/packages/server/src/opencode/session-pruning/plugin.ts +++ b/packages/server/src/opencode/session-pruning/plugin.ts @@ -23,7 +23,7 @@ export default Plugin.define({ if (!sameLocation(location, readLocationRef(ctx.location)) || session.projectID !== ctx.location.project.id) { return { status: "blocked", reason: "not_deletable" } as const } - // Plugin Context does not expose session.message in beta-19398. Read + // The pinned plugin SessionDomain does not expose session.message. Read // the explicit DB in query-only mode, including pre-compaction history. const data = await readPruningPreview(pruningDatabasePath(ctx.options.databasePath, ctx.app.channel), target, location, session.projectID) const preview = data ? previewContent(data) : { status: "blocked", reason: "unavailable" } as const diff --git a/packages/server/src/opencode/session-pruning/tui-reload.ts b/packages/server/src/opencode/session-pruning/tui-reload.ts index 6bf018bee..32f44ed90 100644 --- a/packages/server/src/opencode/session-pruning/tui-reload.ts +++ b/packages/server/src/opencode/session-pruning/tui-reload.ts @@ -21,9 +21,8 @@ function hasPagination(message: SessionCache["message"]): message is SessionCach } export function createPruningReload(cache: SessionCache) { - // These are public @opencode/client/solid APIs. beta-19419's plugin Data - // declaration omits them, but its audited TUI host passes the original cache - // directly (f91c6d8b25, packages/tui/src/plugin/api.tsx: data: host.data). + // These are public @opencode/client/solid APIs. The TUI plugin's narrower + // Data declaration does not guarantee their presence on the supplied cache. // Fail explicitly if that capability changes; invalidate/sync alone is unsafe. const messages = cache.message if (!hasPagination(messages)) throw new Error("Unsupported TUI cache: pagination publication API is unavailable") @@ -41,7 +40,7 @@ export function createPruningReload(cache: SessionCache) { try { do { state.dirty = false - // beta-19419 tracks pagination independently of invalidate/sync. Join + // The native cache tracks pagination independently of invalidate/sync. Join // the public loadMore publication before the authoritative read; an // older page could otherwise reinsert deleted blocks after sync. // With no `all` option, loadMore joins an active successful page rather diff --git a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts index ad25f4a14..ab04b2f79 100644 --- a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts @@ -63,10 +63,17 @@ test("modern proxy preserves Forms scope and rejects obsolete selectors in curso { method: "DELETE" as const, url: `${prefix}/api/session/global/form/f`, headers: { ...context("two"), "x-opencode-directory": encodeURIComponent("/host") } }, { method: "PATCH" as const, url: `${prefix}/api/session/foreign`, payload: { title: "forbidden" } }, { method: "GET" as const, url: `${prefix}/api/session?cursor=${cursor("two")}` }, + ...["workspace", "location[workspace]", "workspaceID", "location[workspaceID]"].map(key => ({ + method: "GET" as const, url: `${prefix}/api/session?cursor=${cursor()}&${key}=one`, + })), { method: "POST" as const, url: `${prefix}/api/experimental/session/import`, payload: { location: { directory: "/host", workspaceID: "one" }, info: { location: { directory: "/host", workspaceID: "one" } }, messages: [{ type: "location-switched", location: { directory: "/host", workspaceID: "two" } }], } }, + { method: "POST" as const, url: `${prefix}/api/experimental/session/import`, payload: { + location: { directory: "/host" }, info: { location: { directory: "/host" } }, + messages: [{ type: "location-switched", location: { directory: "/host" }, previous: { location: { directory: "/foreign" } } }], + } }, ]) 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() } @@ -76,17 +83,16 @@ test("future releases advertising retired schemas are refused before functional const upstream = Fastify() const calls: string[] = [] upstream.get("/openapi.json", async () => { calls.push("schema"); return legacyContractFixture }) - upstream.get("/api/form/request", async request => { + upstream.all("/api/*", async () => { calls.push("forms") - assert.equal((request.query as Record)["location[workspace]"], "one") - return { data: [], location: { directory: "/repo", workspaceID: "one" } } + assert.fail("a retired contract must not receive functional calls") }) 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: "2.0.100", pid: 1, discovery: "info" }) const service = new OpenCodeSharedService() try { - await assert.rejects(service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }), /Unsupported OpenCode runtime contract/) + await assert.rejects(service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }), { code: "opencode_update_required" }) assert.deepEqual(calls, ["schema"]) } finally { await service.shutdown(); await upstream.close() } }) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 031063855..48bd43d82 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -44,7 +44,6 @@ import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" import { forwardRuntimeRequest } from "../opencode/compatibility/proxy" -import { contractProfile, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime" import { LOCATION_CONTEXT_HEADER, locationRequestOptions, readLocationContext } from "../opencode/compatibility/location" import { decodeSessionListScope, prepareLocationImport, readRequestLocations, type SessionListScope } from "../opencode/compatibility/proxy-locations" import type { RemoteProxySessionManager } from "./remote-proxy" @@ -661,9 +660,9 @@ async function proxyWorkspaceRequest(args: { return } - const profile = connection ? await connection.profile() : contractProfile(runtimeIdentity(endpoint)) + await connection?.profile() let locationContext: LocationRef | undefined - try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER], profile) } + try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER]) } catch { return reply.code(400).send({ error: "Invalid location context" }) } const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix) @@ -705,7 +704,7 @@ async function proxyWorkspaceRequest(args: { const sessionListHasScope = request.method === "GET" && pathname.replace(/\/+$/, "") === "/api/session" && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project")) - const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, profile, connection?.client) + const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client) if (sessionListScope !== "allowed") { reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) return @@ -741,9 +740,8 @@ async function proxyWorkspaceRequest(args: { request.method, stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory), serviceDirectory, - profile, ) - const requestLocations = readRequestLocations(targetUrl, imported.body, workspace.path, profile) + const requestLocations = readRequestLocations(targetUrl, imported.body, workspace.path) const explicitLocations = [...requestLocations.locations] if (locationContext) { requestLocations.directories.push(locationContext.directory) @@ -1148,16 +1146,15 @@ async function authorizeSessionList( method: string, manager: InstanceProxyWorkspaceManager, workspaceId: string, - profile: ContractProfile, client?: OpenCodeClient, ): Promise<"allowed" | "invalid" | "foreign"> { if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed" const cursors = targetUrl.searchParams.getAll("cursor") if (cursors.length > 1) return "invalid" if (cursors.length === 1) { - const scope = decodeSessionListScope(cursors[0], profile) + const scope = decodeSessionListScope(cursors[0]) if (!scope) return "invalid" - for (const key of ["directory", "location[directory]", "project", "subpath", ...(profile === "legacy" ? ["workspace", "location[workspace]"] : [])]) { + for (const key of ["directory", "location[directory]", "project", "subpath"]) { targetUrl.searchParams.delete(key) } return ownsSessionListScope(manager, workspaceId, scope, client) @@ -1176,7 +1173,7 @@ async function authorizeSessionList( const project = projects[0] const subpath = subpaths[0] if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid" - return ownsSessionListScope(manager, workspaceId, { project, subpath, workspaceID: targetUrl.searchParams.get("workspace") ?? undefined }, client) + return ownsSessionListScope(manager, workspaceId, { project, subpath }, client) } function isSafeRelativePath(value: string): boolean { @@ -1190,7 +1187,7 @@ async function ownsSessionListScope( client?: OpenCodeClient, ): Promise<"allowed" | "foreign"> { if (scope.directory) { - const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory, workspaceID: scope.workspaceID }, client) + const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client) return owned ? "allowed" : "foreign" } if (!scope.project) return "foreign" @@ -1201,7 +1198,7 @@ async function ownsSessionListScope( : /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical) ? path.win32.resolve(project.canonical, scope.subpath) : path.posix.resolve(project.canonical, scope.subpath) - const owned = await manager.ownsLocation(workspaceId, { directory, workspaceID: scope.workspaceID }, client) + const owned = await manager.ownsLocation(workspaceId, { directory }, client) return owned ? "allowed" : "foreign" } @@ -1425,10 +1422,10 @@ function replacePromptFileUris(body: unknown, replacements: ReadonlyMap { @@ -65,7 +65,7 @@ it("reserves the physical worktree and rejects a same-HEAD replacement before de }, active: async () => ({}), move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { - current.location = readLocationContext(options?.headers["x-codenomad-location"], "legacy") ?? { directory } + current.location = readInternalLocationContext(options?.headers["x-codenomad-location"]) ?? { directory } }, get: async () => structuredClone(current), }, @@ -134,12 +134,12 @@ it("fails a direct delete call closed when session evacuation fails", async () = project: { list: async () => [{ id: "project" }] }, debug: { location: { list: async () => [{ directory: path.join(temp, "unrelated"), workspaceID: "root-location" }] } }, shell: { list: async (_input: unknown, options?: { headers: Record }) => ({ - data: blocker === "shell" && readLocationContext(options?.headers["x-codenomad-location"], "legacy")?.workspaceID === "root-location" + data: blocker === "shell" && readInternalLocationContext(options?.headers["x-codenomad-location"])?.workspaceID === "root-location" ? [{ id: "sh_blocker", status: "running", cwd: target }] : [], }) }, pty: { list: async (_input: unknown, options?: { headers: Record }) => ({ - data: blocker === "pty" && readLocationContext(options?.headers["x-codenomad-location"], "legacy")?.workspaceID === "root-location" + data: blocker === "pty" && readInternalLocationContext(options?.headers["x-codenomad-location"])?.workspaceID === "root-location" ? [{ id: "pty_blocker", status: "running", cwd: target }] : [], }) }, diff --git a/packages/server/src/workspaces/__tests__/binary-probe.test.ts b/packages/server/src/workspaces/__tests__/binary-probe.test.ts index 13a408234..7ee4aece9 100644 --- a/packages/server/src/workspaces/__tests__/binary-probe.test.ts +++ b/packages/server/src/workspaces/__tests__/binary-probe.test.ts @@ -13,7 +13,7 @@ describe("bounded read-only binary validation", () => { const result = probeOpenCodeBinary(fixture.binary).then((value) => { completed = true; return value }) await delay(50) assert.equal(completed, false, "the probes must not block timers or other requests") - assert.deepEqual(await result, { valid: true }) + assert.deepEqual(await result, { valid: true, version: "custom-build" }) assert.deepEqual(fixture.calls(), [["--version"], ["service", "--help"]]) } finally { fixture.dispose() @@ -24,7 +24,7 @@ describe("bounded read-only binary validation", () => { for (const stderr of [false, true]) for (const helpExit of [0, 1]) { const fixture = binaryProbeFixture({ help: legacyHelp, stderr, helpExit }) try { - assert.deepEqual(await probeOpenCodeBinary(fixture.binary), { valid: false, errorCode: OPENCODE_V2_REQUIRED_ERROR_CODE }) + assert.deepEqual(await probeOpenCodeBinary(fixture.binary), { valid: false, version: "custom-build", errorCode: OPENCODE_V2_REQUIRED_ERROR_CODE }) assert.deepEqual(fixture.calls(), [["--version"], ["service", "--help"]]) } finally { fixture.dispose() diff --git a/packages/server/src/workspaces/__tests__/location-context-fixture.ts b/packages/server/src/workspaces/__tests__/location-context-fixture.ts new file mode 100644 index 000000000..3fb942e0f --- /dev/null +++ b/packages/server/src/workspaces/__tests__/location-context-fixture.ts @@ -0,0 +1,7 @@ +import { readLocationRef } from "../../opencode/compatibility/location" + +// Internal identity fixtures inspect the context before public transport rejects +// obsolete selectors. This is not a live-runtime serializer or admission path. +export function readInternalLocationContext(value: string | undefined) { + return value === undefined ? undefined : readLocationRef(JSON.parse(decodeURIComponent(value))) +} diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 46f9092ab..8dde42882 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -10,7 +10,6 @@ const RECONNECT_DELAY_MS = 1000 const LOCATION_OWNER_CACHE_MS = 2000 const SESSION_LOCATION_CACHE_MS = 2000 const GLOBAL_EVENT_TYPES = new Set([ - "catalog.updated", "agent.updated", "command.updated", "config.updated", diff --git a/packages/server/src/workspaces/opencode-connection-generation.test.ts b/packages/server/src/workspaces/opencode-connection-generation.test.ts index 188e65f00..42377190d 100644 --- a/packages/server/src/workspaces/opencode-connection-generation.test.ts +++ b/packages/server/src/workspaces/opencode-connection-generation.test.ts @@ -24,7 +24,7 @@ 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) + endpoint = makeEndpoint("2.0.6", 3) now = 60_000 await assert.rejects(service.acquire(), /opencode_update_required/) assert.throws(current.assertCurrent, /connection changed/) @@ -51,7 +51,7 @@ test("a late old stream cannot publish events or invalidate the replacement conn const service = new OpenCodeSharedService({ headers: () => undefined, makeClient: () => ({ event: { subscribe: () => (async function* () { yield { type: "server.connected", data: {} } as OpenCodeEvent - yield { type: "catalog.updated", data: {} } as unknown as OpenCodeEvent + yield { type: "model.updated", data: {} } as OpenCodeEvent })() }, }) as unknown as OpenCodeClient }) await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }) diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts index c346bfd8e..3691c62ec 100644 --- a/packages/server/src/workspaces/project-session-families.test.ts +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import type { OpenCodeClient, SessionInfo } from "@opencode/client" -import { readLocationContext, readLocationRef } from "../opencode/compatibility/location" +import { readLocationRef } from "../opencode/compatibility/location" +import { readInternalLocationContext } from "./__tests__/location-context-fixture" import { listCompleteProjectSessions, moveProjectSessionFamily, @@ -64,7 +65,7 @@ function clientHarness(initial: SessionInfo[], options: { moveCall += 1 moveCalls.push(sessionID) if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`) - const location = readLocationContext(requestOptions?.headers["x-codenomad-location"], "legacy") ?? { directory } + const location = readInternalLocationContext(requestOptions?.headers["x-codenomad-location"]) ?? { directory } if (options.visibilityDelayGets) pending.set(sessionID, { location, remaining: options.visibilityDelayGets }) else sessions.get(sessionID)!.location = location }, diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 6c26bbcdb..67c315c58 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -8,7 +8,6 @@ import { isOpenCodeServiceCommandUnavailable, isOpenCodeServiceHelp } from "./op export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) -const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" @@ -237,8 +236,9 @@ function parseBinaryVersion(result: BinaryProbeExecution): ReturnType { }, active: async () => ({}), move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { - const location = readLocationContext(options?.headers["x-codenomad-location"], "legacy") ?? { directory } + const location = readInternalLocationContext(options?.headers["x-codenomad-location"]) ?? { directory } moves.push(location) current.location = location as typeof original }, @@ -377,7 +377,7 @@ describe("evacuateWorktreeSessions", () => { list: async () => ({ data: [current], cursor: {} }), active: async () => ({}), move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { - const resolved = readLocationContext(options?.headers["x-codenomad-location"], "legacy")! + const resolved = readInternalLocationContext(options?.headers["x-codenomad-location"])! assert.equal(resolved.directory, directory) moves.push(resolved) current.location = { directory: resolved.directory, workspaceID: resolved.workspaceID! } diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index 2e0ee78e7..128a77c23 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -18,14 +18,23 @@ export const OpenCodeUpdateCard: Component = () => {
{t("settings.opencode.update.installed")}{data().currentVersion ?? "—"}
{t("settings.opencode.setup.minimum")}{data().minimumVersion}
+
{t("settings.opencode.setup.recommended")}{data().recommendedVersion}
{t("settings.opencode.setup.daemon")}{data().daemonVersion ?? "—"}
+

{t("settings.opencode.setup.minimumReason", { version: data().minimumVersion })}

+ +

{t(`settings.opencode.setup.${data().incompatibilityReason}`)}

+
+ +

{t("settings.opencode.setup.untested", { version: data().daemonVersion ?? data().currentVersion ?? "" })}

+

{data().binaryPath}

{t("settings.opencode.setup.wsl")}

-

{t("settings.opencode.setup.restartRequired")}

+

{t(data().serviceState === "restart_required" + ? "settings.opencode.setup.restartRequired" : "settings.opencode.setup.restartAvailable")}

{t("settings.opencode.setup.serviceError")}

{t("settings.opencode.update.checkFailed")}

- +

{t("settings.opencode.setup.manual")}

@@ -33,7 +42,7 @@ export const OpenCodeUpdateCard: Component = () => { {busy() ? t("settings.opencode.update.updating") : data().state === "missing" ? t("settings.opencode.setup.install") : t("settings.opencode.update.action", { version: data().latestVersion ?? "" })} - + diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index b6eca1969..780d6ceea 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -5,14 +5,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.ready": "Die OpenCode-Programmdatei ist verfügbar.", + "settings.opencode.setup.recommended": "Empfohlene und getestete Version", + "settings.opencode.setup.untested": "OpenCode {version} wurde mit dieser CodeNomad-Version nicht vollständig validiert. Dies allein verhindert keine Verbindung.", "settings.opencode.setup.error": "Die gewählte Programmdatei konnte nicht überprüft werden.", "settings.opencode.setup.minimum": "Mindestversion", + "settings.opencode.setup.minimumReason": "OpenCode {version} hat den nativen Startzeitstempel für Schritte eingeführt, den die Gesprächsanzeige benötigt. Frühere Versionen benötigen Ereigniskonvertierungen, die entfernt wurden.", + "settings.opencode.setup.canonical_api": "Der laufende Dienst stellt die für diese CodeNomad-Version erforderliche API für Sitzungen, Berechtigungen, Formulare und Warteschlangen nicht bereit.", + "settings.opencode.setup.session_environment": "Der laufende Dienst kann die Sitzungsumgebung nicht ersetzen, was vor dem Senden von Prompts oder Befehlen erforderlich ist.", "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.restartAvailable": "Eine neuere Version ist installiert. Sie können den laufenden Dienst weiter nutzen und später neu starten. 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.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index a4c85e905..088fa450d 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -5,14 +5,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.ready": "The OpenCode executable is available.", + "settings.opencode.setup.recommended": "Recommended and tested version", + "settings.opencode.setup.untested": "OpenCode {version} has not been fully validated with this CodeNomad release. This alone does not prevent connecting.", "settings.opencode.setup.error": "The selected executable could not be verified.", "settings.opencode.setup.minimum": "Minimum version", + "settings.opencode.setup.minimumReason": "OpenCode {version} introduced the native step-start timestamp required by the conversation display. Earlier versions need event conversions that have been removed.", + "settings.opencode.setup.canonical_api": "The running service does not expose the session, permission, Form and inbox API required by this CodeNomad release.", + "settings.opencode.setup.session_environment": "The running service cannot replace the session environment required before sending prompts or commands.", "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.restartAvailable": "A newer version is installed. You can keep using the running service and restart later. 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.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 116ac3f1d..940b4f728 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -5,14 +5,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.ready": "El ejecutable de OpenCode está disponible.", + "settings.opencode.setup.recommended": "Versión recomendada y probada", + "settings.opencode.setup.untested": "OpenCode {version} no se ha validado completamente con esta versión de CodeNomad. Esto por sí solo no impide conectarse.", "settings.opencode.setup.error": "No se pudo verificar el ejecutable seleccionado.", "settings.opencode.setup.minimum": "Versión mínima", + "settings.opencode.setup.minimumReason": "OpenCode {version} introdujo la marca de tiempo nativa de inicio de paso que necesita la vista de conversación. Las versiones anteriores requieren conversiones de eventos que se han eliminado.", + "settings.opencode.setup.canonical_api": "El servicio actual no ofrece la API de sesiones, permisos, formularios y cola de entrada necesaria para esta versión de CodeNomad.", + "settings.opencode.setup.session_environment": "El servicio actual no permite reemplazar el entorno de la sesión, una operación necesaria antes de enviar mensajes o comandos.", "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.restartAvailable": "Hay una versión más reciente instalada. Puedes seguir usando el servicio actual y reiniciarlo más tarde. Reiniciar 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.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 2c435b219..9c40e9e7c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -5,14 +5,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.ready": "L’exécutable OpenCode est disponible.", + "settings.opencode.setup.recommended": "Version conseillée et testée", + "settings.opencode.setup.untested": "OpenCode {version} n’a pas été entièrement validé avec cette version de CodeNomad. Cela n’empêche pas à lui seul la connexion.", "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.minimumReason": "OpenCode {version} a introduit l’horodatage natif de début d’étape nécessaire à l’affichage des conversations. Les versions antérieures nécessitent des conversions d’événements qui ont été retirées.", + "settings.opencode.setup.canonical_api": "Le service en cours n’expose pas l’API de sessions, permissions, formulaires et file d’attente nécessaire à cette version de CodeNomad.", + "settings.opencode.setup.session_environment": "Le service en cours ne permet pas de remplacer l’environnement de la session, une opération nécessaire avant l’envoi de messages ou de commandes.", "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.restartAvailable": "Une version plus récente est installée. Vous pouvez continuer avec le service en cours et le redémarrer plus tard. 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.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 4cfe8defc..a45feedd0 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -5,14 +5,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.ready": "קובץ ההפעלה של OpenCode זמין.", + "settings.opencode.setup.recommended": "גרסה מומלצת ונבדקת", + "settings.opencode.setup.untested": "OpenCode {version} לא נבדקה במלואה עם גרסה זו של CodeNomad. עובדה זו לבדה אינה מונעת התחברות.", "settings.opencode.setup.error": "לא ניתן לאמת את קובץ ההפעלה שנבחר.", "settings.opencode.setup.minimum": "גרסה מינימלית", + "settings.opencode.setup.minimumReason": "OpenCode {version} הוסיפה את חותמת הזמן המקורית לתחילת שלב הנדרשת להצגת שיחות. גרסאות קודמות דורשות המרות אירועים שהוסרו.", + "settings.opencode.setup.canonical_api": "השירות הפעיל אינו מספק את ממשק ה-API לשיחות, הרשאות, טפסים ותור הקלט הנדרש לגרסה זו של CodeNomad.", + "settings.opencode.setup.session_environment": "השירות הפעיל אינו יכול להחליף את סביבת השיחה, פעולה הנדרשת לפני שליחת הודעות או פקודות.", "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.restartAvailable": "מותקנת גרסה חדשה יותר. אפשר להמשיך להשתמש בשירות הפעיל ולהפעיל אותו מחדש מאוחר יותר. הפעלה מחדש תקטע עבודה פעילה בכל לקוחות OpenCode המחוברים.", "settings.opencode.setup.serviceError": "לא ניתן לאמת את השירות המשותף. יש לבדוק את החיבור והאימות ולנסות שוב.", "settings.opencode.setup.manual": "יש לבחור קובץ הפעלה נתמך או להתקין @opencode/cli במחשב המבצע ולנסות שוב.", "settings.opencode.setup.wsl": "קובץ זה פועל בתוך WSL. יש להתקין או לעדכן את OpenCode בהפצה שנבחרה.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 7f53f2c87..fbf0fef09 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -5,14 +5,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.ready": "OpenCode の実行ファイルが利用可能です。", + "settings.opencode.setup.recommended": "推奨・テスト済みバージョン", + "settings.opencode.setup.untested": "OpenCode {version} は、この CodeNomad リリースで完全には検証されていません。これだけを理由に接続が拒否されることはありません。", "settings.opencode.setup.error": "選択した実行ファイルを確認できませんでした。", "settings.opencode.setup.minimum": "最小バージョン", + "settings.opencode.setup.minimumReason": "OpenCode {version} で、会話表示に必要なネイティブのステップ開始時刻が導入されました。それ以前のバージョンには、削除済みのイベント変換が必要です。", + "settings.opencode.setup.canonical_api": "実行中のサービスは、この CodeNomad リリースに必要なセッション、権限、フォーム、受信キューの API を提供していません。", + "settings.opencode.setup.session_environment": "実行中のサービスは、プロンプトやコマンドの送信前に必要なセッション環境の置き換えに対応していません。", "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.restartAvailable": "新しいバージョンがインストールされています。実行中のサービスを使い続け、後で再起動できます。再起動すると、接続中のすべての OpenCode クライアントで作業が中断されます。", "settings.opencode.setup.serviceError": "共有サービスを確認できません。接続と認証を確認して再試行してください。", "settings.opencode.setup.manual": "対応する実行ファイルを選ぶか、実行ホストに @opencode/cli をインストールして再試行してください。", "settings.opencode.setup.wsl": "この実行ファイルは WSL 内で動作します。選択したディストリビューション内で OpenCode をインストールまたは更新してください。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 0395e727d..019c3c106 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -5,14 +5,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.ready": "OpenCode कार्यान्वयन फाइल उपलब्ध छ।", + "settings.opencode.setup.recommended": "सिफारिस गरिएको र परीक्षण गरिएको संस्करण", + "settings.opencode.setup.untested": "OpenCode {version} यो CodeNomad संस्करणसँग पूर्ण रूपमा प्रमाणित गरिएको छैन। यस कारणले मात्र जडान रोकिन्दैन।", "settings.opencode.setup.error": "छानिएको कार्यान्वयन फाइल प्रमाणित गर्न सकिएन।", "settings.opencode.setup.minimum": "न्यूनतम संस्करण", + "settings.opencode.setup.minimumReason": "OpenCode {version} ले कुराकानी प्रदर्शनलाई आवश्यक पर्ने चरण सुरु भएको मूल समय थपेको हो। पुराना संस्करणलाई हटाइएका घटना रूपान्तरण आवश्यक पर्छन्।", + "settings.opencode.setup.canonical_api": "चलिरहेको सेवाले यो CodeNomad संस्करणलाई आवश्यक पर्ने सत्र, अनुमति, फारम र इनबक्स API प्रदान गर्दैन।", + "settings.opencode.setup.session_environment": "चलिरहेको सेवाले सत्रको वातावरण बदल्न सक्दैन, जुन प्रम्प्ट वा आदेश पठाउनुअघि आवश्यक हुन्छ।", "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.restartAvailable": "नयाँ संस्करण स्थापित छ। चलिरहेको सेवा प्रयोग गरिरहन र पछि पुनः सुरु गर्न सक्नुहुन्छ। पुनः सुरु गर्दा सबै जडित OpenCode क्लाइन्टको सक्रिय काम रोकिन्छ।", "settings.opencode.setup.serviceError": "साझा सेवा प्रमाणित गर्न सकिएन। जडान र प्रमाणीकरण जाँच गरी फेरि प्रयास गर्नुहोस्।", "settings.opencode.setup.manual": "समर्थित कार्यान्वयन फाइल छान्नुहोस् वा कार्यान्वयन होस्टमा @opencode/cli स्थापना गरी फेरि प्रयास गर्नुहोस्।", "settings.opencode.setup.wsl": "यो फाइल WSL भित्र चल्छ। छानिएको वितरणमा OpenCode स्थापना वा अद्यावधिक गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 3dd41f22b..38d361896 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -5,14 +5,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.ready": "Исполняемый файл OpenCode доступен.", + "settings.opencode.setup.recommended": "Рекомендуемая и проверенная версия", + "settings.opencode.setup.untested": "OpenCode {version} не прошёл полную проверку с этим выпуском CodeNomad. Само по себе это не препятствует подключению.", "settings.opencode.setup.error": "Не удалось проверить выбранный исполняемый файл.", "settings.opencode.setup.minimum": "Минимальная версия", + "settings.opencode.setup.minimumReason": "В OpenCode {version} появилась нативная метка времени начала шага, необходимая для отображения разговоров. Более ранним версиям нужны удалённые преобразования событий.", + "settings.opencode.setup.canonical_api": "Работающая служба не предоставляет API сеансов, разрешений, форм и очереди ввода, необходимый этому выпуску CodeNomad.", + "settings.opencode.setup.session_environment": "Работающая служба не может заменить окружение сеанса, что необходимо перед отправкой запросов или команд.", "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.restartAvailable": "Установлена более новая версия. Можно продолжить использовать работающую службу и перезапустить её позже. Перезапуск прервёт активную работу всех подключённых клиентов OpenCode.", "settings.opencode.setup.serviceError": "Не удалось проверить общую службу. Проверьте соединение и аутентификацию, затем повторите попытку.", "settings.opencode.setup.manual": "Выберите поддерживаемый исполняемый файл или установите @opencode/cli на узле выполнения и повторите попытку.", "settings.opencode.setup.wsl": "Этот файл выполняется в WSL. Установите или обновите OpenCode в выбранном дистрибутиве.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index 0d1fb8a45..6434a0988 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -5,14 +5,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.ready": "OpenCode çalıştırılabilir dosyası kullanılabilir.", + "settings.opencode.setup.recommended": "Önerilen ve test edilen sürüm", + "settings.opencode.setup.untested": "OpenCode {version}, bu CodeNomad sürümüyle tam olarak doğrulanmadı. Bu durum tek başına bağlanmayı engellemez.", "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.minimumReason": "OpenCode {version}, konuşma görünümünün gerektirdiği yerel adım başlangıç zamanını ekledi. Önceki sürümler, kaldırılmış olay dönüşümlerine ihtiyaç duyar.", + "settings.opencode.setup.canonical_api": "Çalışan hizmet, bu CodeNomad sürümünün gerektirdiği oturum, izin, form ve gelen kutusu API'sini sunmuyor.", + "settings.opencode.setup.session_environment": "Çalışan hizmet, istem veya komut göndermeden önce gerekli olan oturum ortamını değiştirme işlemini desteklemiyor.", "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.restartAvailable": "Daha yeni bir sürüm kurulu. Çalışan hizmeti kullanmaya devam edip daha sonra yeniden başlatabilirsiniz. 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.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 562beb65e..0ff308d46 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -5,14 +5,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.ready": "OpenCode 可执行文件可用。", + "settings.opencode.setup.recommended": "推荐且经过测试的版本", + "settings.opencode.setup.untested": "OpenCode {version} 尚未与此 CodeNomad 版本进行完整验证。仅凭这一点不会阻止连接。", "settings.opencode.setup.error": "无法验证所选可执行文件。", "settings.opencode.setup.minimum": "最低版本", + "settings.opencode.setup.minimumReason": "OpenCode {version} 引入了对话显示所需的原生步骤开始时间戳。更早版本需要已移除的事件转换。", + "settings.opencode.setup.canonical_api": "运行中的服务未提供此 CodeNomad 版本所需的会话、权限、表单和收件队列 API。", + "settings.opencode.setup.session_environment": "运行中的服务无法替换会话环境,而发送提示或命令前需要执行此操作。", "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.restartAvailable": "已安装更新版本。您可以继续使用当前服务,稍后再重启。重启会中断所有已连接 OpenCode 客户端的当前工作。", "settings.opencode.setup.serviceError": "无法验证共享服务。请检查连接和身份验证后重试。", "settings.opencode.setup.manual": "请选择受支持的可执行文件,或在执行主机上安装 @opencode/cli 后重试。", "settings.opencode.setup.wsl": "此可执行文件在 WSL 中运行。请在所选发行版中安装或更新 OpenCode。", diff --git a/packages/ui/src/stores/instance-invalidation.ts b/packages/ui/src/stores/instance-invalidation.ts index 8970782b8..1b899e200 100644 --- a/packages/ui/src/stores/instance-invalidation.ts +++ b/packages/ui/src/stores/instance-invalidation.ts @@ -7,7 +7,6 @@ export function getInstanceRefreshTargets(eventType: string): readonly InstanceR case "command.updated": return ["commands"] case "models-dev.refreshed": - case "catalog.updated": return ["agents", "providers", "commands"] case "model.updated": case "provider.updated": diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts index ad85e5b82..10b5909a3 100644 --- a/packages/ui/src/stores/opencode-setup.ts +++ b/packages/ui/src/stores/opencode-setup.ts @@ -12,7 +12,7 @@ let pending: Promise | undefined let resume: (() => Promise) | undefined export function needsOpenCodeSetup(status = openCodeSetupStatus()): boolean { - return status?.state === "missing" || status?.state === "update_required" || status?.serviceState === "restart_required" + return status?.state === "missing" || status?.state === "update_required" || status?.serviceState === "restart_required" || status?.serviceState === "incompatible" } export function openOpenCodeSetup(retry?: () => Promise) { @@ -63,7 +63,7 @@ export async function runOpenCodeSetup(action: "install" | "start" | "restart" | // 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 + if (action !== "restart" && (openCodeSetupStatus()?.serviceState === "restart_required" || openCodeSetupStatus()?.serviceState === "incompatible")) return const status = action === "reload" ? await serverApi.reloadOpenCodeConfiguration() : await serverApi.startOpenCode(action === "restart") if (epoch !== generation) return setOpenCodeSetupStatus(status) diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index d37f3fb9b..5676483bd 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -22,7 +22,7 @@ before(async () => { }) after(async () => { await browser?.close(); await server?.close() }) -test("missing and required-update flows share a screen; stale daemon restart is explicit", async () => { +test("missing installation and incompatible daemon expose different actions; restart is explicit", async () => { const page = await browser.newPage() const errors: string[] = [] page.on("pageerror", error => errors.push(error.message)) @@ -38,8 +38,8 @@ test("missing and required-update flows share a screen; stale daemon restart is 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", + latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", + canUpgrade: !installed, updateAvailable: !installed, daemonVersion: running ? "2.0.11" : "2.0.6", serviceState: installed ? running ? "ready" : "restart_required" : undefined, canRestart: installed && !running } }) }) try { @@ -64,8 +64,8 @@ test("missing and required-update flows share a screen; stale daemon restart is 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 } })) + await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.6", + latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "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() @@ -73,7 +73,8 @@ test("closing required-update screen leaves a persistent recovery entry", async 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) + assert.equal(await page.getByText("2.0.6", { exact: true }).count(), 1) + await page.getByText(/introduced the native step-start timestamp/).waitFor() } catch (error) { console.error(await page.locator("body").innerText()); throw error } finally { await page.close() } }) @@ -88,21 +89,23 @@ test("optional updates stay non-blocking and a failed install remains recoverabl 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" } }) + return route.fulfill({ json: { state: "ready", currentVersion: "2.0.10", latestVersion: "2.0.11", + minimumVersion: "2.0.7", recommendedVersion: "2.0.11", versionAssessment: "untested", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false, + serviceState: "ready", daemonVersion: "2.0.10" } }) }) 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.getByText(/OpenCode 2.0.10 has not been fully validated/).waitFor() + if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-optional.png") }) + await page.getByRole("button", { name: "Update to OpenCode 2.0.11" }).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.getByRole("button", { name: "Update to OpenCode 2.0.11" }).click() await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]')) assert.equal(attempts, 2) } finally { await page.close() } @@ -117,8 +120,8 @@ test("unsupported proxy calls open recovery once without replaying mutations", a 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" } }) + minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: false, + serviceState: blocked ? "restart_required" : "ready", canRestart: blocked, daemonVersion: blocked ? "2.0.6" : "2.0.11" } }) }) try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 }) @@ -146,7 +149,7 @@ test("optional install reconnects without restarting and leaves explicit activat else connects++ } return route.fulfill({ json: { state: "ready", currentVersion: installed ? "2.0.12" : "2.0.11", - latestVersion: "2.0.12", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", + latestVersion: "2.0.12", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: !installed, updateAvailable: !installed, daemonVersion: restarts ? "2.0.12" : "2.0.11", serviceState: installed && !restarts ? "restart_available" : "ready", canRestart: installed && !restarts } }) }) @@ -159,6 +162,8 @@ test("optional install reconnects without restarting and leaves explicit activat assert.equal(connects, 1, "rebind backend authority while retaining the supported daemon") assert.equal(restarts, 0) await page.getByRole("button", { name: "Restart shared service" }).waitFor() + await page.getByText(/You can keep using the running service and restart later/).waitFor() + assert.equal(await page.getByText(/The running service needs updating/).count(), 0) await page.getByRole("button", { name: "Close", exact: true }).click() assert.equal(await page.getByRole("button", { name: "OpenCode setup required" }).count(), 0) await page.evaluate(() => (window as any).fixture.open()) @@ -177,7 +182,7 @@ test("configuration reload is an informed explicit action and never runs on setu reloads++ } return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11", - minimumVersion: "2.0.11", binaryPath: "opencode2", target: "wsl", canUpgrade: false, + minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "wsl", canUpgrade: false, serviceState: "ready", canRestart: false, canReload: true, daemonVersion: "2.0.11" } }) }) try { @@ -208,7 +213,7 @@ test("activation failure after installation retains the installed version and re connected = 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", + latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: !installed, canRestart: false, serviceState: connected ? "ready" : "stopped" } }) }) try { diff --git a/scripts/test-opencode-setup-desktop.mjs b/scripts/test-opencode-setup-desktop.mjs index 5381623a3..5ef100e6c 100644 --- a/scripts/test-opencode-setup-desktop.mjs +++ b/scripts/test-opencode-setup-desktop.mjs @@ -21,6 +21,8 @@ const requested = process.argv[2] ?? "both" const bootstrapCli = process.argv[3] assert.ok(bootstrapCli && path.isAbsolute(bootstrapCli), "Pass host (both/electron/tauri) and absolute fixture CLI used only to configure an isolated service port") const oldDaemon = process.argv.includes("--old-daemon") +const compatibleDaemon = process.argv.includes("--compatible-daemon") +assert.ok(!(oldDaemon && compatibleDaemon), "Choose one daemon scenario") const resumeFolder = process.argv.includes("--resume-folder") const artifacts = { electron: path.join(workspace, "packages/electron-app/release/win-unpacked/CodeNomad.exe"), @@ -65,16 +67,28 @@ for (const [host, executable] of Object.entries(artifacts)) { await new Promise(resolve => reservation.listen(0, "127.0.0.1", resolve)) const servicePort = reservation.address().port await new Promise(resolve => reservation.close(resolve)) - const record = { host, executable, profile, servicePort, oldDaemon, sha256: createHash("sha256").update(await readFile(executable)).digest("hex"), checks: [] } + const record = { host, executable, profile, servicePort, oldDaemon, compatibleDaemon, sha256: createHash("sha256").update(await readFile(executable)).digest("hex"), checks: [] } const resources = path.join(path.dirname(executable), "resources") record.packagedFiles = {} for (const relative of ["server/dist/opencode-update/service.js", "server/dist/workspaces/opencode-cli-service.js", "server/dist/workspaces/native-service-registration.js", "server/public/index.html", "node/win32-x64/node.exe", "node/win32-x64/node_modules/npm/package.json"]) { record.packagedFiles[relative] = createHash("sha256").update(await readFile(path.join(resources, relative))).digest("hex") } + for (const retired of ["opencode-update/legacy-package.js", "opencode/compatibility/requests.js", "opencode/compatibility/events.js"]) { + await assert.rejects(readFile(path.join(resources, "server/dist", retired)), { code: "ENOENT" }, "retired modules must not survive in desktop resources") + } record.sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace, encoding: "utf8", timeout: 10_000 }).trim() results.push(record) let output = "", browser, page, cli, child, stopped, childError const runCli = (binary, args, timeout = 30_000) => execFileSync(binary, args, { env, cwd: profile, encoding: "utf8", timeout, maxBuffer: 1024 * 1024 }).trim() + const daemonInfo = async () => { + const password = runCli(bootstrapCli, ["service", "get", "password"]) + const response = await fetch(`http://127.0.0.1:${servicePort}/api/info`, { + headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }, + signal: AbortSignal.timeout(10_000), redirect: "error", + }) + assert.equal(response.status, 200) + return response.json() + } const statuses = [] const observerErrors = [], observerTasks = new Set() const observe = observedPage => observedPage.on("response", response => { @@ -86,11 +100,12 @@ for (const [host, executable] of Object.entries(artifacts)) { }) try { runCli(bootstrapCli, ["service", "set", "port", String(servicePort)]) - if (oldDaemon) { + if (oldDaemon || compatibleDaemon) { runCli(bootstrapCli, ["service", "start"], 60_000) record.oldVersion = runCli(bootstrapCli, ["--version"]) record.oldService = runCli(bootstrapCli, ["service", "status"]) assert.equal(record.oldService, `http://127.0.0.1:${servicePort}`) + if (compatibleDaemon) record.before = await daemonInfo() } child = spawn(executable, resumeFolder ? ["--folder", project] : [], { cwd: profile, env, stdio: ["ignore", "pipe", "pipe"] }) // Native-parent-started services may inherit a pipe after the desktop exits. @@ -112,7 +127,8 @@ for (const [host, executable] of Object.entries(artifacts)) { await page.getByRole("dialog").waitFor() await page.getByText(/^(OpenCode is not installed\.|OpenCode n’est pas installé\.)$/).waitFor() await page.screenshot({ path: path.join(profile, "01-missing.png") }) - record.checks.push("real missing setup screen, minimum 2.0.11") + await page.getByText(/introduced the native step-start timestamp|horodatage natif de début d’étape/).waitFor() + record.checks.push("real missing setup screen with separate technical minimum and recommendation") await page.getByRole("button", { name: /^(Close|Fermer)$/ }).click() await page.locator('[role="status"] button').click() await page.getByRole("dialog").waitFor() @@ -134,6 +150,18 @@ for (const [host, executable] of Object.entries(artifacts)) { record.checks.push("old running daemon detected after install, restart deferred for explicit action") await restart.click() } + if (compatibleDaemon) { + await until(() => statuses.some(item => item.url.endsWith("/api/opencode/service") && item.status === 200 + && item.body.serviceState === "restart_available"), "Compatible daemon did not reconnect without restart") + assert.ok(!statuses.some(item => item.request?.restart === true), "compatible daemon must not be restarted automatically") + await page.getByText(/You can keep using the running service and restart later|Vous pouvez continuer avec le service en cours/).waitFor() + record.after = await daemonInfo() + assert.equal(record.after.pid, record.before.pid) + assert.equal(record.after.version, record.before.version) + await page.screenshot({ path: path.join(profile, "02-optional-restart.png") }) + await page.getByRole("button", { name: /^(Close|Fermer)$/ }).click() + record.checks.push("usable older daemon retained with identical PID/version; restart optional after installation") + } await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 120_000 }) await page.screenshot({ path: path.join(profile, "02-installed-connected.png") }) record.cliVersion = runCli(cli, ["--version"]) From 781c3a42b740e83a9ee98f6aa51b9c7d5f988d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 23:55:54 +0200 Subject: [PATCH 13/14] fix(setup): keep recovery above pending folder loading Rebuilt desktop acceptance exposed a blocked recovery action after dismissing setup during an initial folder open. Mount the persistent recovery entry through a body portal so the root-contained folder loading overlay cannot intercept its pointer events. Opening setup still removes the entry and preserves the dialog's modal behavior. Exercise the real loading-overlay class with the full app stylesheet and normal Playwright clicks; all seven setup scenarios and UI typecheck pass. Independent gatekeeper review found no remaining issue. Record successful Windows and native Linux beta/2.0.3 migrations to the corrected 2.0.7 minimum. --- dev-docs/OPENCODE_V2_POST_BETA.md | 9 +++++++++ packages/ui/src/components/opencode-setup.tsx | 5 +++-- packages/ui/tests/browser/opencode-setup.test.ts | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 4254d0865..1677a779a 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -252,6 +252,10 @@ synthetic seed. Keep the compatibility audit in - Native 2.0.3→2.0.7 migration passed with 215-message histories, distinct old identities, forks, provider configuration/connections and same-version Form durability controls. Evidence: `codenomad-history-migration-IoFhtY`. +- Native beta-19271→2.0.7 migration also passed on Windows; both beta-19271 and + 2.0.3→2.0.7 passed under native Linux/WSL. Exact CLI archives were verified, + original seed hashes stayed unchanged, and isolated processes were cleaned up. + Evidence: `pr696-native-207-210/MIGRATION-MINIMUM207.md`. - Seven browser setup scenarios passed. Rendered captures distinguish minimum 2.0.7 from recommendation 2.0.11 and keep 2.0.10 usable with optional update. Evidence: approved temporary `opencode/696-rigorous-browser/`. @@ -264,5 +268,10 @@ synthetic seed. Keep the compatibility audit in also approved the compatible-daemon fixture and generated-output clean step. - Server builds clean generated `dist` before compilation; desktop acceptance checks that deleted adapters/installers do not survive incremental packaging. +- Rebuilt Tauri acceptance found a recovery-layer regression when opening a + folder: the loading overlay intercepted the persistent setup button after + dismissal. The recovery entry now mounts through a body portal. The real-style + browser regression uses normal pointer clicks with the folder overlay present; + all seven setup scenarios and UI typecheck passed after correction. - Rebuilt-host acceptance and final remote CI are recorded in the PR after completion; earlier green tests are not substituted for those results. diff --git a/packages/ui/src/components/opencode-setup.tsx b/packages/ui/src/components/opencode-setup.tsx index 7ba0df2d5..f461f93b9 100644 --- a/packages/ui/src/components/opencode-setup.tsx +++ b/packages/ui/src/components/opencode-setup.tsx @@ -1,5 +1,6 @@ import { createEffect, onCleanup, onMount, Show, untrack } from "solid-js" import { Dialog } from "@kobalte/core/dialog" +import { Portal } from "solid-js/web" import { useI18n } from "../lib/i18n" import { useConfig } from "../stores/preferences" import { sseManager } from "../lib/sse-manager" @@ -35,9 +36,9 @@ export default function OpenCodeSetup() { }) return <> -
+
-
+
diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 5676483bd..ee33ec268 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -62,13 +62,18 @@ test("missing installation and incompatible daemon expose different actions; res finally { await page.close() } }) -test("closing required-update screen leaves a persistent recovery entry", async () => { +test("closing required-update screen leaves recovery clickable above a pending folder overlay", async () => { const page = await browser.newPage() await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.6", latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "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() + await page.evaluate(() => { + const overlay = document.createElement("div") + overlay.className = "folder-loading-overlay" + document.getElementById("root")!.append(overlay) + }) 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() From baaa3755eda5720463aab96f79064020709d8719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 21 Sep 2026 00:05:02 +0200 Subject: [PATCH 14/14] docs(opencode): record final corrected desktop acceptance Record successful packaged Electron and Tauri runs at 781c3a42 for a compatible 2.0.10 daemon retained after installing 2.0.11 and a retired 2.0.3 daemon restarted only by explicit action. Both scenarios resume the requested folder, exercise recovery over its loading overlay, and verify that retired generated modules are absent. Link final evidence and inspected captures, confirm isolated fixture cleanup, and keep completed local/native acceptance distinct from unconfirmed remote CI. --- dev-docs/OPENCODE_V2_POST_BETA.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 1677a779a..529b9e9f5 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -273,5 +273,14 @@ synthetic seed. Keep the compatibility audit in dismissal. The recovery entry now mounts through a body portal. The real-style browser regression uses normal pointer clicks with the folder overlay present; all seven setup scenarios and UI typecheck passed after correction. -- Rebuilt-host acceptance and final remote CI are recorded in the PR after - completion; earlier green tests are not substituted for those results. +- Final packaged acceptance passed on Electron and Tauri at `781c3a42`: + installing 2.0.11 retained the compatible 2.0.10 daemon with identical PID and + version, optional restart wording, and a ready resumed workspace. The retired + 2.0.3 scenario passed with explicit restart. Both hosts passed recovery reentry + over the pending-folder overlay and absence of retired generated modules. + Final captures were inspected; all fixture windows/services were cleaned up. + Evidence: `pr696-native-207-210/DESKTOP-FINAL.md`, + `codenomad-setup-desktop-Z6gPDT` (compatible) and + `codenomad-setup-desktop-kr69SG` (retired). +- Corrected local/native/packaged acceptance is complete. Final-head remote CI + remains a separate, unconfirmed result; it was not continuously polled.