fix(v2): isolate runtime defaults

Force the V2 service to use the opencode2 database under the current user's data directory so parallel V1 launches cannot redirect it through shared environment settings.

Keep the bare opencode and opencode2 commands out of custom binary history while preserving real executable paths, and document the parallel desktop smoke procedure. Existing history entries are intentionally left untouched for manual cleanup.

The server manager test and typecheck passed, and the Tauri package built successfully before the final UI-only simplification. No additional compilation was run per request.
This commit is contained in:
Pascal André 2026-08-14 22:17:27 +02:00
parent c025bfcd78
commit 9a53e2f05c
No known key found for this signature in database
6 changed files with 43 additions and 15 deletions

View file

@ -82,6 +82,30 @@ The final validation should include:
- `git diff --check`.
- A real OpenCode V2 startup, session, event, Shell, and shutdown smoke test.
### Required Parallel UI Smoke
CodeNomad V1 is the working environment and must remain open and untouched. V2 always uses `~/.local/share/opencode2/opencode.db`; launch the V2 build beside V1 from PowerShell with a dedicated CDP port and WebView profile:
```powershell
Start-Process `
-FilePath 'D:\CodeNomad-worktrees\opencode-v2-foundation\packages\tauri-app\target\release\codenomad-tauri.exe' `
-Environment @{
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9223'
WEBVIEW2_USER_DATA_FOLDER = "$env:TEMP\codenomad-webview-v2"
}
```
The smoke is complete only after all of these actions succeed in the visible V2 UI:
1. Confirm the binary selected in CodeNomad's OpenCode settings reports exactly the pinned protocol baseline above.
2. Open `D:\CodeNomad` from Recent Folders or the folder picker.
3. Open an existing session from the session list; direct API session creation is not a substitute.
4. Send a prompt from the composer and receive its visible assistant response.
5. Reload the V2 window and confirm the workspace and session list recover. While V1 owns cross-host restore, reopen the existing V2 session from the list and confirm its messages and pending state recover correctly.
6. Exercise one PTY create/list/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. PTY creation is not currently exposed in the visible UI.
Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The PTY protocol check is the sole exception until the UI exposes creation.
## Review Notes
- The pinned OpenCode V2 protocol client is experimental and may change; public `@opencode-ai/sdk` examples are not authoritative for this build.

View file

@ -96,7 +96,7 @@ class ControlledSharedService {
assert.equal(options?.command?.[5], options?.contenderFile)
assert.equal(options?.launcherRecordsPid, true)
assert.equal(options?.environment?.XDG_STATE_HOME, stateRoot)
assert.equal(options?.environment?.OPENCODE_DB, path.join(os.tmpdir(), "user-opencode.db"))
assert.equal(options?.environment?.OPENCODE_DB, path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db"))
}
}
@ -120,14 +120,6 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor
}
describe("workspace manager shared service lifecycle", () => {
it("fails before contacting the service when OPENCODE_DB is absent", async () => {
const { manager, service } = createHarness()
;(manager as any).options.settings = { getOwner: () => ({ environmentVariables: { OPENCODE_DB: "" } }) }
await assert.rejects(manager.create(process.cwd()), /non-empty OPENCODE_DB/)
assert.equal(service.validationCalls.length, 0)
assert.equal((manager as any).workspaces.size, 0)
})
it("translates a matching WSL UNC workspace for service API calls", () => {
const { manager } = createHarness()
assert.equal(

View file

@ -1,4 +1,5 @@
import path from "path"
import os from "node:os"
import { spawnSync } from "child_process"
import { randomUUID } from "node:crypto"
import type { Endpoint } from "@opencode-ai/client/service"
@ -30,6 +31,7 @@ import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktr
const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000
const OPENCODE_SERVICE_VERSION = "0.0.0-next-17353"
const OPENCODE_DATABASE = path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db")
const ORDINARY_CREATION_OWNER = ""
const WORKSPACE_STATE = Symbol("workspaceState")
const SERVICE_CONTENDER_FILE = path.join(SERVICE_STATE_ROOT, `contenders-${process.pid}-${randomUUID()}.txt`)
@ -474,9 +476,8 @@ export class WorkspaceManager {
if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath
configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT
const serviceEnvironment = { ...process.env, ...configuredEnvironment }
if (!serviceEnvironment.OPENCODE_DB?.trim()) {
throw new Error("OpenCode V2 requires a non-empty OPENCODE_DB environment variable")
}
// ponytail: fixed V2 storage root until database selection needs to be configurable.
serviceEnvironment.OPENCODE_DB = OPENCODE_DATABASE
prepareServiceState(SERVICE_CONTENDER_FILE)
const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], {
env: serviceEnvironment,

View file

@ -43,7 +43,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const lastUsedBinary = () => serverSettings().opencodeBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode2"))
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode" && binary.path !== "opencode2"))
const binaryOptions = createMemo<BinaryOption[]>(() => [{ path: "opencode2", isDefault: true }, ...customBinaries()])

View file

@ -1,7 +1,17 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildSpeechPatch } from "../lib/speech-patch"
import type { SpeechSettingsUpdate } from "./preferences"
import { buildBinaryList, type SpeechSettingsUpdate } from "./preferences"
describe("buildBinaryList", () => {
it("does not add built-in commands to custom binary history", () => {
const custom = { path: "C:/tools/opencode.exe", lastUsed: 1 }
const source = [{ path: "opencode2", lastUsed: 2 }, custom]
assert.deepEqual(buildBinaryList("opencode", undefined, source), source)
assert.deepEqual(buildBinaryList("opencode2", undefined, source), source)
})
})
describe("buildSpeechPatch", () => {
it("only includes fields that are explicitly provided", () => {

View file

@ -489,7 +489,8 @@ function buildRecentFolderList(folderPath: string, source: RecentFolder[], alias
return folders.slice(0, MAX_RECENT_FOLDERS)
}
function buildBinaryList(binaryPath: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] {
export function buildBinaryList(binaryPath: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] {
if (binaryPath === "opencode" || binaryPath === "opencode2") return source
const timestamp = Date.now()
const existing = source.find((b) => b.path === binaryPath)
if (existing) {