Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

- Git is a full-functionality prerequisite, with directory-only degraded conversations when the backend cannot find Git. Only the explicitly opened physical folder is session authority in that mode; never infer sibling worktrees from native project IDs. Keep ancestor/descendant mutation identities covered by the deletion fence across Git availability changes. Inform agents through the owned native `codenomad.git-availability` instruction before prompts/custom commands, remove stale context after recovery, and keep this advisory separate from fail-closed environment synchronization. No blocking Git setup UI. Validate with `scripts/test-git-degraded-native.mjs` using an isolated CLI/database and provider.

- 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.
- 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 for a shared user npm installation, prefers PATH and retains private copies only as migration fallbacks. Keep installer locking and Windows live-executable preflight in `opencode-update/installation-lock.ts`; register terminal PATH only on explicit installation. 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`.
Expand Down
76 changes: 60 additions & 16 deletions dev-docs/OPENCODE_V2_POST_BETA.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe that earlier diff, not approval of its scope or the current correction.

## Setup and recovery

The global setup dialog and Preferences reuse `OpenCodeUpdateCard` and its store.
The global setup dialog and Preferences reuse `OpenCodeSetupPanel` 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
Expand All @@ -49,21 +49,65 @@ 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. 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.
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.
Exclusive receipt creation is idempotent on Windows; a slower old-version install
cannot downgrade the selection published by another backend.
Automatic selection now follows PATH order (`opencode2`, then `opencode` within
each directory), then the conventional user npm installation, then the old private
CodeNomad installation as a migration fallback. An explicit executable retains
priority. Both setup and binary validation use the same discovery. The UI shows
the effective path and source; “automatic” replaces the misleading “system PATH”
label. Recovery puts diagnosis and installation before executable selection;
Preferences keeps executable selection first, inline actions, collapsed version
details/troubleshooting and logs last.
Official npm packages from the beta and 2.0.0 transition advertised different
launcher targets: discovery checks their published `bin` map and real launcher
target, choosing `opencode` when `opencode2` is only the retired `.cjs` alias.
Known historical beta versions can migrate to a stable target without treating
their version label as an unrecognized custom build.
Discovery preserves terminal PATH/PATHEXT ordering even when a standalone
executable shares the npm command directory; such an executable remains
user-managed. On POSIX, only the prefix's actual `bin` directory is treated as
an npm command directory, not a neighboring folder. A standalone executable
replacing a POSIX npm symlink is still selected in PATH order and remains
user-managed; only an actual symlink to the package's retired `.cjs` alias is
skipped in favor of the working `opencode` command. On Windows, the same
retired alias is skipped only when the npm `.cmd` script positively invokes
the package's `.cjs` target; a customized `.cmd` wrapper keeps PATH priority
and cannot be overwritten through the automatic npm updater.

Default host installation uses bundled Node/npm to run a standard global npm
installation of `@opencode/cli`. Existing writable npm installations on PATH are
reused after verifying their manifest and launcher identity; standalone/curl,
Homebrew and custom installations remain user-managed. With no PATH installation,
the user prefix is `%APPDATA%/npm` on Windows and `~/.local` on POSIX, or an explicit
absolute `NPM_CONFIG_PREFIX`. Existing npm prefixes on PATH are retained. npm
publishes the normal terminal commands; CodeNomad verifies the executable version
and launcher before registering the command directory. Windows registration preserves
the HKCU Path value's type and unexpanded variables and broadcasts the environment
change. Bash, zsh, sh and fish profiles receive idempotent entries. A new terminal
is needed; the backend's own PATH is updated immediately. Remote installation and
PATH changes apply on the server host. No system Node or administrator rights are
needed for the conventional user prefix. Installer execution remains bounded to
five minutes and 1 MiB output.

Migration remains available when a private copy is already current. PATH registration
failure leaves the installed package discoverable, and retry can repair PATH without
reinstalling. Neither migration nor command repair downgrades a newer shared version.
Old version directories and selection receipts are read-only fallbacks and are never
removed by installation. After choosing/verifying the common executable and checking
that no process uses the private copy, the user may remove only
`~/.local/share/codenomad/opencode`; `~/.local/share/opencode` is native user data
and must not be confused with this installation directory.

An exclusive `.codenomad-opencode-install.lock` in the npm prefix serializes
CodeNomad backends; the version is re-probed under that lock. A competing backend
gets a retryable conflict. A lock left after a crash is deliberately not stolen
by time/PID heuristics: npm may outlive its backend. The server log gives the lock
path; remove it only after confirming the installer has exited. External package
managers do not participate in this lock. Windows checks the executable for write
access before npm can retire the old package; a mapped or non-writable executable
defers the update with localized feedback and leaves the package intact. CodeNomad
never stops the shared daemon to complete an installation. Standard npm's own
failure semantics apply after this preflight; this is not the old immutable,
versioned private-package publication scheme.

Explicit custom binaries remain selected. WSL and custom installations receive
execution-host instructions rather than a Windows-side Linux installation.
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@ export interface OpenCodeUpdateStatus {
incompatibilityReason?: "step_timestamp" | "canonical_api" | "session_environment"
state: "missing" | "update_required" | "ready" | "error"
binaryPath: string
installationSource?: "path" | "user" | "legacy"
needsSharedInstallation?: boolean
daemonVersion?: string
serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "incompatible" | "error"
canReload?: boolean
Expand Down
38 changes: 38 additions & 0 deletions packages/server/src/opencode-update/installation-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mkdir, open, rm } from "node:fs/promises"
import path from "node:path"

export class InstallationBusyError extends Error {
constructor(readonly code: "installation_busy" | "installation_in_use", message: string) {
super(message)
}
}

/** Serializes CodeNomad backends sharing a standard npm prefix. Never steal a
* lock on a timeout: an orphaned npm child can outlive its backend. After a crash,
* remove the lock only after checking that the installer has exited. */
export async function withInstallationLock<T>(prefix: string, install: () => Promise<T>): Promise<T> {
await mkdir(prefix, { recursive: true })
const lock = path.join(prefix, ".codenomad-opencode-install.lock")
const handle = await open(lock, "wx", 0o600).catch(error => {
if (error.code === "EEXIST") throw new InstallationBusyError("installation_busy", `OpenCode installation lock exists: ${lock}`)
throw error
})
try {
await handle.writeFile(JSON.stringify({ pid: process.pid, started: new Date().toISOString() }))
return await install()
} finally {
await handle.close()
await rm(lock)
}
}

/** Windows forbids writing a mapped executable. Check before npm can retire the
* old package; do not stop the daemon or leave a partially replaced installation. */
export async function assertExecutableWritable(binary: string, platform = process.platform) {
if (platform !== "win32") return
try { await (await open(binary, "r+")).close() }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
throw new InstallationBusyError("installation_in_use", "The OpenCode executable is in use or not writable")
}
}
22 changes: 22 additions & 0 deletions packages/server/src/opencode-update/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ test("installation re-resolves the executable and rejects false success", async
await assert.rejects(wrong.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed")
})

test("legacy migration and PATH repair remain available without an update and never downgrade", async () => {
for (const source of ["legacy", "user"] as const) {
let migrated = false, installs = 0
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: migrated ? "common" : source, label: "OpenCode", source: migrated ? "path" : source }),
probeBinary: () => ({ valid: true, version: "2.0.15" }),
resolveLatestVersion: async () => "2.0.14",
upgradeBinary: async (_binary, target) => {
assert.equal(target, "2.0.15")
installs++; migrated = true
return { success: true, version: target }
},
}))
assert.equal((await service.getStatus()).canUpgrade, true)
assert.equal((await service.getStatus()).needsSharedInstallation, true)
await service.upgrade()
assert.equal(installs, 1)
assert.equal((await service.getStatus()).needsSharedInstallation, false)
assert.equal((await service.getStatus()).canUpgrade, false)
}
})

test("coalesces overlapping installations and never downgrades a newer version", async () => {
let upgrades = 0
let version = "2.0.6"
Expand Down
28 changes: 18 additions & 10 deletions packages/server/src/opencode-update/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { assertSupportedOpenCode, isBelowOpenCodeMinimum, MINIMUM_OPENCODE_VERSI
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 { bundledNpm } from "./managed-installation"
import { installSharedOpenCode, sharedInstallPrefix } from "./shared-installation"
import { InstallationBusyError } from "./installation-lock"
import type { OpenCodeServiceLifecycle } from "../workspaces/opencode-service"
import { parseWslUncPath } from "../workspaces/spawn"

Expand Down Expand Up @@ -69,9 +71,10 @@ export class OpenCodeUpdateService {
? !currentVersion || compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 : null
const status: OpenCodeUpdateStatus = {
currentVersion, latestVersion, updateAvailable,
canUpgrade: !invalid && Boolean(updateAvailable) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary),
canUpgrade: !invalid && Boolean(updateAvailable || needsSharedInstallation(binary)) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary),
minimumVersion: MINIMUM_OPENCODE_VERSION, recommendedVersion: RECOMMENDED_OPENCODE_VERSION,
versionAssessment: "untested", state, binaryPath: binary.path,
installationSource: binary.source, needsSharedInstallation: needsSharedInstallation(binary),
target: parseWslUncPath(binary.path) ? "wsl" : "host", canRestart: false,
...(!latestVersion ? { checkError: "update_check_failed" as const } : {}),
}
Expand Down Expand Up @@ -201,32 +204,33 @@ export class OpenCodeUpdateService {
throw new OpenCodeUpdateError("unsupported_binary", "Cannot order this custom OpenCode version for an automatic update")
}

if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0 && !needsSharedInstallation(binary)) {
return { success: true, version: currentVersion }
}

if (!this.deps.canUpgradeBinary(binary)) {
throw new OpenCodeUpdateError(
"unsupported_binary",
"Automatic updates are only available for the managed opencode2 command",
"Automatic updates require the default command and a writable npm installation",
)
}

try {
const result = await this.deps.upgradeBinary(binary, latestVersion)
const targetVersion = currentVersion && compareOpenCodeVersionStrings(currentVersion, latestVersion) > 0 ? currentVersion : latestVersion
const result = await this.deps.upgradeBinary(binary, targetVersion)
if (!result.success) {
throw new OpenCodeUpdateError("upgrade_failed", result.error)
}
const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path)
if (isBelowOpenCodeMinimum(installedVersion) || !/^\d+\.\d+\.\d+$/.test(installedVersion) || compareOpenCodeVersionStrings(installedVersion, latestVersion) < 0) {
if (isBelowOpenCodeMinimum(installedVersion) || !/^\d+\.\d+\.\d+$/.test(installedVersion) || compareOpenCodeVersionStrings(installedVersion, targetVersion) < 0) {
throw new OpenCodeUpdateError(
"upgrade_verification_failed",
`OpenCode reported ${result.version}, but the configured binary is ${installedVersion} instead of ${latestVersion}`,
)
}
return { success: true, version: installedVersion }
} catch (error) {
if (error instanceof OpenCodeUpdateError) throw error
if (error instanceof OpenCodeUpdateError || error instanceof InstallationBusyError) throw error
throw new OpenCodeUpdateError(
"upgrade_failed",
error instanceof Error ? error.message : "OpenCode upgrade failed",
Expand Down Expand Up @@ -300,12 +304,12 @@ export function createOpenCodeUpdateService(
},
probeBinary: probeBinaryVersionAsync,
resolveLatestVersion: resolveLatestOpenCodeVersion,
canUpgradeBinary: () => {
canUpgradeBinary: binary => {
const configured = settings.getOwner("config", "server").opencodeBinary
return (!configured || configured === "opencode" || configured === "opencode2") && Boolean(bundledNpm())
return !parseWslUncPath(binary.path) && (!configured || configured === "opencode" || configured === "opencode2") && Boolean(bundledNpm()) && Boolean(sharedInstallPrefix())
},
upgradeBinary: async (_binary, version) => {
await installManagedOpenCode(version)
await installSharedOpenCode(version)
return { success: true, version }
},
lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle),
Expand All @@ -318,3 +322,7 @@ export function createOpenCodeUpdateService(
function comparableVersion(version: string): boolean {
return /^\d+\.\d+\.\d+$/.test(version) || /^0\.0\.0-beta-\d+$/.test(version)
}

function needsSharedInstallation(binary: ResolvedBinary): boolean {
return binary.source === "legacy" || binary.source === "user"
}
Loading
Loading