From f76bb54258be5dc5bb81765a0dfc105b9cddac5f Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:05:24 -0400 Subject: [PATCH 01/12] feat(contracts): add runtime bootstrap websocket contracts --- packages/contracts/src/providerDiscovery.ts | 37 +++++++++++++++++ packages/contracts/src/ws.test.ts | 46 +++++++++++++++++++++ packages/contracts/src/ws.ts | 8 ++++ 3 files changed, 91 insertions(+) diff --git a/packages/contracts/src/providerDiscovery.ts b/packages/contracts/src/providerDiscovery.ts index 1c445fdf..dc0dcf4f 100644 --- a/packages/contracts/src/providerDiscovery.ts +++ b/packages/contracts/src/providerDiscovery.ts @@ -583,3 +583,40 @@ export const ProviderGetRuntimeHealthInput = Schema.Struct({ forceRefresh: Schema.optional(Schema.Boolean), }); export type ProviderGetRuntimeHealthInput = typeof ProviderGetRuntimeHealthInput.Type; + +export const OpenCodeRuntimeBootstrapLane = Schema.Literal("wsl-service"); +export type OpenCodeRuntimeBootstrapLane = typeof OpenCodeRuntimeBootstrapLane.Type; + +export const OpenCodeRuntimeBootstrapState = Schema.Literals([ + "unsupported", + "notInstalled", + "installing", + "starting", + "ready", + "error", +]); +export type OpenCodeRuntimeBootstrapState = typeof OpenCodeRuntimeBootstrapState.Type; + +export const ProviderRuntimeBootstrapStatusInput = Schema.Struct({ + provider: Schema.Literal("opencode"), +}); +export type ProviderRuntimeBootstrapStatusInput = typeof ProviderRuntimeBootstrapStatusInput.Type; + +export const ProviderRuntimeBootstrapInput = Schema.Struct({ + provider: Schema.Literal("opencode"), + forceReinstall: Schema.optional(Schema.Boolean), +}); +export type ProviderRuntimeBootstrapInput = typeof ProviderRuntimeBootstrapInput.Type; + +export const ProviderRuntimeBootstrapSnapshot = Schema.Struct({ + provider: Schema.Literal("opencode"), + lane: OpenCodeRuntimeBootstrapLane, + state: OpenCodeRuntimeBootstrapState, + serviceName: Schema.optional(Schema.Literal("jcode-opencode.service")), + binaryPath: Schema.optional(TrimmedNonEmptyString), + serverUrl: Schema.optional(TrimmedNonEmptyString), + profileId: Schema.optional(TrimmedNonEmptyString), + message: Schema.optional(Schema.String.check(Schema.isMaxLength(4096))), + checkedAt: TrimmedNonEmptyString, +}); +export type ProviderRuntimeBootstrapSnapshot = typeof ProviderRuntimeBootstrapSnapshot.Type; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 511ee932..7ef65033 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -167,6 +167,52 @@ it.effect("accepts provider search skills catalog requests", () => }), ); +it.effect("accepts provider runtime bootstrap status requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-bootstrap-status-1", + body: { + _tag: WS_METHODS.providerGetRuntimeBootstrapStatus, + provider: "opencode", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerGetRuntimeBootstrapStatus); + }), +); + +it.effect("accepts provider runtime bootstrap requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-bootstrap-1", + body: { + _tag: WS_METHODS.providerBootstrapRuntime, + provider: "opencode", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerBootstrapRuntime); + }), +); + +it.effect("accepts provider runtime repair requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-repair-1", + body: { + _tag: WS_METHODS.providerRepairRuntime, + provider: "opencode", + forceReinstall: true, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerRepairRuntime); + if (parsed.body._tag === WS_METHODS.providerRepairRuntime) { + assert.strictEqual(parsed.body.forceReinstall, true); + } + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decode(WsResponse, { diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 48c47c0c..54673b40 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -78,6 +78,8 @@ import { ProviderListCommandsInput, ProviderGetRuntimeHealthInput, ProviderGetComposerCapabilitiesInput, + ProviderRuntimeBootstrapInput, + ProviderRuntimeBootstrapStatusInput, ProviderListPluginsInput, ProviderListModelsInput, ProviderListAgentsInput, @@ -167,6 +169,9 @@ export const WS_METHODS = { // Provider discovery providerGetComposerCapabilities: "provider.getComposerCapabilities", providerGetRuntimeHealth: "provider.getRuntimeHealth", + providerGetRuntimeBootstrapStatus: "provider.getRuntimeBootstrapStatus", + providerBootstrapRuntime: "provider.bootstrapRuntime", + providerRepairRuntime: "provider.repairRuntime", providerCompactThread: "provider.compactThread", providerListCommands: "provider.listCommands", providerListSkills: "provider.listSkills", @@ -285,6 +290,9 @@ const WebSocketRequestBody = Schema.Union([ // Provider discovery tagRequestBody(WS_METHODS.providerGetComposerCapabilities, ProviderGetComposerCapabilitiesInput), tagRequestBody(WS_METHODS.providerGetRuntimeHealth, ProviderGetRuntimeHealthInput), + tagRequestBody(WS_METHODS.providerGetRuntimeBootstrapStatus, ProviderRuntimeBootstrapStatusInput), + tagRequestBody(WS_METHODS.providerBootstrapRuntime, ProviderRuntimeBootstrapInput), + tagRequestBody(WS_METHODS.providerRepairRuntime, ProviderRuntimeBootstrapInput), tagRequestBody(WS_METHODS.providerCompactThread, ProviderCompactThreadInput), tagRequestBody(WS_METHODS.providerListCommands, ProviderListCommandsInput), tagRequestBody(WS_METHODS.providerListSkills, ProviderListSkillsInput), From 098b4f47146534df076c0e1f27f11671af2212a2 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:05:29 -0400 Subject: [PATCH 02/12] feat(contracts): expose runtime bootstrap rpc contracts --- packages/contracts/src/rpc.test.ts | 33 +++++++++++++++++++++++++++++- packages/contracts/src/rpc.ts | 27 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 5baa940a..868f5695 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { WsRpcError, WsRpcGroup } from "./rpc"; +import { + WsProviderBootstrapRuntimeRpc, + WsProviderGetRuntimeBootstrapStatusRpc, + WsProviderRepairRuntimeRpc, + WsRpcError, + WsRpcGroup, +} from "./rpc"; +import { WS_METHODS } from "./ws"; describe("WS RPC contracts", () => { it("exports the additive Effect RPC group", () => { @@ -12,6 +19,30 @@ describe("WS RPC contracts", () => { expect(new WsRpcError({ message: "failed" }).message).toBe("failed"); }); + it("exports provider runtime bootstrap RPC methods", () => { + expect(WsProviderGetRuntimeBootstrapStatusRpc.key).toBe( + `effect/rpc/Rpc/${WS_METHODS.providerGetRuntimeBootstrapStatus}`, + ); + expect(WsProviderBootstrapRuntimeRpc.key).toBe( + `effect/rpc/Rpc/${WS_METHODS.providerBootstrapRuntime}`, + ); + expect(WsProviderRepairRuntimeRpc.key).toBe( + `effect/rpc/Rpc/${WS_METHODS.providerRepairRuntime}`, + ); + }); + + it("includes provider runtime bootstrap RPC methods in the group", () => { + expect(WsRpcGroup.requests.get(WS_METHODS.providerGetRuntimeBootstrapStatus)).toBe( + WsProviderGetRuntimeBootstrapStatusRpc, + ); + expect(WsRpcGroup.requests.get(WS_METHODS.providerBootstrapRuntime)).toBe( + WsProviderBootstrapRuntimeRpc, + ); + expect(WsRpcGroup.requests.get(WS_METHODS.providerRepairRuntime)).toBe( + WsProviderRepairRuntimeRpc, + ); + }); + it("preserves typed voice transcription auth-expired details", () => { const decoded = Schema.decodeUnknownSync(WsRpcError)({ _tag: "WsRpcError", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b08b8890..7b6751b3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -54,6 +54,9 @@ import { ProviderGetComposerCapabilitiesInput, ProviderComposerCapabilities, ProviderGetRuntimeHealthInput, + ProviderRuntimeBootstrapInput, + ProviderRuntimeBootstrapSnapshot, + ProviderRuntimeBootstrapStatusInput, ProviderListAgentsInput, ProviderListAgentsResult, ProviderListCommandsInput, @@ -573,6 +576,27 @@ export const WsProviderGetRuntimeHealthRpc = Rpc.make(WS_METHODS.providerGetRunt error: WsRpcError, }); +export const WsProviderGetRuntimeBootstrapStatusRpc = Rpc.make( + WS_METHODS.providerGetRuntimeBootstrapStatus, + { + payload: ProviderRuntimeBootstrapStatusInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, + }, +); + +export const WsProviderBootstrapRuntimeRpc = Rpc.make(WS_METHODS.providerBootstrapRuntime, { + payload: ProviderRuntimeBootstrapInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, +}); + +export const WsProviderRepairRuntimeRpc = Rpc.make(WS_METHODS.providerRepairRuntime, { + payload: ProviderRuntimeBootstrapInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, +}); + export const WsProviderCompactThreadRpc = Rpc.make(WS_METHODS.providerCompactThread, { payload: ProviderCompactThreadInput, success: Schema.Void, @@ -707,6 +731,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsProviderGetComposerCapabilitiesRpc, WsProviderGetRuntimeHealthRpc, + WsProviderGetRuntimeBootstrapStatusRpc, + WsProviderBootstrapRuntimeRpc, + WsProviderRepairRuntimeRpc, WsProviderCompactThreadRpc, WsProviderListCommandsRpc, WsProviderListSkillsRpc, From e535a7910ba08d46ba8227bf8507c52226276e2f Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:05:38 -0400 Subject: [PATCH 03/12] feat(web): bridge runtime bootstrap native api calls --- apps/web/src/wsNativeApi.ts | 4 ++++ packages/contracts/src/ipc.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f4b16d80..02de388c 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -675,6 +675,10 @@ export function createWsNativeApi(): NativeApi { getComposerCapabilities: (input) => transport.request(WS_METHODS.providerGetComposerCapabilities, input), getRuntimeHealth: (input) => transport.request(WS_METHODS.providerGetRuntimeHealth, input), + getRuntimeBootstrapStatus: (input) => + transport.request(WS_METHODS.providerGetRuntimeBootstrapStatus, input), + bootstrapRuntime: (input) => transport.request(WS_METHODS.providerBootstrapRuntime, input), + repairRuntime: (input) => transport.request(WS_METHODS.providerRepairRuntime, input), compactThread: (input) => transport.request(WS_METHODS.providerCompactThread, input), listCommands: (input) => transport.request(WS_METHODS.providerListCommands, input), listSkills: (input) => transport.request(WS_METHODS.providerListSkills, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b6188940..16cbf09b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -112,6 +112,9 @@ import type { ProviderComposerCapabilities, ProviderGetComposerCapabilitiesInput, ProviderGetRuntimeHealthInput, + ProviderRuntimeBootstrapInput, + ProviderRuntimeBootstrapSnapshot, + ProviderRuntimeBootstrapStatusInput, ProviderListAgentsInput, ProviderListAgentsResult, ProviderListCommandsInput, @@ -479,6 +482,15 @@ export interface NativeApi { input: ProviderGetComposerCapabilitiesInput, ) => Promise; getRuntimeHealth: (input: ProviderGetRuntimeHealthInput) => Promise; + getRuntimeBootstrapStatus: ( + input: ProviderRuntimeBootstrapStatusInput, + ) => Promise; + bootstrapRuntime: ( + input: ProviderRuntimeBootstrapInput, + ) => Promise; + repairRuntime: ( + input: ProviderRuntimeBootstrapInput, + ) => Promise; compactThread: (input: ProviderCompactThreadInput) => Promise; listCommands: (input: ProviderListCommandsInput) => Promise; listSkills: (input: ProviderListSkillsInput) => Promise; From e3a28961e0ef888b9e49fcf308b2c498c6682da1 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:05:46 -0400 Subject: [PATCH 04/12] feat(server): add wsl opencode runtime bootstrap --- .../provider/openCodeRuntimeBootstrap.test.ts | 514 ++++++++++++++++++ .../src/provider/openCodeRuntimeBootstrap.ts | 401 ++++++++++++++ 2 files changed, 915 insertions(+) create mode 100644 apps/server/src/provider/openCodeRuntimeBootstrap.test.ts create mode 100644 apps/server/src/provider/openCodeRuntimeBootstrap.ts diff --git a/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts b/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts new file mode 100644 index 00000000..6c9f8abd --- /dev/null +++ b/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts @@ -0,0 +1,514 @@ +import { + DEFAULT_SERVER_SETTINGS, + type OpenCodeRuntimeProfile, + type ServerSettings, +} from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_WSL_OPENCODE_SERVER_URL, + JCODE_OPENCODE_SERVICE_NAME, + WSL_OPENCODE_PROFILE_ID, + WSL_OPENCODE_PROFILE_LABEL, + bootstrapWslOpenCodeRuntime, + detectWslOpenCodeBootstrapStatus, + getWslOpenCodeRuntimeBootstrapStatus, + makeWslOpenCodeBootstrapPaths, + makeWslOpenCodeRuntimeProfilePatch, + redactBootstrapMessage, + repairWslOpenCodeRuntime, + renderJcodeOpenCodeServiceUnit, + renderJcodeOpenCodeStartScript, + upsertWslOpenCodeRuntimeProfile, + type WslOpenCodeBootstrapAdapter, + type WslOpenCodeBootstrapProbe, +} from "./openCodeRuntimeBootstrap"; + +const NOW = "2026-06-11T12:00:00.000Z"; +const BINARY_PATH = "/home/alice/.local/share/jcode/runtime/opencode/opencode"; +const START_SCRIPT_PATH = "/home/alice/.local/share/jcode/runtime/opencode/jcode-opencode-start"; +const SERVICE_UNIT_PATH = "/home/alice/.config/systemd/user/jcode-opencode.service"; + +type AdapterCall = + | { readonly name: "getProbe" } + | { readonly name: "ensureRuntimeDirectory" } + | { readonly name: "resolveOpenCodeBinary"; readonly forceReinstall: boolean } + | { readonly name: "writeExecutableFile"; readonly path: string; readonly contents: string } + | { readonly name: "writeFile"; readonly path: string; readonly contents: string } + | { readonly name: "systemctlUser"; readonly args: readonly string[] } + | { readonly name: "smokeRuntime"; readonly serverUrl: string }; + +function probe(overrides: Partial = {}): WslOpenCodeBootstrapProbe { + return { + now: NOW, + platform: "linux", + osRelease: "Linux 6.8.0 microsoft-standard-WSL2", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + userSystemdAvailable: true, + serviceExists: true, + serviceActive: true, + binaryPath: BINARY_PATH, + portAvailable: true, + profileReachable: false, + ...overrides, + }; +} + +function existingProfile(overrides: Partial = {}): OpenCodeRuntimeProfile { + return { + id: "managed-opencode", + label: "Managed OpenCode", + provider: "opencode", + mode: "managed", + configMode: "inherit", + binaryPath: "/usr/bin/opencode", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + ...overrides, + }; +} + +function settingsWithProfiles( + profiles: ServerSettings["providers"]["opencode"]["runtimeProfiles"], +): ServerSettings { + return { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + opencode: { + ...DEFAULT_SERVER_SETTINGS.providers.opencode, + runtimeProfiles: profiles, + activeRuntimeProfileId: profiles[0]?.id ?? "", + }, + }, + }; +} + +function recordingAdapter( + options: { + readonly probe?: WslOpenCodeBootstrapProbe; + readonly binaryPath?: string; + readonly failSmokeWith?: string; + } = {}, +): { readonly adapter: WslOpenCodeBootstrapAdapter; readonly calls: AdapterCall[] } { + const calls: AdapterCall[] = []; + const adapter: WslOpenCodeBootstrapAdapter = { + now: () => NOW, + getProbe: async () => { + calls.push({ name: "getProbe" }); + return options.probe ?? probe(); + }, + ensureRuntimeDirectory: async () => { + calls.push({ name: "ensureRuntimeDirectory" }); + }, + resolveOpenCodeBinary: async (forceReinstall) => { + calls.push({ name: "resolveOpenCodeBinary", forceReinstall }); + return options.binaryPath ?? BINARY_PATH; + }, + writeExecutableFile: async (path, contents) => { + calls.push({ name: "writeExecutableFile", path, contents }); + }, + writeFile: async (path, contents) => { + calls.push({ name: "writeFile", path, contents }); + }, + systemctlUser: async (args) => { + calls.push({ name: "systemctlUser", args }); + }, + smokeRuntime: async (serverUrl) => { + calls.push({ name: "smokeRuntime", serverUrl }); + if (options.failSmokeWith) { + throw new Error(options.failSmokeWith); + } + }, + }; + + return { adapter, calls }; +} + +describe("openCodeRuntimeBootstrap", () => { + it("exports the documented WSL OpenCode runtime constants", () => { + expect(JCODE_OPENCODE_SERVICE_NAME).toBe("jcode-opencode.service"); + expect(WSL_OPENCODE_PROFILE_ID).toBe("wsl-opencode-service"); + expect(WSL_OPENCODE_PROFILE_LABEL).toBe("WSL OpenCode service"); + expect(DEFAULT_WSL_OPENCODE_SERVER_URL).toBe("http://127.0.0.1:4096"); + }); + + it("reports unsupported outside Linux or without WSL markers", () => { + for (const input of [ + probe({ platform: "darwin", osRelease: "Darwin Kernel Version", env: {} }), + probe({ osRelease: "Linux 6.8.0 generic", env: {} }), + ]) { + const status = detectWslOpenCodeBootstrapStatus(input); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "unsupported", + checkedAt: NOW, + }); + expect(status.message).toContain("WSL"); + } + }); + + it("reports unsupported in WSL when user systemd is missing", () => { + const status = detectWslOpenCodeBootstrapStatus(probe({ userSystemdAvailable: false })); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "unsupported", + checkedAt: NOW, + }); + expect(status.message).toContain("systemd"); + }); + + it("reports an error when the default loopback port is occupied", () => { + const status = detectWslOpenCodeBootstrapStatus(probe({ portAvailable: false })); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + checkedAt: NOW, + }); + expect(status.message).toContain("port"); + }); + + it("reports ready when the WSL runtime profile is reachable", () => { + const status = detectWslOpenCodeBootstrapStatus( + probe({ serviceExists: false, serviceActive: false, profileReachable: true }), + ); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "ready", + binaryPath: BINARY_PATH, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + }); + + it("reports not installed when the service or binary is missing", () => { + for (const input of [ + probe({ serviceExists: false, serviceActive: false }), + probe({ binaryPath: null }), + ]) { + const status = detectWslOpenCodeBootstrapStatus(input); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "notInstalled", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + } + }); + + it("reports an error with the service name when an existing service is inactive", () => { + const status = detectWslOpenCodeBootstrapStatus(probe({ serviceActive: false })); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: BINARY_PATH, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + expect(status.message).toContain(JCODE_OPENCODE_SERVICE_NAME); + }); + + it("reports an error when an existing active service is unreachable", () => { + const status = detectWslOpenCodeBootstrapStatus(probe()); + + expect(status).toMatchObject({ + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: BINARY_PATH, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + expect(status.message).toContain("unreachable"); + }); + + it("renders a loopback-only user service unit", () => { + const unit = renderJcodeOpenCodeServiceUnit({ + startScriptPath: "/home/alice/.local/bin/jcode-opencode-start", + }); + + expect(unit).toContain("Description=JCode external OpenCode runtime"); + expect(unit).toContain("ExecStart=/home/alice/.local/bin/jcode-opencode-start"); + expect(unit).not.toContain("0.0.0.0"); + }); + + it("quotes generated service unit paths with systemd escapes", () => { + const unit = renderJcodeOpenCodeServiceUnit({ + startScriptPath: "/home/alice/bin/opencode start'script", + }); + + expect(unit).toContain('ExecStart="/home/alice/bin/opencode start\'script"'); + }); + + it("renders a start script that unsets inline OpenCode config before serving", () => { + const script = renderJcodeOpenCodeStartScript({ + binaryPath: BINARY_PATH, + host: "127.0.0.1", + port: 4096, + }); + + expect(script).toContain("unset OPENCODE_CONFIG_CONTENT"); + expect(script).toContain(`'${BINARY_PATH}' serve --hostname=127.0.0.1 --port=4096`); + expect(script).not.toContain("0.0.0.0"); + }); + + it("shell-quotes generated start script binary paths", () => { + const script = renderJcodeOpenCodeStartScript({ + binaryPath: "/tmp/opencode $(touch /tmp/pwned)'bin", + host: "127.0.0.1", + port: 4096, + }); + + expect(script).toContain("exec '/tmp/opencode $(touch /tmp/pwned)'\\''bin' serve"); + }); + + it("creates deterministic WSL bootstrap paths under the runtime and user systemd dirs", () => { + const paths = makeWslOpenCodeBootstrapPaths({ + homeDir: "/home/alice", + runtimeDir: "/home/alice/.local/share/jcode/runtime/opencode", + }); + + expect(paths).toEqual({ + runtimeDir: "/home/alice/.local/share/jcode/runtime/opencode", + startScriptPath: START_SCRIPT_PATH, + serviceUnitPath: SERVICE_UNIT_PATH, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + host: "127.0.0.1", + port: 4096, + }); + }); + + it("creates the WSL external runtime profile patch with safe defaults", () => { + const profile = makeWslOpenCodeRuntimeProfilePatch({ binaryPath: BINARY_PATH }); + + expect(profile).toEqual({ + id: WSL_OPENCODE_PROFILE_ID, + label: WSL_OPENCODE_PROFILE_LABEL, + provider: "opencode", + mode: "external", + configMode: "inherit", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + binaryPath: BINARY_PATH, + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }); + }); + + it("upserts the WSL profile without duplicating it", () => { + const settings = settingsWithProfiles([ + existingProfile(), + existingProfile({ + id: WSL_OPENCODE_PROFILE_ID, + label: "Old WSL profile", + mode: "external", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + binaryPath: "/old/opencode", + }), + ]); + const patch = makeWslOpenCodeRuntimeProfilePatch({ + binaryPath: BINARY_PATH, + serverUrl: "http://127.0.0.1:4097", + }); + + const nextProfiles = upsertWslOpenCodeRuntimeProfile( + settings.providers.opencode.runtimeProfiles, + patch, + ); + + expect(nextProfiles).toHaveLength(2); + expect(nextProfiles[0]).toEqual(settings.providers.opencode.runtimeProfiles[0]); + expect(nextProfiles[1]).toMatchObject({ + id: WSL_OPENCODE_PROFILE_ID, + label: WSL_OPENCODE_PROFILE_LABEL, + binaryPath: BINARY_PATH, + serverUrl: "http://127.0.0.1:4097", + }); + }); + + it("appends the WSL profile when it does not already exist", () => { + const settings = settingsWithProfiles([existingProfile()]); + const patch = makeWslOpenCodeRuntimeProfilePatch({ binaryPath: BINARY_PATH }); + + const nextProfiles = upsertWslOpenCodeRuntimeProfile( + settings.providers.opencode.runtimeProfiles, + patch, + ); + + expect(nextProfiles).toHaveLength(2); + expect(nextProfiles[1]?.id).toBe(WSL_OPENCODE_PROFILE_ID); + }); + + it("redacts credentials from bootstrap messages", () => { + const redacted = redactBootstrapMessage( + "failed token=abc password=secret http://user:pass@example.test/path?client_secret=1", + ); + + expect(redacted).not.toContain("abc"); + expect(redacted).not.toContain("secret"); + expect(redacted).not.toContain("user:pass"); + expect(redacted).toContain("token="); + expect(redacted).toContain("password="); + }); + + it("gets bootstrap status through the injected probe adapter", async () => { + const { adapter, calls } = recordingAdapter({ + probe: probe({ profileReachable: true, serverUrl: "http://127.0.0.1:4096" }), + }); + + const snapshot = await getWslOpenCodeRuntimeBootstrapStatus(adapter); + + expect(calls).toEqual([{ name: "getProbe" }]); + expect(snapshot).toMatchObject({ + state: "ready", + provider: "opencode", + lane: "wsl-service", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + }); + + it("installs by writing script and service, starting systemd, and returning a ready profile snapshot", async () => { + const { adapter, calls } = recordingAdapter(); + + const result = await bootstrapWslOpenCodeRuntime(adapter, { provider: "opencode" }); + + expect(calls.map((call) => call.name)).toEqual([ + "ensureRuntimeDirectory", + "resolveOpenCodeBinary", + "writeExecutableFile", + "writeFile", + "systemctlUser", + "systemctlUser", + "smokeRuntime", + ]); + expect(calls[1]).toEqual({ name: "resolveOpenCodeBinary", forceReinstall: false }); + expect(calls[2]).toMatchObject({ + name: "writeExecutableFile", + path: START_SCRIPT_PATH, + }); + if (calls[2]?.name === "writeExecutableFile") { + expect(calls[2].contents).toContain( + `'${BINARY_PATH}' serve --hostname=127.0.0.1 --port=4096`, + ); + } + expect(calls[3]).toMatchObject({ + name: "writeFile", + path: SERVICE_UNIT_PATH, + }); + if (calls[3]?.name === "writeFile") { + expect(calls[3].contents).toContain(`ExecStart=${START_SCRIPT_PATH}`); + } + expect(calls[4]).toEqual({ name: "systemctlUser", args: ["daemon-reload"] }); + expect(calls[5]).toEqual({ + name: "systemctlUser", + args: ["enable", "--now", JCODE_OPENCODE_SERVICE_NAME], + }); + expect(calls[6]).toEqual({ + name: "smokeRuntime", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + }); + expect(result.profile).toMatchObject({ + id: WSL_OPENCODE_PROFILE_ID, + label: WSL_OPENCODE_PROFILE_LABEL, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + binaryPath: BINARY_PATH, + }); + expect(result.snapshot).toMatchObject({ + state: "ready", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: BINARY_PATH, + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + profileId: WSL_OPENCODE_PROFILE_ID, + checkedAt: NOW, + }); + }); + + it("repairs idempotently without duplicating the profile", async () => { + const existingWslProfile = makeWslOpenCodeRuntimeProfilePatch({ + binaryPath: "/old/opencode", + }); + const { adapter, calls } = recordingAdapter(); + + const result = await repairWslOpenCodeRuntime(adapter, { + provider: "opencode", + forceReinstall: true, + }); + const nextProfiles = upsertWslOpenCodeRuntimeProfile([existingWslProfile], result.profile); + + expect(nextProfiles).toHaveLength(1); + expect(nextProfiles[0]).toMatchObject({ + id: WSL_OPENCODE_PROFILE_ID, + binaryPath: BINARY_PATH, + }); + expect(calls.map((call) => call.name)).toEqual([ + "ensureRuntimeDirectory", + "resolveOpenCodeBinary", + "writeExecutableFile", + "writeFile", + "systemctlUser", + "systemctlUser", + "smokeRuntime", + ]); + expect(calls[1]).toEqual({ name: "resolveOpenCodeBinary", forceReinstall: true }); + expect(calls[4]).toEqual({ name: "systemctlUser", args: ["daemon-reload"] }); + expect(calls[5]).toEqual({ + name: "systemctlUser", + args: ["restart", JCODE_OPENCODE_SERVICE_NAME], + }); + expect(calls[6]).toEqual({ + name: "smokeRuntime", + serverUrl: DEFAULT_WSL_OPENCODE_SERVER_URL, + }); + expect(result.snapshot.state).toBe("ready"); + }); + + it("redacts caught bootstrap failures before surfacing them", async () => { + const { adapter } = recordingAdapter({ + failSmokeWith: "token=abc password=secret client_secret=hidden", + }); + + await expect(bootstrapWslOpenCodeRuntime(adapter, { provider: "opencode" })).rejects.toThrow( + "token=", + ); + await expect( + bootstrapWslOpenCodeRuntime(adapter, { provider: "opencode" }), + ).rejects.not.toThrow("secret"); + }); +}); diff --git a/apps/server/src/provider/openCodeRuntimeBootstrap.ts b/apps/server/src/provider/openCodeRuntimeBootstrap.ts new file mode 100644 index 00000000..c25ad54a --- /dev/null +++ b/apps/server/src/provider/openCodeRuntimeBootstrap.ts @@ -0,0 +1,401 @@ +import type { + OpenCodeRuntimeProfile, + ProviderRuntimeBootstrapInput, + ProviderRuntimeBootstrapSnapshot, +} from "@jcode/contracts"; + +export const JCODE_OPENCODE_SERVICE_NAME = "jcode-opencode.service" as const; +export const WSL_OPENCODE_PROFILE_ID = "wsl-opencode-service" as const; +export const WSL_OPENCODE_PROFILE_LABEL = "WSL OpenCode service" as const; +export const DEFAULT_WSL_OPENCODE_SERVER_URL = "http://127.0.0.1:4096" as const; + +const WSL_OPENCODE_START_SCRIPT_NAME = "jcode-opencode-start" as const; + +export interface WslOpenCodeBootstrapProbe { + readonly now: string; + readonly platform: string; + readonly osRelease: string; + readonly env: Readonly>; + readonly userSystemdAvailable: boolean; + readonly serviceExists: boolean; + readonly serviceActive: boolean; + readonly binaryPath: string | null; + readonly portAvailable: boolean; + readonly profileReachable: boolean; + readonly serverUrl?: string; +} + +export interface WslOpenCodeBootstrapAdapter { + readonly paths?: WslOpenCodeBootstrapPaths; + readonly now: () => string; + readonly getProbe: () => Promise; + readonly ensureRuntimeDirectory: () => Promise; + readonly resolveOpenCodeBinary: (forceReinstall: boolean) => Promise; + readonly writeExecutableFile: (path: string, contents: string) => Promise; + readonly writeFile: (path: string, contents: string) => Promise; + readonly systemctlUser: (args: readonly string[]) => Promise; + readonly smokeRuntime: (serverUrl: string) => Promise; +} + +export interface WslOpenCodeBootstrapPaths { + readonly runtimeDir: string; + readonly startScriptPath: string; + readonly serviceUnitPath: string; + readonly serverUrl: string; + readonly host: "127.0.0.1"; + readonly port: 4096; +} + +type BootstrapState = ProviderRuntimeBootstrapSnapshot["state"]; + +function isTruthyEnvMarker(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + +function isWslProbe(input: WslOpenCodeBootstrapProbe): boolean { + if (input.platform !== "linux") { + return false; + } + + const osRelease = input.osRelease.toLowerCase(); + return ( + osRelease.includes("microsoft") || + osRelease.includes("wsl") || + isTruthyEnvMarker(input.env.WSL_DISTRO_NAME) || + isTruthyEnvMarker(input.env.WSL_INTEROP) + ); +} + +function snapshot(input: { + readonly checkedAt: string; + readonly state: BootstrapState; + readonly message?: string; + readonly serviceName?: typeof JCODE_OPENCODE_SERVICE_NAME; + readonly binaryPath?: string; + readonly serverUrl?: string; + readonly profileId?: string; +}): ProviderRuntimeBootstrapSnapshot { + return { + provider: "opencode", + lane: "wsl-service", + state: input.state, + checkedAt: input.checkedAt, + ...(input.serviceName ? { serviceName: input.serviceName } : {}), + ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), + ...(input.serverUrl ? { serverUrl: input.serverUrl } : {}), + ...(input.profileId ? { profileId: input.profileId } : {}), + ...(input.message ? { message: redactBootstrapMessage(input.message) } : {}), + }; +} + +export function detectWslOpenCodeBootstrapStatus( + input: WslOpenCodeBootstrapProbe, +): ProviderRuntimeBootstrapSnapshot { + const serverUrl = input.serverUrl ?? DEFAULT_WSL_OPENCODE_SERVER_URL; + + if (!isWslProbe(input)) { + return snapshot({ + checkedAt: input.now, + state: "unsupported", + message: "WSL OpenCode bootstrap is only supported inside WSL.", + }); + } + + if (!input.userSystemdAvailable) { + return snapshot({ + checkedAt: input.now, + state: "unsupported", + message: "WSL user systemd is required for jcode-opencode.service.", + }); + } + + if (!input.portAvailable) { + return snapshot({ + checkedAt: input.now, + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), + message: "OpenCode loopback port 4096 is already occupied.", + }); + } + + if (input.profileReachable) { + return snapshot({ + checkedAt: input.now, + state: "ready", + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), + }); + } + + if (!input.serviceExists || !input.binaryPath) { + return snapshot({ + checkedAt: input.now, + state: "notInstalled", + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), + message: "WSL OpenCode service or binary is not installed.", + }); + } + + if (!input.serviceActive) { + return snapshot({ + checkedAt: input.now, + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: input.binaryPath, + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + message: `${JCODE_OPENCODE_SERVICE_NAME} exists but is not active.`, + }); + } + + if (!input.profileReachable) { + return snapshot({ + checkedAt: input.now, + state: "error", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: input.binaryPath, + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + message: `${JCODE_OPENCODE_SERVICE_NAME} is active but the OpenCode runtime is unreachable.`, + }); + } + + return snapshot({ + checkedAt: input.now, + state: "ready", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath: input.binaryPath, + serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + }); +} + +export function renderJcodeOpenCodeServiceUnit(input: { + readonly startScriptPath: string; +}): string { + return [ + "[Unit]", + "Description=JCode external OpenCode runtime", + "After=network.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${quoteSystemdExecArg(input.startScriptPath)}`, + "Restart=on-failure", + "RestartSec=2", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +function quoteSystemdExecArg(value: string): string { + if (!/[\s"\\]/u.test(value)) { + return value; + } + return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`; +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/gu, "'\\''")}'`; +} + +export function renderJcodeOpenCodeStartScript(input: { + readonly binaryPath: string; + readonly host: "127.0.0.1"; + readonly port: 4096; +}): string { + return [ + "#!/usr/bin/env sh", + "set -eu", + "unset OPENCODE_CONFIG_CONTENT", + `exec ${quoteShellArg(input.binaryPath)} serve --hostname=${input.host} --port=${input.port}`, + "", + ].join("\n"); +} + +function withoutTrailingSlash(path: string): string { + return path.length > 1 ? path.replace(/\/+$/u, "") : path; +} + +export function makeWslOpenCodeBootstrapPaths(input: { + readonly homeDir: string; + readonly runtimeDir: string; + readonly serverUrl?: string; +}): WslOpenCodeBootstrapPaths { + const homeDir = withoutTrailingSlash(input.homeDir); + const runtimeDir = withoutTrailingSlash(input.runtimeDir); + + return { + runtimeDir, + startScriptPath: `${runtimeDir}/${WSL_OPENCODE_START_SCRIPT_NAME}`, + serviceUnitPath: `${homeDir}/.config/systemd/user/${JCODE_OPENCODE_SERVICE_NAME}`, + serverUrl: input.serverUrl ?? DEFAULT_WSL_OPENCODE_SERVER_URL, + host: "127.0.0.1", + port: 4096, + }; +} + +export function makeWslOpenCodeRuntimeProfilePatch(input: { + readonly binaryPath: string; + readonly serverUrl?: string; +}): OpenCodeRuntimeProfile { + return { + id: WSL_OPENCODE_PROFILE_ID, + label: WSL_OPENCODE_PROFILE_LABEL, + provider: "opencode", + mode: "external", + configMode: "inherit", + serverUrl: input.serverUrl ?? DEFAULT_WSL_OPENCODE_SERVER_URL, + binaryPath: input.binaryPath, + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }; +} + +export function upsertWslOpenCodeRuntimeProfile( + existing: readonly OpenCodeRuntimeProfile[], + profile: OpenCodeRuntimeProfile, +): OpenCodeRuntimeProfile[] { + const nextProfiles = existing.map((existingProfile) => + existingProfile.id === WSL_OPENCODE_PROFILE_ID ? profile : existingProfile, + ); + + return existing.some((existingProfile) => existingProfile.id === WSL_OPENCODE_PROFILE_ID) + ? nextProfiles + : [...existing, profile]; +} + +export function redactBootstrapMessage(message: string): string { + return message + .replace(/\b(client_secret)=([^\s&]+)/gi, "client_=") + .replace(/\b(token|password)=([^\s&]+)/gi, "$1=") + .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1@"); +} + +export async function getWslOpenCodeRuntimeBootstrapStatus( + adapter: WslOpenCodeBootstrapAdapter, +): Promise { + return detectWslOpenCodeBootstrapStatus(await adapter.getProbe()); +} + +export async function bootstrapWslOpenCodeRuntime( + adapter: WslOpenCodeBootstrapAdapter, + input: ProviderRuntimeBootstrapInput, +): Promise<{ + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; +}> { + return runWslOpenCodeRuntimeOrchestration(adapter, input, { + serviceCommand: ["enable", "--now", JCODE_OPENCODE_SERVICE_NAME], + }); +} + +export async function repairWslOpenCodeRuntime( + adapter: WslOpenCodeBootstrapAdapter, + input: ProviderRuntimeBootstrapInput, +): Promise<{ + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; +}> { + return runWslOpenCodeRuntimeOrchestration(adapter, input, { + serviceCommand: ["restart", JCODE_OPENCODE_SERVICE_NAME], + }); +} + +function getErrorMessage(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; + } + if (typeof cause === "string") { + return cause; + } + return "WSL OpenCode runtime bootstrap failed."; +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + return index > 0 ? path.slice(0, index) : "."; +} + +function deriveWslHomeDirectory(binaryPath: string): string { + if (binaryPath.startsWith("/root/")) { + return "/root"; + } + + const homeMatch = binaryPath.match(/^(\/home\/[^/]+)(?:\/|$)/); + if (homeMatch?.[1]) { + return homeMatch[1]; + } + + const envHome = process.env.HOME?.trim(); + if (envHome?.startsWith("/")) { + return envHome; + } + + throw new Error("Unable to resolve WSL home directory for OpenCode runtime bootstrap."); +} + +function getFallbackBootstrapPaths(binaryPath: string): WslOpenCodeBootstrapPaths { + return makeWslOpenCodeBootstrapPaths({ + homeDir: deriveWslHomeDirectory(binaryPath), + runtimeDir: dirname(binaryPath), + }); +} + +async function runWslOpenCodeRuntimeOrchestration( + adapter: WslOpenCodeBootstrapAdapter, + input: ProviderRuntimeBootstrapInput, + options: { readonly serviceCommand: readonly string[] }, +): Promise<{ + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; +}> { + try { + await adapter.ensureRuntimeDirectory(); + const binaryPath = await adapter.resolveOpenCodeBinary(Boolean(input.forceReinstall)); + const paths = adapter.paths ?? getFallbackBootstrapPaths(binaryPath); + const profile = makeWslOpenCodeRuntimeProfilePatch({ binaryPath, serverUrl: paths.serverUrl }); + + await adapter.writeExecutableFile( + paths.startScriptPath, + renderJcodeOpenCodeStartScript({ binaryPath, host: paths.host, port: paths.port }), + ); + await adapter.writeFile( + paths.serviceUnitPath, + renderJcodeOpenCodeServiceUnit({ startScriptPath: paths.startScriptPath }), + ); + await adapter.systemctlUser(["daemon-reload"]); + await adapter.systemctlUser(options.serviceCommand); + await adapter.smokeRuntime(paths.serverUrl); + + return { + profile, + snapshot: snapshot({ + checkedAt: adapter.now(), + state: "ready", + serviceName: JCODE_OPENCODE_SERVICE_NAME, + binaryPath, + serverUrl: paths.serverUrl, + profileId: WSL_OPENCODE_PROFILE_ID, + }), + }; + } catch (cause) { + throw new Error(redactBootstrapMessage(getErrorMessage(cause))); + } +} From 8158c31f8a25b215eab9778fe8279d6d479ff31d Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:05:53 -0400 Subject: [PATCH 05/12] feat(server): wire runtime bootstrap rpc handlers --- apps/server/src/wsRpc.ts | 273 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index c2e631eb..3b1d8eb5 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -1,11 +1,16 @@ import { execFile } from "node:child_process"; import { realpathSync } from "node:fs"; +import { createServer } from "node:net"; import { type AuthAccessStreamEvent, type AuthCapabilityScope, type AuthClientSession, type AuthSessionId, + type OpenCodeRuntimeProfile, + type ProviderRuntimeBootstrapInput, + type ProviderRuntimeBootstrapSnapshot, + type ProviderRuntimeBootstrapStatusInput, MessageId, ORCHESTRATION_WS_METHODS, type ServerGenerateThreadRecapInput, @@ -39,6 +44,7 @@ import { } from "effect"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { authErrorResponse, makeEffectAuthRequest } from "./auth/http"; import { BootstrapCredentialService } from "./auth/Services/BootstrapCredentialService"; @@ -69,6 +75,15 @@ import { OpenCodeRuntimeLive, } from "./provider/opencodeRuntime"; import { checkOpenCodeRuntimeHealth } from "./provider/openCodeRuntimeHealth"; +import { + JCODE_OPENCODE_SERVICE_NAME, + bootstrapWslOpenCodeRuntime, + getWslOpenCodeRuntimeBootstrapStatus, + makeWslOpenCodeBootstrapPaths, + repairWslOpenCodeRuntime, + upsertWslOpenCodeRuntimeProfile, + type WslOpenCodeBootstrapAdapter, +} from "./provider/openCodeRuntimeBootstrap"; import { applyOpenClawSecretUpdate } from "./provider/openclawSecretUpdate"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; @@ -82,6 +97,42 @@ import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; const MAX_DIAGNOSTIC_CHILD_PROCESSES = 80; const MAX_DIAGNOSTIC_ARGS_CHARS = 500; +function execFileText(file: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile(file, [...args], { maxBuffer: 2 * 1024 * 1024 }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +async function commandSucceeds(file: string, args: readonly string[]): Promise { + try { + await execFileText(file, args); + return true; + } catch { + return false; + } +} + +function isLoopbackPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => { + resolve(false); + }); + server.once("listening", () => { + server.close(() => { + resolve(true); + }); + }); + server.listen(port, "127.0.0.1"); + }); +} + type CurrentRpcAuthSessionShape = AuthenticatedSession | null; const CurrentRpcAuthSession = ServiceMap.Service( @@ -241,6 +292,7 @@ function withCurrentClientSession( export const makeWsRpcLayer = () => WsRpcGroup.toLayer( Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const checkpointDiffQuery = yield* CheckpointDiffQuery; const bootstrapCredentials = yield* BootstrapCredentialService; const config = yield* ServerConfig; @@ -287,6 +339,194 @@ export const makeWsRpcLayer = () => }); }).pipe(Effect.provide(OpenCodeRuntimeLive)); + const runtimeBootstrapPaths = makeWslOpenCodeBootstrapPaths({ + homeDir: config.homeDir, + runtimeDir: path.join(config.homeDir, ".local", "share", "jcode", "runtime", "opencode"), + }); + const runtimeBootstrapServiceUnitDir = path.dirname(runtimeBootstrapPaths.serviceUnitPath); + + const resolveBootstrapOpenCodeBinary = async (_forceReinstall: boolean): Promise => { + const settings = await Effect.runPromise(serverSettings.getSettings); + const configuredBinary = + settings.providers.opencode.binaryPath.trim() || OPENCODE_CLI_SPEC.defaultBinaryPath; + + if (configuredBinary.includes("/")) { + const exists = await Effect.runPromise( + fileSystem.exists(configuredBinary).pipe(Effect.orElseSucceed(() => false)), + ); + if (!exists) { + throw new Error(`OpenCode binary not found at ${configuredBinary}.`); + } + return configuredBinary; + } + + const stdout = await execFileText("sh", [ + "-lc", + 'command -v "$1"', + "jcode-opencode-resolve", + configuredBinary, + ]).catch(() => ""); + const resolved = stdout.trim().split(/\r?\n/u)[0]?.trim(); + if (resolved) { + return resolved; + } + + throw new Error(`OpenCode binary '${configuredBinary}' was not found on PATH.`); + }; + + const smokeBootstrapOpenCodeRuntime = async (serverUrl: string): Promise => { + const binaryPath = await resolveBootstrapOpenCodeBinary(false); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + yield* runtime.connectToOpenCodeServer({ + binaryPath, + cliSpec: OPENCODE_CLI_SPEC, + serverUrl, + timeoutMs: 5_000, + }); + }), + ).pipe( + Effect.provide(OpenCodeRuntimeLive), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + ); + }; + + const writeBootstrapFile = (filePath: string, contents: string) => + Effect.runPromise( + fileSystem + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe(Effect.andThen(fileSystem.writeFileString(filePath, contents))), + ); + + const runtimeBootstrapAdapter: WslOpenCodeBootstrapAdapter = { + paths: runtimeBootstrapPaths, + now: () => new Date().toISOString(), + getProbe: async () => { + const [osRelease, userSystemdAvailable, serviceExists, serviceActive, binaryPath] = + await Promise.all([ + Effect.runPromise( + fileSystem + .readFileString("/proc/sys/kernel/osrelease") + .pipe(Effect.catch(() => Effect.succeed(""))), + ), + commandSucceeds("systemctl", ["--user", "show-environment"]), + commandSucceeds("systemctl", ["--user", "cat", JCODE_OPENCODE_SERVICE_NAME]), + commandSucceeds("systemctl", [ + "--user", + "is-active", + "--quiet", + JCODE_OPENCODE_SERVICE_NAME, + ]), + resolveBootstrapOpenCodeBinary(false).catch(() => null), + ]); + const profileReachable = await smokeBootstrapOpenCodeRuntime( + runtimeBootstrapPaths.serverUrl, + ) + .then(() => true) + .catch(() => false); + const portAvailable = profileReachable + ? true + : await isLoopbackPortAvailable(runtimeBootstrapPaths.port); + + return { + now: new Date().toISOString(), + platform: process.platform, + osRelease, + env: process.env, + userSystemdAvailable, + serviceExists, + serviceActive, + binaryPath, + portAvailable, + profileReachable, + serverUrl: runtimeBootstrapPaths.serverUrl, + }; + }, + ensureRuntimeDirectory: () => + Effect.runPromise( + fileSystem + .makeDirectory(runtimeBootstrapPaths.runtimeDir, { recursive: true }) + .pipe( + Effect.andThen( + fileSystem.makeDirectory(runtimeBootstrapServiceUnitDir, { recursive: true }), + ), + ), + ), + resolveOpenCodeBinary: resolveBootstrapOpenCodeBinary, + writeExecutableFile: (filePath, contents) => + writeBootstrapFile(filePath, contents).then(() => + Effect.runPromise(fileSystem.chmod(filePath, 0o755)), + ), + writeFile: writeBootstrapFile, + systemctlUser: (args) => + execFileText("systemctl", ["--user", ...args]).then(() => undefined), + smokeRuntime: smokeBootstrapOpenCodeRuntime, + }; + + const getOpenCodeRuntimeBootstrapStatus = ( + _input: ProviderRuntimeBootstrapStatusInput, + ): Effect.Effect => + Effect.tryPromise({ + try: () => getWslOpenCodeRuntimeBootstrapStatus(runtimeBootstrapAdapter), + catch: (cause) => cause, + }); + + const persistOpenCodeRuntimeProfile = (profile: OpenCodeRuntimeProfile) => + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const runtimeProfiles = upsertWslOpenCodeRuntimeProfile( + settings.providers.opencode.runtimeProfiles, + profile, + ); + yield* serverSettings.updateSettings({ + providers: { + opencode: { + serverUrl: profile.serverUrl ?? "", + runtimeProfiles, + activeRuntimeProfileId: profile.id, + }, + }, + }); + }); + + const applyRuntimeBootstrapResult = (result: { + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; + }) => persistOpenCodeRuntimeProfile(result.profile).pipe(Effect.as(result.snapshot)); + + const bootstrapOpenCodeRuntime = ( + input: ProviderRuntimeBootstrapInput, + ): Effect.Effect => + getOpenCodeRuntimeBootstrapStatus(input).pipe( + Effect.flatMap((status) => { + if (status.state === "unsupported") { + return Effect.succeed(status); + } + return Effect.tryPromise({ + try: () => bootstrapWslOpenCodeRuntime(runtimeBootstrapAdapter, input), + catch: (cause) => cause, + }).pipe(Effect.flatMap(applyRuntimeBootstrapResult)); + }), + ); + + const repairOpenCodeRuntime = ( + input: ProviderRuntimeBootstrapInput, + ): Effect.Effect => + getOpenCodeRuntimeBootstrapStatus(input).pipe( + Effect.flatMap((status) => { + if (status.state === "unsupported") { + return Effect.succeed(status); + } + return Effect.tryPromise({ + try: () => repairWslOpenCodeRuntime(runtimeBootstrapAdapter, input), + catch: (cause) => cause, + }).pipe(Effect.flatMap(applyRuntimeBootstrapResult)); + }), + ); + const canonicalizeProjectWorkspaceRoot = Effect.fnUntraced(function* ( workspaceRoot: string, options: { readonly createIfMissing?: boolean } = {}, @@ -514,6 +754,30 @@ export const makeWsRpcLayer = () => }), ); + const requireOwnerSession: Effect.Effect = Effect.serviceOption( + CurrentRpcAuthSession, + ).pipe( + Effect.flatMap((sessionOpt) => { + const session = Option.getOrNull(sessionOpt); + if (!session) { + return Effect.fail(new WsRpcError({ message: "Authentication required" })); + } + if (session.role === "owner") { + return Effect.void; + } + return Effect.fail( + new WsRpcError({ + message: "Insufficient permissions: this operation requires owner role", + }), + ); + }), + ); + + const ownerOnly = ( + effect: Effect.Effect, + ): Effect.Effect => + requireOwnerSession.pipe(Effect.flatMap(() => effect)); + return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => withCommandScope( @@ -1086,6 +1350,15 @@ export const makeWsRpcLayer = () => }), "Failed to get runtime health", ), + [WS_METHODS.providerGetRuntimeBootstrapStatus]: (input) => + rpcEffect( + getOpenCodeRuntimeBootstrapStatus(input), + "Failed to get runtime bootstrap status", + ), + [WS_METHODS.providerBootstrapRuntime]: (input) => + rpcEffect(ownerOnly(bootstrapOpenCodeRuntime(input)), "Failed to bootstrap runtime"), + [WS_METHODS.providerRepairRuntime]: (input) => + rpcEffect(ownerOnly(repairOpenCodeRuntime(input)), "Failed to repair runtime"), [WS_METHODS.providerCompactThread]: (input) => rpcEffect(providerService.compactThread(input), "Failed to compact thread"), [WS_METHODS.providerListCommands]: (input) => From e49ec1dfc9b3725b3a7538f1c87c5f25dcfb3c7a Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:06:00 -0400 Subject: [PATCH 06/12] feat(web): add opencode runtime bootstrap controls --- .../OpenCodeRuntimeSettingsPanel.browser.tsx | 148 +++++++++++++++++ .../OpenCodeRuntimeSettingsPanel.tsx | 154 ++++++++++++++++-- 2 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx diff --git a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx new file mode 100644 index 00000000..111f5aa7 --- /dev/null +++ b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx @@ -0,0 +1,148 @@ +import "../index.css"; + +import type { + NativeApi, + OpenCodeRuntimeHealth, + ProviderRuntimeBootstrapSnapshot, +} from "@jcode/contracts"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { OpenCodeRuntimeSettingsPanel } from "./OpenCodeRuntimeSettingsPanel"; + +const NOW = "2026-06-11T12:00:00.000Z"; + +const health: OpenCodeRuntimeHealth = { + provider: "opencode", + profileId: "wsl-opencode-service", + profileLabel: "WSL OpenCode service", + mode: "external", + configMode: "inherit", + status: "healthy", + serverUrl: "http://127.0.0.1:4096", + external: true, + capabilities: {}, + mismatches: [], + checkedAt: NOW, +}; + +let bootstrapStatus: ProviderRuntimeBootstrapSnapshot; + +const nativeApi = { + provider: { + getRuntimeHealth: vi.fn(async () => health), + getRuntimeBootstrapStatus: vi.fn(async () => bootstrapStatus), + bootstrapRuntime: vi.fn(async () => ({ + ...bootstrapStatus, + state: "ready" as const, + message: "OpenCode runtime is ready.", + })), + repairRuntime: vi.fn(async () => ({ + ...bootstrapStatus, + state: "ready" as const, + message: "OpenCode runtime is ready.", + })), + }, +} as unknown as NativeApi; + +describe("OpenCodeRuntimeSettingsPanel", () => { + beforeEach(() => { + bootstrapStatus = { + provider: "opencode", + lane: "wsl-service", + state: "notInstalled", + checkedAt: NOW, + message: "OpenCode runtime is not installed.", + }; + for (const method of Object.values(nativeApi.provider)) { + if (typeof method === "function" && "mockClear" in method) { + (method as { mockClear: () => void }).mockClear(); + } + } + window.nativeApi = nativeApi; + }); + + afterEach(() => { + Reflect.deleteProperty(window, "nativeApi"); + document.body.innerHTML = ""; + }); + + it("shows install when runtime bootstrap status is not installed", async () => { + const screen = await render(); + + await vi.waitFor(() => { + expect(nativeApi.provider.getRuntimeBootstrapStatus).toHaveBeenCalledWith({ + provider: "opencode", + }); + expect(document.body.textContent).toContain("OpenCode runtime is not installed."); + }); + + await page.getByRole("button", { name: "Install OpenCode runtime" }).click(); + + await vi.waitFor(() => { + expect(nativeApi.provider.bootstrapRuntime).toHaveBeenCalledWith({ provider: "opencode" }); + expect(nativeApi.provider.getRuntimeHealth).toHaveBeenCalledWith({ + provider: "opencode", + forceRefresh: true, + }); + expect(document.body.textContent).toContain("OpenCode runtime is ready."); + }); + + await screen.unmount(); + }); + + it("shows repair when runtime bootstrap status is error", async () => { + bootstrapStatus = { + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: "jcode-opencode.service", + message: "Service stopped.", + checkedAt: NOW, + }; + + const screen = await render(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Service stopped."); + }); + + await page.getByRole("button", { name: "Repair runtime" }).click(); + + await vi.waitFor(() => { + expect(nativeApi.provider.repairRuntime).toHaveBeenCalledWith({ provider: "opencode" }); + expect(nativeApi.provider.getRuntimeHealth).toHaveBeenCalledWith({ + provider: "opencode", + forceRefresh: true, + }); + expect(document.body.textContent).toContain("OpenCode runtime is ready."); + }); + + await screen.unmount(); + }); + + it("shows unsupported status without install or repair actions", async () => { + bootstrapStatus = { + provider: "opencode", + lane: "wsl-service", + state: "unsupported", + message: "WSL bootstrap is only available on Windows hosts.", + checkedAt: NOW, + }; + + const screen = await render(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "WSL bootstrap is only available on Windows hosts.", + ); + expect(document.body.textContent).not.toContain("Install OpenCode runtime"); + expect(document.body.textContent).not.toContain("Repair runtime"); + expect(nativeApi.provider.bootstrapRuntime).not.toHaveBeenCalled(); + expect(nativeApi.provider.repairRuntime).not.toHaveBeenCalled(); + }); + + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx index 4ef40f7d..da8af872 100644 --- a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx +++ b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx @@ -1,7 +1,7 @@ -import type { OpenCodeRuntimeHealth } from "@jcode/contracts"; +import type { OpenCodeRuntimeHealth, ProviderRuntimeBootstrapSnapshot } from "@jcode/contracts"; import { useCallback, useEffect, useState } from "react"; -import { Loader2Icon, RefreshCwIcon } from "../lib/icons"; +import { DownloadIcon, Loader2Icon, RefreshCwIcon, WrenchIcon } from "../lib/icons"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; @@ -29,9 +29,29 @@ function capabilityLine( return `${label}: ${summary.count}`; } +function bootstrapStatusClassName(state: ProviderRuntimeBootstrapSnapshot["state"]): string { + switch (state) { + case "ready": + return "text-emerald-500"; + case "installing": + case "starting": + return "text-amber-500"; + case "error": + return "text-destructive"; + case "notInstalled": + case "unsupported": + return "text-muted-foreground"; + } +} + export function OpenCodeRuntimeSettingsPanel() { const [health, setHealth] = useState(null); + const [bootstrapStatus, setBootstrapStatus] = useState( + null, + ); const [isChecking, setIsChecking] = useState(false); + const [isBootstrapping, setIsBootstrapping] = useState(false); + const [bootstrapAction, setBootstrapAction] = useState<"install" | "repair" | null>(null); const checkRuntime = useCallback(async (forceRefresh = false) => { setIsChecking(true); @@ -52,11 +72,65 @@ export function OpenCodeRuntimeSettingsPanel() { } }, []); + const checkBootstrapStatus = useCallback(async () => { + try { + const result = await ensureNativeApi().provider.getRuntimeBootstrapStatus({ + provider: "opencode", + }); + setBootstrapStatus(result); + } catch (error) { + toastManager.add({ + type: "error", + title: "OpenCode runtime bootstrap status failed", + description: (error as Error).message, + }); + } + }, []); + useEffect(() => { - void checkRuntime(false); + void Promise.all([checkRuntime(false), checkBootstrapStatus()]); + }, [checkBootstrapStatus, checkRuntime]); + + const installRuntime = useCallback(async () => { + setIsBootstrapping(true); + setBootstrapAction("install"); + try { + const result = await ensureNativeApi().provider.bootstrapRuntime({ provider: "opencode" }); + setBootstrapStatus(result); + await checkRuntime(true); + } catch (error) { + toastManager.add({ + type: "error", + title: "OpenCode runtime install failed", + description: (error as Error).message, + }); + } finally { + setIsBootstrapping(false); + setBootstrapAction(null); + } + }, [checkRuntime]); + + const repairRuntime = useCallback(async () => { + setIsBootstrapping(true); + setBootstrapAction("repair"); + try { + const result = await ensureNativeApi().provider.repairRuntime({ provider: "opencode" }); + setBootstrapStatus(result); + await checkRuntime(true); + } catch (error) { + toastManager.add({ + type: "error", + title: "OpenCode runtime repair failed", + description: (error as Error).message, + }); + } finally { + setIsBootstrapping(false); + setBootstrapAction(null); + } }, [checkRuntime]); const status = health?.status ?? "unknown"; + const bootstrapMessage = bootstrapStatus?.message ?? null; return (
@@ -74,22 +148,68 @@ export function OpenCodeRuntimeSettingsPanel() {
) : null} - +
+ {bootstrapStatus?.state === "notInstalled" ? ( + + ) : null} + {bootstrapStatus?.state === "error" ? ( + + ) : null} + +
+ {bootstrapStatus ? ( +
+
+ + Bootstrap: {bootstrapStatus.state} + + {bootstrapStatus.serviceName ? {bootstrapStatus.serviceName} : null} +
+ {bootstrapMessage ?
{bootstrapMessage}
: null} +
+ ) : null} + {health ? (
{capabilityLine("Commands", health.capabilities.commands)}
From 5392a36f1b7798b233ce80a1ba30cef459c4b26c Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:06:06 -0400 Subject: [PATCH 07/12] docs: record wsl opencode bootstrap design --- ...26-06-11-wsl-opencode-runtime-bootstrap.md | 1005 +++++++++++++++++ ...1-wsl-opencode-runtime-bootstrap-design.md | 369 ++++++ 2 files changed, 1374 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md create mode 100644 docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md diff --git a/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md b/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md new file mode 100644 index 00000000..e26aa4b5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md @@ -0,0 +1,1005 @@ +# WSL OpenCode Runtime Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Settings-native WSL OpenCode runtime bootstrap that installs/repairs a loopback `jcode-opencode.service`, activates a WSL runtime profile, verifies health, and documents the lane. + +**Architecture:** Add typed provider-runtime bootstrap contracts, implement pure server-side WSL/service/profile helpers with injected filesystem/command seams, expose RPC handlers, bridge them to the web Native API, and extend `OpenCodeRuntimeSettingsPanel` with install/repair controls. Runtime verification stays on the existing `provider.getRuntimeHealth` path; docs explain install, generated files, diagnostics, repair, rollback, and the distinction from the Windows installer branch. + +**Tech Stack:** Bun, TypeScript, Effect Schema/RPC, React, Vitest, Vitest Browser, user systemd command rendering, Markdown docs. + +--- + +## File Structure + +- Modify `packages/contracts/src/providerDiscovery.ts`: add OpenCode-only runtime bootstrap schemas and types. +- Modify `packages/contracts/src/ws.ts`: add WS method constants and request body tags. +- Modify `packages/contracts/src/rpc.ts`: add Effect RPC entries for status/install/repair. +- Modify `packages/contracts/src/ipc.ts`: add Native API provider method signatures. +- Modify `packages/contracts/src/ws.test.ts`: add decode tests for new WS request tags. +- Modify `packages/contracts/src/rpc.test.ts`: assert the RPC group exports the new methods. +- Create `apps/server/src/provider/openCodeRuntimeBootstrap.ts`: pure WSL detection, service rendering, redaction, profile upsert, and injected command orchestration. +- Create `apps/server/src/provider/openCodeRuntimeBootstrap.test.ts`: pure tests for bootstrap behavior. +- Modify `apps/server/src/wsRpc.ts`: wire bootstrap handlers and call `ServerSettingsService.updateSettings` when install/repair returns a profile patch. +- Modify `apps/web/src/wsNativeApi.ts`: bridge status/install/repair calls to WS methods. +- Modify `apps/web/src/wsNativeApi.test.ts`: assert web bridge calls the expected WS method names. +- Modify `apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx`: add bootstrap state, install/repair controls, progress/error text, and post-success health refresh. +- Create `apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx`: browser tests for unsupported/not-installed/ready/error states. +- Modify `docs/runbooks/local-opencode-runtime.md`: document generated WSL service, helper, health, repair, rollback. +- Modify `docs/runbooks/local-deploy.md`: explain when Settings bootstrap is preferred over manual service files. +- Modify `docs/runbooks/README.md`: add WSL OpenCode runtime install/repair scenario links. +- Modify `docs/architecture/provider-runtime.md`: document server-owned provider-runtime bootstrap ownership. +- Modify `docs/adr/0007-parallel-windows-wsl-backend-routing.md`: clarify this is narrow provider runtime bootstrap, not full backend routing. + +Do not modify Windows installer code in this branch. Do not commit unless the user explicitly asks; commit steps in this plan are checkpoints only. + +--- + +### Task 1: Contracts And WS Methods + +**Files:** + +- Modify: `packages/contracts/src/providerDiscovery.ts` +- Modify: `packages/contracts/src/ws.ts` +- Modify: `packages/contracts/src/rpc.ts` +- Modify: `packages/contracts/src/ipc.ts` +- Test: `packages/contracts/src/ws.test.ts` +- Test: `packages/contracts/src/rpc.test.ts` + +- [ ] **Step 1: Write failing WS decode tests** + +Add these tests after the existing provider discovery request tests in `packages/contracts/src/ws.test.ts`: + +```ts +it.effect("accepts provider runtime bootstrap status requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-bootstrap-status-1", + body: { + _tag: WS_METHODS.providerGetRuntimeBootstrapStatus, + provider: "opencode", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerGetRuntimeBootstrapStatus); + }), +); + +it.effect("accepts provider runtime bootstrap requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-bootstrap-1", + body: { + _tag: WS_METHODS.providerBootstrapRuntime, + provider: "opencode", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerBootstrapRuntime); + }), +); + +it.effect("accepts provider runtime repair requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-runtime-repair-1", + body: { + _tag: WS_METHODS.providerRepairRuntime, + provider: "opencode", + forceReinstall: true, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerRepairRuntime); + if (parsed.body._tag === WS_METHODS.providerRepairRuntime) { + assert.strictEqual(parsed.body.forceReinstall, true); + } + }), +); +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +snip bun run --cwd packages/contracts test src/ws.test.ts +``` + +Expected: FAIL because `WS_METHODS.providerGetRuntimeBootstrapStatus`, `providerBootstrapRuntime`, and `providerRepairRuntime` do not exist. + +- [ ] **Step 3: Add bootstrap schemas** + +Append these schemas after `ProviderGetRuntimeHealthInput` in `packages/contracts/src/providerDiscovery.ts`: + +```ts +export const OpenCodeRuntimeBootstrapLane = Schema.Literal("wsl-service"); +export type OpenCodeRuntimeBootstrapLane = typeof OpenCodeRuntimeBootstrapLane.Type; + +export const OpenCodeRuntimeBootstrapState = Schema.Literals([ + "unsupported", + "notInstalled", + "installing", + "starting", + "ready", + "error", +]); +export type OpenCodeRuntimeBootstrapState = typeof OpenCodeRuntimeBootstrapState.Type; + +export const ProviderRuntimeBootstrapStatusInput = Schema.Struct({ + provider: Schema.Literal("opencode"), +}); +export type ProviderRuntimeBootstrapStatusInput = typeof ProviderRuntimeBootstrapStatusInput.Type; + +export const ProviderRuntimeBootstrapInput = Schema.Struct({ + provider: Schema.Literal("opencode"), + forceReinstall: Schema.optional(Schema.Boolean), +}); +export type ProviderRuntimeBootstrapInput = typeof ProviderRuntimeBootstrapInput.Type; + +export const ProviderRuntimeBootstrapSnapshot = Schema.Struct({ + provider: Schema.Literal("opencode"), + lane: OpenCodeRuntimeBootstrapLane, + state: OpenCodeRuntimeBootstrapState, + serviceName: Schema.optional(Schema.Literal("jcode-opencode.service")), + binaryPath: Schema.optional(TrimmedNonEmptyString), + serverUrl: Schema.optional(TrimmedNonEmptyString), + profileId: Schema.optional(TrimmedNonEmptyString), + message: Schema.optional(Schema.String.check(Schema.isMaxLength(4096))), + checkedAt: TrimmedNonEmptyString, +}); +export type ProviderRuntimeBootstrapSnapshot = typeof ProviderRuntimeBootstrapSnapshot.Type; +``` + +- [ ] **Step 4: Add WS methods and request tags** + +Update imports from `./providerDiscovery` in `packages/contracts/src/ws.ts` to include: + +```ts +ProviderRuntimeBootstrapInput, +ProviderRuntimeBootstrapStatusInput, +``` + +Add these `WS_METHODS` entries after `providerGetRuntimeHealth`: + +```ts +providerGetRuntimeBootstrapStatus: "provider.getRuntimeBootstrapStatus", +providerBootstrapRuntime: "provider.bootstrapRuntime", +providerRepairRuntime: "provider.repairRuntime", +``` + +Add these request body tags after `providerGetRuntimeHealth`: + +```ts +tagRequestBody( + WS_METHODS.providerGetRuntimeBootstrapStatus, + ProviderRuntimeBootstrapStatusInput, +), +tagRequestBody(WS_METHODS.providerBootstrapRuntime, ProviderRuntimeBootstrapInput), +tagRequestBody(WS_METHODS.providerRepairRuntime, ProviderRuntimeBootstrapInput), +``` + +- [ ] **Step 5: Add RPC contracts** + +Update imports in `packages/contracts/src/rpc.ts` to include: + +```ts +ProviderRuntimeBootstrapInput, +ProviderRuntimeBootstrapSnapshot, +ProviderRuntimeBootstrapStatusInput, +``` + +Add these RPC entries after `WsProviderGetRuntimeHealthRpc`: + +```ts +export const WsProviderGetRuntimeBootstrapStatusRpc = Rpc.make( + WS_METHODS.providerGetRuntimeBootstrapStatus, + { + payload: ProviderRuntimeBootstrapStatusInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, + }, +); + +export const WsProviderBootstrapRuntimeRpc = Rpc.make(WS_METHODS.providerBootstrapRuntime, { + payload: ProviderRuntimeBootstrapInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, +}); + +export const WsProviderRepairRuntimeRpc = Rpc.make(WS_METHODS.providerRepairRuntime, { + payload: ProviderRuntimeBootstrapInput, + success: ProviderRuntimeBootstrapSnapshot, + error: WsRpcError, +}); +``` + +If `WsRpcGroup` explicitly lists RPCs later in the file, add these three entries to the group in the provider discovery block. + +- [ ] **Step 6: Add native API contract signatures** + +In `packages/contracts/src/ipc.ts`, add provider methods matching the existing provider native API shape: + +```ts +getRuntimeBootstrapStatus: (input: ProviderRuntimeBootstrapStatusInput) => + Promise; +bootstrapRuntime: (input: ProviderRuntimeBootstrapInput) => + Promise; +repairRuntime: (input: ProviderRuntimeBootstrapInput) => Promise; +``` + +Add the corresponding imports from `./providerDiscovery`. + +- [ ] **Step 7: Run contract tests to verify GREEN** + +Run: + +```bash +snip bun run --cwd packages/contracts test src/ws.test.ts src/rpc.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Checkpoint diff** + +Run: + +```bash +GIT_MASTER=1 git diff -- packages/contracts/src/providerDiscovery.ts packages/contracts/src/ws.ts packages/contracts/src/rpc.ts packages/contracts/src/ipc.ts packages/contracts/src/ws.test.ts packages/contracts/src/rpc.test.ts +``` + +Expected: only contract/test changes for runtime bootstrap. Do not commit unless the user explicitly asks. + +--- + +### Task 2: Pure Server Bootstrap Module + +**Files:** + +- Create: `apps/server/src/provider/openCodeRuntimeBootstrap.ts` +- Create: `apps/server/src/provider/openCodeRuntimeBootstrap.test.ts` + +- [ ] **Step 1: Write failing pure tests** + +Create `apps/server/src/provider/openCodeRuntimeBootstrap.test.ts` with: + +```ts +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; + +import { + JCODE_OPENCODE_SERVICE_NAME, + WSL_OPENCODE_PROFILE_ID, + detectWslOpenCodeBootstrapStatus, + makeWslOpenCodeRuntimeProfilePatch, + redactBootstrapMessage, + renderJcodeOpenCodeServiceUnit, + renderJcodeOpenCodeStartScript, + upsertWslOpenCodeRuntimeProfile, +} from "./openCodeRuntimeBootstrap"; + +const NOW = "2026-06-11T12:00:00.000Z"; + +function settingsWithProfiles( + profiles: ServerSettings["providers"]["opencode"]["runtimeProfiles"], +): ServerSettings { + return { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + opencode: { + ...DEFAULT_SERVER_SETTINGS.providers.opencode, + runtimeProfiles: profiles, + activeRuntimeProfileId: profiles[0]?.id, + }, + }, + }; +} + +describe("openCodeRuntimeBootstrap", () => { + it("reports unsupported outside WSL", () => { + const status = detectWslOpenCodeBootstrapStatus({ + now: NOW, + platform: "linux", + osRelease: "Linux 6.8 generic", + env: {}, + userSystemdAvailable: true, + serviceExists: false, + serviceActive: false, + binaryPath: null, + portAvailable: true, + profileReachable: false, + }); + + expect(status.state).toBe("unsupported"); + expect(status.message).toContain("WSL"); + }); + + it("reports not installed in supported WSL without service or binary", () => { + const status = detectWslOpenCodeBootstrapStatus({ + now: NOW, + platform: "linux", + osRelease: "Linux microsoft-standard-WSL2", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + userSystemdAvailable: true, + serviceExists: false, + serviceActive: false, + binaryPath: null, + portAvailable: true, + profileReachable: false, + }); + + expect(status.state).toBe("notInstalled"); + expect(status.lane).toBe("wsl-service"); + }); + + it("renders a loopback-only user service", () => { + const unit = renderJcodeOpenCodeServiceUnit({ + startScriptPath: "/home/alice/.local/bin/jcode-opencode-start", + }); + + expect(unit).toContain("Description=JCode external OpenCode runtime"); + expect(unit).toContain("ExecStart=/home/alice/.local/bin/jcode-opencode-start"); + expect(unit).not.toContain("0.0.0.0"); + }); + + it("renders a start script that unsets inline OpenCode config", () => { + const script = renderJcodeOpenCodeStartScript({ + binaryPath: "/home/alice/.local/share/jcode/runtime/opencode/opencode", + host: "127.0.0.1", + port: 4096, + }); + + expect(script).toContain("unset OPENCODE_CONFIG_CONTENT"); + expect(script).toContain("--hostname=127.0.0.1"); + expect(script).toContain("--port=4096"); + }); + + it("upserts the WSL profile without duplicating it", () => { + const settings = settingsWithProfiles([ + { + id: WSL_OPENCODE_PROFILE_ID, + label: "Old WSL profile", + provider: "opencode", + mode: "external", + configMode: "inherit", + serverUrl: "http://127.0.0.1:4096", + binaryPath: "/old/opencode", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ]); + + const patch = makeWslOpenCodeRuntimeProfilePatch({ + binaryPath: "/home/alice/.local/share/jcode/runtime/opencode/opencode", + serverUrl: "http://127.0.0.1:4096", + }); + const nextProfiles = upsertWslOpenCodeRuntimeProfile( + settings.providers.opencode.runtimeProfiles, + patch, + ); + + expect(nextProfiles).toHaveLength(1); + expect(nextProfiles[0]?.label).toBe("WSL OpenCode service"); + expect(nextProfiles[0]?.binaryPath).toBe( + "/home/alice/.local/share/jcode/runtime/opencode/opencode", + ); + }); + + it("redacts credentials from bootstrap messages", () => { + expect( + redactBootstrapMessage( + "failed token=abc password=secret http://user:pass@example.test/path?client_secret=1", + ), + ).not.toContain("secret"); + }); + + it("uses the documented constants", () => { + expect(JCODE_OPENCODE_SERVICE_NAME).toBe("jcode-opencode.service"); + expect(WSL_OPENCODE_PROFILE_ID).toBe("wsl-opencode-service"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts +``` + +Expected: FAIL because `openCodeRuntimeBootstrap.ts` does not exist. + +- [ ] **Step 3: Implement pure bootstrap helpers** + +Create `apps/server/src/provider/openCodeRuntimeBootstrap.ts` with exported constants and pure helpers needed by the test. Keep live command execution as typed seams for later orchestration; do not call `systemctl` directly in pure helpers. + +The implementation must export: + +```ts +export const JCODE_OPENCODE_SERVICE_NAME = "jcode-opencode.service" as const; +export const WSL_OPENCODE_PROFILE_ID = "wsl-opencode-service" as const; +export const WSL_OPENCODE_PROFILE_LABEL = "WSL OpenCode service" as const; +export const DEFAULT_WSL_OPENCODE_SERVER_URL = "http://127.0.0.1:4096" as const; +``` + +Implement these functions with the behavior asserted in Step 1: + +```ts +export function detectWslOpenCodeBootstrapStatus( + input: WslOpenCodeBootstrapProbe, +): ProviderRuntimeBootstrapSnapshot; +export function renderJcodeOpenCodeServiceUnit(input: { readonly startScriptPath: string }): string; +export function renderJcodeOpenCodeStartScript(input: { + readonly binaryPath: string; + readonly host: "127.0.0.1"; + readonly port: 4096; +}): string; +export function makeWslOpenCodeRuntimeProfilePatch(input: { + readonly binaryPath: string; + readonly serverUrl?: string; +}): OpenCodeRuntimeProfile; +export function upsertWslOpenCodeRuntimeProfile( + existing: readonly OpenCodeRuntimeProfile[], + profile: OpenCodeRuntimeProfile, +): OpenCodeRuntimeProfile[]; +export function redactBootstrapMessage(message: string): string; +``` + +Detection rules: + +- Non-Linux or Linux without WSL markers returns `unsupported`. +- Missing user systemd returns `unsupported` with a systemd message. +- Occupied port returns `error` with a port message. +- Reachable profile returns `ready`. +- Missing service/binary returns `notInstalled`. +- Existing inactive service returns `error` with `serviceName`. +- Existing active service returns `ready`. + +- [ ] **Step 4: Run tests to verify GREEN** + +Run: + +```bash +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Refactor only after green** + +If helpers grow beyond a readable module, split only pure rendering into `apps/server/src/provider/openCodeRuntimeBootstrapTemplates.ts` with a matching test import. Keep behavior unchanged and rerun: + +```bash +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts +``` + +Expected: PASS. + +--- + +### Task 3: Server Orchestration And RPC Wiring + +**Files:** + +- Modify: `apps/server/src/provider/openCodeRuntimeBootstrap.ts` +- Modify: `apps/server/src/wsRpc.ts` +- Test: `apps/server/src/provider/openCodeRuntimeBootstrap.test.ts` + +- [ ] **Step 1: Add failing orchestration tests with injected adapters** + +Extend `openCodeRuntimeBootstrap.test.ts` with tests for: + +```ts +it("installs by writing script and service, starting systemd, and returning a ready profile snapshot", async () => { + // Arrange an injected adapter that records write/start/check calls. + // Assert generated paths, service name, loopback URL, and profile id. +}); + +it("repairs idempotently without duplicating the profile", async () => { + // Arrange existing WSL profile and service. + // Assert one WSL profile remains and restart/health checks run once. +}); +``` + +Use real arrays and simple fake functions. The expected RED failure is missing exported orchestration helpers. + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts +``` + +Expected: FAIL because install/repair orchestration helpers do not exist. + +- [ ] **Step 3: Add orchestration helpers** + +Add typed adapter seams in `openCodeRuntimeBootstrap.ts`: + +```ts +export interface WslOpenCodeBootstrapAdapter { + readonly now: () => string; + readonly getProbe: () => Promise; + readonly ensureRuntimeDirectory: () => Promise; + readonly resolveOpenCodeBinary: (forceReinstall: boolean) => Promise; + readonly writeExecutableFile: (path: string, contents: string) => Promise; + readonly writeFile: (path: string, contents: string) => Promise; + readonly systemctlUser: (args: readonly string[]) => Promise; + readonly smokeRuntime: (serverUrl: string) => Promise; +} +``` + +Export: + +```ts +export async function getWslOpenCodeRuntimeBootstrapStatus( + adapter: WslOpenCodeBootstrapAdapter, +): Promise; + +export async function bootstrapWslOpenCodeRuntime( + adapter: WslOpenCodeBootstrapAdapter, + input: ProviderRuntimeBootstrapInput, +): Promise<{ + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; +}>; + +export async function repairWslOpenCodeRuntime( + adapter: WslOpenCodeBootstrapAdapter, + input: ProviderRuntimeBootstrapInput, +): Promise<{ + readonly snapshot: ProviderRuntimeBootstrapSnapshot; + readonly profile: OpenCodeRuntimeProfile; +}>; +``` + +Implementation rules: + +- Write helper script before unit file. +- Run `systemctl --user daemon-reload` before `enable --now`. +- Return `state: "ready"` only after `smokeRuntime` succeeds. +- On caught errors, return/throw redacted messages only. + +- [ ] **Step 4: Wire RPC handlers** + +In `apps/server/src/wsRpc.ts`, import server bootstrap helpers. Add handlers after `providerGetRuntimeHealth`: + +```ts +[WS_METHODS.providerGetRuntimeBootstrapStatus]: (input) => + rpcEffect(getOpenCodeRuntimeBootstrapStatus(input), "Failed to get runtime bootstrap status"), +[WS_METHODS.providerBootstrapRuntime]: (input) => + rpcEffect(bootstrapOpenCodeRuntime(input), "Failed to bootstrap runtime"), +[WS_METHODS.providerRepairRuntime]: (input) => + rpcEffect(repairOpenCodeRuntime(input), "Failed to repair runtime"), +``` + +If the bootstrap helper returns a profile patch, update settings with `serverSettings.updateSettings` inside the handler or a server wrapper. Do not hand-edit settings JSON. + +- [ ] **Step 5: Run server tests to verify GREEN** + +Run: + +```bash +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts src/provider/openCodeRuntimeHealth.test.ts +``` + +Expected: PASS. + +--- + +### Task 4: Web Native API Bridge + +**Files:** + +- Modify: `apps/web/src/wsNativeApi.ts` +- Modify: `apps/web/src/wsNativeApi.test.ts` + +- [ ] **Step 1: Write failing bridge tests** + +Add tests in `apps/web/src/wsNativeApi.test.ts` near existing provider API request tests: + +```ts +it("bridges OpenCode runtime bootstrap status requests", async () => { + requestMock.mockResolvedValueOnce({ + provider: "opencode", + lane: "wsl-service", + state: "notInstalled", + checkedAt: "2026-06-11T12:00:00.000Z", + }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.provider.getRuntimeBootstrapStatus({ provider: "opencode" }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.providerGetRuntimeBootstrapStatus, { + provider: "opencode", + }); +}); + +it("bridges OpenCode runtime bootstrap and repair requests", async () => { + requestMock.mockResolvedValue({ + provider: "opencode", + lane: "wsl-service", + state: "ready", + checkedAt: "2026-06-11T12:00:00.000Z", + }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.provider.bootstrapRuntime({ provider: "opencode" }); + await api.provider.repairRuntime({ provider: "opencode", forceReinstall: true }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.providerBootstrapRuntime, { + provider: "opencode", + }); + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.providerRepairRuntime, { + provider: "opencode", + forceReinstall: true, + }); +}); +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +snip bun run --cwd apps/web test src/wsNativeApi.test.ts +``` + +Expected: FAIL because provider bridge methods do not exist. + +- [ ] **Step 3: Implement bridge methods** + +In `apps/web/src/wsNativeApi.ts`, add provider methods that call `transport.request` with the new `WS_METHODS` entries: + +```ts +getRuntimeBootstrapStatus: (input) => + transport.request(WS_METHODS.providerGetRuntimeBootstrapStatus, input), +bootstrapRuntime: (input) => transport.request(WS_METHODS.providerBootstrapRuntime, input), +repairRuntime: (input) => transport.request(WS_METHODS.providerRepairRuntime, input), +``` + +Use existing method style and return typing. + +- [ ] **Step 4: Run tests to verify GREEN** + +Run: + +```bash +snip bun run --cwd apps/web test src/wsNativeApi.test.ts +``` + +Expected: PASS. + +--- + +### Task 5: Settings Panel UI + +**Files:** + +- Modify: `apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx` +- Create: `apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx` + +- [ ] **Step 1: Write failing browser tests** + +Create `apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx`: + +```tsx +import "../index.css"; + +import type { + NativeApi, + OpenCodeRuntimeHealth, + ProviderRuntimeBootstrapSnapshot, +} from "@jcode/contracts"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { OpenCodeRuntimeSettingsPanel } from "./OpenCodeRuntimeSettingsPanel"; + +const NOW = "2026-06-11T12:00:00.000Z"; + +const health: OpenCodeRuntimeHealth = { + provider: "opencode", + profileId: "wsl-opencode-service", + profileLabel: "WSL OpenCode service", + mode: "external", + configMode: "inherit", + status: "healthy", + serverUrl: "http://127.0.0.1:4096", + external: true, + capabilities: {}, + mismatches: [], + checkedAt: NOW, +}; + +let bootstrapStatus: ProviderRuntimeBootstrapSnapshot; + +const nativeApi = { + provider: { + getRuntimeHealth: vi.fn(async () => health), + getRuntimeBootstrapStatus: vi.fn(async () => bootstrapStatus), + bootstrapRuntime: vi.fn(async () => ({ ...bootstrapStatus, state: "ready" as const })), + repairRuntime: vi.fn(async () => ({ ...bootstrapStatus, state: "ready" as const })), + }, +} as unknown as NativeApi; + +describe("OpenCodeRuntimeSettingsPanel", () => { + beforeEach(() => { + bootstrapStatus = { + provider: "opencode", + lane: "wsl-service", + state: "notInstalled", + checkedAt: NOW, + message: "OpenCode runtime is not installed.", + }; + for (const method of Object.values(nativeApi.provider)) { + if (typeof method === "function" && "mockClear" in method) { + (method as { mockClear: () => void }).mockClear(); + } + } + window.nativeApi = nativeApi; + }); + + afterEach(() => { + Reflect.deleteProperty(window, "nativeApi"); + document.body.innerHTML = ""; + }); + + it("shows install when runtime bootstrap status is not installed", async () => { + const screen = await render(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("OpenCode runtime is not installed."); + }); + + await page.getByRole("button", { name: "Install OpenCode runtime" }).click(); + + await vi.waitFor(() => { + expect(nativeApi.provider.bootstrapRuntime).toHaveBeenCalledWith({ provider: "opencode" }); + expect(nativeApi.provider.getRuntimeHealth).toHaveBeenCalledWith({ + provider: "opencode", + forceRefresh: true, + }); + }); + + await screen.unmount(); + }); + + it("shows repair when runtime bootstrap status is error", async () => { + bootstrapStatus = { + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: "jcode-opencode.service", + message: "Service stopped.", + checkedAt: NOW, + }; + + const screen = await render(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Service stopped."); + }); + + await page.getByRole("button", { name: "Repair runtime" }).click(); + + await vi.waitFor(() => { + expect(nativeApi.provider.repairRuntime).toHaveBeenCalledWith({ provider: "opencode" }); + }); + + await screen.unmount(); + }); +}); +``` + +- [ ] **Step 2: Run browser tests to verify RED** + +Run: + +```bash +snip bun run --cwd apps/web test:browser:local src/components/OpenCodeRuntimeSettingsPanel.browser.tsx +``` + +Expected: FAIL because the panel does not fetch bootstrap status or render install/repair buttons. + +- [ ] **Step 3: Implement UI state** + +In `OpenCodeRuntimeSettingsPanel.tsx`: + +- Import `ProviderRuntimeBootstrapSnapshot` type and a download/wrench icon if available. +- Add `bootstrapStatus`, `isBootstrapping`, and `bootstrapAction` state. +- Fetch bootstrap status in the existing initial `useEffect` alongside health. +- Add `installRuntime` and `repairRuntime` callbacks that call the new Native API methods and then `checkRuntime(true)`. +- Render install button for `state === "notInstalled"`. +- Render repair button for `state === "error"`. +- Render unsupported messages without attempting install. +- Keep the existing `Check` button and capability summary. + +Use button labels exactly as tests expect: + +```text +Install OpenCode runtime +Repair runtime +``` + +- [ ] **Step 4: Run browser tests to verify GREEN** + +Run: + +```bash +snip bun run --cwd apps/web test:browser:local src/components/OpenCodeRuntimeSettingsPanel.browser.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Run focused web unit bridge tests** + +Run: + +```bash +snip bun run --cwd apps/web test src/wsNativeApi.test.ts +``` + +Expected: PASS. + +--- + +### Task 6: Documentation Workstream + +**Files:** + +- Modify: `docs/runbooks/local-opencode-runtime.md` +- Modify: `docs/runbooks/local-deploy.md` +- Modify: `docs/runbooks/README.md` +- Modify: `docs/architecture/provider-runtime.md` +- Modify: `docs/adr/0007-parallel-windows-wsl-backend-routing.md` + +- [ ] **Step 1: Update local OpenCode runtime runbook** + +Add sections describing: + +```text +WSL Settings bootstrap +Generated files +Health checks +Repair +Rollback +``` + +Include these concrete portable paths: + +```text +~/.local/share/jcode/runtime/opencode/opencode +~/.local/bin/jcode-opencode-start +~/.config/systemd/user/jcode-opencode.service +http://127.0.0.1:4096/ +``` + +- [ ] **Step 2: Update local deploy notes and runbook index** + +In `docs/runbooks/local-deploy.md`, explain that WSL OpenCode runtime bootstrap should be run from Settings -> Providers -> OpenCode when available, and manual systemd files are for diagnosis or unsupported environments. + +In `docs/runbooks/README.md`, add a scenario row: + +```markdown +| Installing or repairing a WSL OpenCode runtime | [Local OpenCode Runtime Service](local-opencode-runtime.md) | [Provider Runtime Architecture](../architecture/provider-runtime.md) | +``` + +- [ ] **Step 3: Update architecture/ADR docs** + +In `docs/architecture/provider-runtime.md`, add the ownership invariant: + +```text +Settings may trigger provider runtime bootstrap actions, but the server owns service creation, runtime profile mutation, and runtime health verification. +``` + +In ADR 0007, add a short note that WSL OpenCode runtime bootstrap is a narrow provider-runtime service lane and does not implement project-to-backend routing. + +- [ ] **Step 4: Verify docs** + +Run: + +```bash +GIT_MASTER=1 git diff --check -- docs/runbooks/local-opencode-runtime.md docs/runbooks/local-deploy.md docs/runbooks/README.md docs/architecture/provider-runtime.md docs/adr/0007-parallel-windows-wsl-backend-routing.md +snip bunx oxfmt@0.52.0 --check docs/runbooks/local-opencode-runtime.md docs/runbooks/local-deploy.md docs/runbooks/README.md docs/architecture/provider-runtime.md docs/adr/0007-parallel-windows-wsl-backend-routing.md +``` + +Expected: PASS. If formatter fails, run `snip bunx oxfmt@0.52.0 ` only for touched docs and rerun the checks. + +--- + +### Task 7: Final Focused Verification + +**Files:** + +- All files touched by Tasks 1-6. + +- [ ] **Step 1: Run LSP diagnostics on changed source files** + +Run LSP diagnostics on changed `.ts`/`.tsx` files. Expected: no new errors. + +- [ ] **Step 2: Run focused tests** + +Run: + +```bash +snip bun run --cwd packages/contracts test src/ws.test.ts src/rpc.test.ts +snip bun run --cwd apps/server test src/provider/openCodeRuntimeBootstrap.test.ts src/provider/openCodeRuntimeHealth.test.ts +snip bun run --cwd apps/web test src/wsNativeApi.test.ts +snip bun run --cwd apps/web test:browser:local src/components/OpenCodeRuntimeSettingsPanel.browser.tsx +``` + +Expected: PASS. + +- [ ] **Step 3: Run focused typechecks with safe-run** + +Run: + +```bash +safe-run --profile build -- bun run --cwd packages/contracts typecheck +safe-run --profile build -- bun run --cwd apps/server typecheck +safe-run --profile build -- bun run --cwd apps/web typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Run formatting checks for touched files** + +Run: + +```bash +snip bunx oxfmt@0.52.0 --check +``` + +Expected: PASS. + +- [ ] **Step 5: Review diff for scope and secrets** + +Run: + +```bash +GIT_MASTER=1 git diff --check +GIT_MASTER=1 git diff --stat +``` + +Search touched docs/source for private values: + +```bash +rg -n "tailnet|tailscale|cookie|token=|password=|client_secret|owner pairing|/home/jay/code/jcode-stable" docs apps packages +``` + +Expected: no new private hostnames, tokens, cookies, owner pairing links, service passwords, or Jay-specific defaults. + +--- + +## Self-Review Notes + +Spec coverage: + +- Contracts/RPC covered by Task 1. +- Server-owned WSL service/profile bootstrap covered by Tasks 2-3. +- Existing runtime health reuse covered by Tasks 3 and 7. +- Web Settings install/repair UX covered by Tasks 4-5. +- Documentation workstream covered by Task 6. +- Verification and private-value checks covered by Task 7. + +Scope guard: + +- This plan does not implement Windows installer logic. +- This plan does not create `jcode.service` or install JCode itself into WSL. +- This plan does not implement ADR 0007 backend routing. +- This plan keeps OpenCode bound to `127.0.0.1:4096`. diff --git a/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md b/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md new file mode 100644 index 00000000..32b740c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md @@ -0,0 +1,369 @@ +# WSL OpenCode Runtime Bootstrap Design + +## Summary + +Add a Settings-native installer for an OpenCode runtime service in WSL. The user installs or repairs OpenCode from Settings -> Providers -> OpenCode, JCode creates a WSL user-systemd `jcode-opencode.service`, updates the OpenCode runtime profile, and verifies the service through the existing runtime health panel. + +## Context + +JCode already has two related but distinct lanes: + +- The Windows turnkey lane, which is being developed separately and uses a guided first-run flow plus managed-runtime sidecar concepts. +- The WSL service lane, where an end user chooses to run JCode as a service in WSL and use it from a browser. + +This design focuses only on the WSL lane. It does not implement or modify the Windows installer work. The same Settings affordance can later choose an OS-specific backend: Windows JCode can install a Windows-flavoured runtime, while WSL JCode installs a WSL user service. + +Current source seams already support most of the runtime side: + +- `apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx` displays OpenCode runtime health and has a `Check` action. +- `apps/web/src/routes/_chat.settings.tsx` places that panel under Settings -> Providers -> OpenCode runtime and exposes OpenCode binary/server fields in the provider tools section. +- `packages/contracts/src/providerDiscovery.ts` defines `OpenCodeRuntimeProfile`, runtime modes, config modes, health statuses, mismatches, and `ProviderGetRuntimeHealthInput`. +- `apps/server/src/provider/openCodeRuntimeProfiles.ts` resolves external and managed OpenCode runtime profiles. +- `apps/server/src/provider/openCodeRuntimeHealth.ts` validates runtime reachability and capabilities. +- `apps/server/src/wsRpc.ts` already exposes `provider.getRuntimeHealth`, `server.getSettings`, and `server.updateSettings`. +- `docs/runbooks/local-opencode-runtime.md` documents the observed two-service topology and service assumptions. + +The Windows managed-runtime branch adds useful concepts but should not be copied wholesale. ADR 0005's durable principle is that the server owns runtime lifecycle and Settings/desktop surfaces trigger and report it. For WSL, lifecycle ownership should mean installing and managing a user-systemd service, not spawning an in-process child sidecar. + +## Goals + +- Add an `Install OpenCode runtime` action in Settings -> Providers -> OpenCode when JCode is running in the WSL service lane and no usable local OpenCode runtime is detected. +- Install or locate OpenCode inside the current WSL distro, not on the Windows host. +- Create, enable, start, stop, and repair a WSL user-systemd `jcode-opencode.service` that binds OpenCode to loopback. +- Patch `providers.opencode.runtimeProfiles` and `activeRuntimeProfileId` so JCode uses the installed runtime through the existing runtime-profile path. +- Reuse the current runtime health panel and `provider.getRuntimeHealth` after installation. +- Document the installer lane, service ownership, runtime profile wiring, diagnostics, and rollback as part of the implementation so this work is not a black box. +- Keep the flow portable and publishable: no Jay-specific paths, private hostnames, tokens, cookies, owner pairing links, or local machine assumptions. +- Keep Windows installer work separate, while leaving room for the same UI action to dispatch OS-specific installers later. + +## Non-Goals + +- No full JCode-in-WSL installation flow in this slice. +- No Windows runtime installer implementation in this worktree. +- No global WSL mode or full backend-routing implementation from ADR 0007. +- No multi-distro backend registry work beyond detecting/reporting the current WSL context needed for this installer. +- No silent install on Settings page load. The user must explicitly click the install action. +- No management of private tunnels, remote exposure, or non-loopback OpenCode listeners. +- No persistence of generated secrets in browser storage or public docs. + +## Recommended Approach + +Implement a WSL-specific OpenCode runtime bootstrap service behind a Settings action. + +The action is product-level, but the implementation is server-owned: + +1. The web panel asks the server for OpenCode bootstrap capability/status. +2. If JCode is running in a supported WSL context and no reachable local runtime is configured, the panel shows `Install OpenCode runtime`. +3. Clicking the button calls a new server RPC to install or repair the runtime. +4. The server performs WSL-local installation work, writes user-systemd files, starts the service, updates OpenCode runtime profile settings, and returns a bootstrap snapshot. +5. The panel invalidates settings/status and calls `provider.getRuntimeHealth({ provider: "opencode", forceRefresh: true })`. + +This keeps provider setup where the user expects it, under Settings -> Providers -> OpenCode, without turning this slice into a full installer for JCode itself. + +Rejected alternatives: + +- Reuse the Windows managed child-process sidecar directly. It fits desktop bootstrap, but a WSL service lane should survive shell restarts and behave like the documented `jcode-opencode.service` topology. +- Create both `jcode.service` and `jcode-opencode.service` from this Settings action. That mixes application deployment with provider runtime setup and should be a separate WSL installation lane. +- Docs-only bootstrap. Safer, but it misses the agreed product direction: Settings should install or repair skipped provider runtimes later. + +## User Experience + +The existing OpenCode runtime card should gain an installer state above or beside the `Check` action. + +Suggested states: + +- `Ready`: runtime health is `healthy` or `degraded`; show status and keep `Check`. +- `Not installed`: no OpenCode binary/service/profile was found; show `Install OpenCode runtime`. +- `Service stopped`: service exists but is inactive; show `Start runtime` and `Repair`. +- `Misconfigured`: service or profile exists but points at an unreachable URL/path; show `Repair runtime`. +- `Installing`: show progress text such as `Installing OpenCode`, `Writing service`, `Starting service`, `Checking runtime`. +- `Unsupported`: JCode is not running in the WSL lane or user-systemd is unavailable; show concise instructions and keep manual fields visible. + +The provider tools section should continue to expose manual OpenCode binary/server fields for advanced users. The install action should not hide or remove manual configuration. + +A successful install should result in a visible runtime card similar to: + +```text +healthy WSL OpenCode service external +http://127.0.0.1:4096 +Commands: N Skills: N Plugins: N Agents: N Models: N Config: inherit +``` + +The profile label should make the lane clear, for example `WSL OpenCode service`. + +## Runtime Service Design + +The bootstrapper creates a WSL user service that mirrors the documented local runtime topology. + +Service name: + +```text +jcode-opencode.service +``` + +Default listener: + +```text +http://127.0.0.1:4096/ +``` + +Default command shape: + +```bash +opencode serve --hostname=127.0.0.1 --port=4096 --print-logs --log-level INFO +``` + +The generated service should: + +- Run as the current WSL user under user systemd. +- Bind to loopback only. +- Use a bootstrap-owned OpenCode binary path when JCode installed OpenCode, or a detected path when reusing an existing install. +- Unset `OPENCODE_CONFIG_CONTENT` before starting OpenCode so inline generated config does not replace the user's real runtime profile. +- Avoid writing machine-specific paths into committed docs or templates. +- Use deterministic state paths under XDG locations when possible, such as `$XDG_DATA_HOME/jcode/runtime/opencode` and `$XDG_CONFIG_HOME/systemd/user/jcode-opencode.service`. + +The design should prefer an executable helper script over a long inline systemd command. That keeps quoting, env cleanup, and future service tuning testable. + +Suggested generated artifacts: + +```text +~/.local/share/jcode/runtime/opencode/opencode +~/.local/bin/jcode-opencode-start +~/.config/systemd/user/jcode-opencode.service +``` + +The installer should run: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now jcode-opencode.service +``` + +It should verify: + +```bash +systemctl --user is-active jcode-opencode.service +curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ +``` + +## Server API Design + +Add a narrow OpenCode runtime bootstrap RPC rather than overloading `server.updateSettings` or `provider.getRuntimeHealth`. + +Candidate WS methods: + +```text +provider.getRuntimeBootstrapStatus +provider.bootstrapRuntime +provider.repairRuntime +``` + +For v1, the payload can stay OpenCode-specific even if the method names are provider-shaped: + +```ts +type ProviderRuntimeBootstrapStatusInput = { + provider: "opencode"; +}; + +type ProviderRuntimeBootstrapInput = { + provider: "opencode"; + forceReinstall?: boolean; +}; +``` + +Result shape should be a state snapshot, not raw command output: + +```ts +type ProviderRuntimeBootstrapSnapshot = { + provider: "opencode"; + lane: "wsl-service"; + state: "unsupported" | "notInstalled" | "installing" | "starting" | "ready" | "error"; + serviceName?: "jcode-opencode.service"; + binaryPath?: string; + serverUrl?: string; + profileId?: string; + message?: string; + checkedAt: string; +}; +``` + +Errors should be actionable and redacted. The UI does not need raw `systemctl`, `curl`, or installer logs in v1. A later diagnostics export can include redacted command evidence. + +## Settings And Profile Design + +On successful bootstrap, the server updates `ServerSettings.providers.opencode` through `ServerSettingsService.updateSettings`, not by hand-editing settings JSON. + +Create or update a profile like: + +```ts +{ + id: "wsl-opencode-service", + label: "WSL OpenCode service", + provider: "opencode", + mode: "external", + configMode: "inherit", + serverUrl: "http://127.0.0.1:4096", + binaryPath: "", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn" +} +``` + +Set: + +```ts +{ + providers: { + opencode: { + runtimeProfiles: updatedProfiles, + activeRuntimeProfileId: "wsl-opencode-service", + serverUrl: "http://127.0.0.1:4096" + } + } +} +``` + +The legacy `serverUrl` field can be set for compatibility with the existing synthetic-profile fallback, but the runtime profile should be the primary durable state. + +Do not store service passwords unless the OpenCode server mode being installed requires one. If a password is introduced later, generate it server-side and keep it out of browser storage and logs. + +## Detection And Preconditions + +The bootstrap status check should answer these questions before showing install actions: + +1. Is JCode running on Linux under WSL? +2. Is user systemd available? +3. Is `systemctl --user` functional? +4. Is a supported OpenCode binary already present? +5. Does `jcode-opencode.service` already exist? +6. Is port `4096` already occupied? +7. Is an existing OpenCode runtime profile already reachable? + +Suggested WSL detection sources: + +- `process.platform === "linux"` +- `/proc/version` or `/proc/sys/kernel/osrelease` contains `microsoft` or `WSL` +- environment hints such as `WSL_DISTRO_NAME`, treated as hints rather than sole proof + +Unsupported states should be explicit: + +- Not WSL: hide WSL-specific installer or show the OS-appropriate installer later. +- WSL without user systemd: explain that user systemd must be enabled or fall back to manual fields. +- Port conflict: ask the user to change/stop the conflicting service or later support custom ports. + +## Documentation Workstream + +Documentation is part of the feature, not a follow-up. The implementation should update docs in the same branch as the code so future work can stitch this WSL lane together with the Windows installer lane without reverse-engineering runtime behavior from source. + +Required documentation updates: + +- `docs/runbooks/local-opencode-runtime.md`: describe the generated WSL `jcode-opencode.service`, helper script, state paths, install/repair commands, health checks, and rollback. Keep Jay's observed deployment separate from end-user WSL defaults. +- `docs/runbooks/local-deploy.md`: explain where the WSL OpenCode runtime bootstrap fits in local deployment and when users should use Settings instead of manual service files. +- `docs/runbooks/README.md`: add or adjust scenario links so operators can find WSL OpenCode runtime install, repair, and rollback instructions. +- `docs/architecture/provider-runtime.md`: document the provider-runtime ownership model: Settings triggers bootstrap, the server owns service/profile mutation, and runtime health remains the verification source of truth. +- `docs/adr/0007-parallel-windows-wsl-backend-routing.md` or a follow-up ADR: note that this feature is a narrow provider-runtime bootstrap inside WSL, not the full backend-routing implementation. This prevents future agents from confusing the two scopes. +- `docs/adr/README.md`: update the ADR index if a new follow-up ADR is added. +- `docs/security/baseline.md` or `docs/security/dev-automation-access.md` only if the implementation changes documented security posture, local loopback assumptions, or automation-access guidance. + +The docs should include concrete examples but remain portable: + +```text +~/.local/bin/jcode-opencode-start +~/.config/systemd/user/jcode-opencode.service +http://127.0.0.1:4096/ +``` + +They must not include private hostnames, tailnet URLs, tokens, cookies, owner pairing links, service passwords, or Jay-specific stable checkout paths as end-user defaults. + +The implementation plan should reserve explicit tasks for docs updates and source cross-checks. A feature is not done until the docs explain: + +1. How the Settings action decides it is available. +2. What files and services it creates. +3. How the runtime profile points JCode at the service. +4. How to inspect health and logs. +5. How to repair or roll back the service. +6. How this WSL lane intentionally differs from the Windows installer lane. + +## Security And Privacy + +- Bind OpenCode to `127.0.0.1` by default. +- Never generate public, LAN, tailnet, or wildcard listeners from this flow. +- Redact command output before returning errors to the browser. +- Do not log tokens, cookies, owner pairing links, service passwords, private URLs, or private hostnames. +- Keep service files and helper scripts local to the user's WSL home. +- Do not write Jay-specific paths or observed local stable-service paths into generated files. +- Treat installer downloads as untrusted input: verify checksums when the upstream release publishes a digest; otherwise report that checksum verification is unavailable rather than pretending it happened. + +## Failure And Repair + +Repair should be explicit and idempotent. + +`Repair runtime` may: + +- Recreate the helper script. +- Recreate the user service file. +- Run `systemctl --user daemon-reload`. +- Restart `jcode-opencode.service`. +- Reapply the runtime profile patch. +- Re-run runtime health. + +`Repair runtime` should not: + +- Delete a user's unrelated OpenCode config. +- Replace an existing non-JCode OpenCode service without warning. +- Change listener host away from loopback. +- Reinstall JCode itself. + +Rollback guidance should be surfaced in docs and diagnostics: + +```bash +systemctl --user disable --now jcode-opencode.service +rm -f ~/.config/systemd/user/jcode-opencode.service +systemctl --user daemon-reload +``` + +The UI should keep manual provider fields available after failure so users can point JCode at their own runtime. + +## Contract And Code Seams + +Likely implementation areas: + +- `packages/contracts/src/providerDiscovery.ts`: add bootstrap status/request/result schemas if the contract stays provider-scoped. +- `packages/contracts/src/ws.ts` and `packages/contracts/src/rpc.ts`: add WS method constants and RPC schemas for runtime bootstrap status/install/repair. +- `apps/server/src/provider/openCodeRuntimeBootstrap.ts`: new WSL service bootstrap/detect/repair logic. +- `apps/server/src/provider/openCodeRuntimeProfiles.ts`: reuse profile resolution; optionally add a helper for upserting the WSL service profile. +- `apps/server/src/provider/openCodeRuntimeHealth.ts`: reuse after bootstrap; do not duplicate inventory checks. +- `apps/server/src/wsRpc.ts`: wire new RPC handlers and call `ServerSettingsService.updateSettings` for profile updates. +- `apps/web/src/wsNativeApi.ts`: expose `provider.getRuntimeBootstrapStatus`, `provider.bootstrapRuntime`, and `provider.repairRuntime`. +- `apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx`: add installer state, install/repair buttons, progress/error display, and post-install health refresh. +- `docs/runbooks/local-opencode-runtime.md`: extend with generated WSL service paths and rollback once implementation details are finalized. + +## Acceptance Criteria + +- On JCode running inside supported WSL with no reachable OpenCode runtime, Settings -> Providers -> OpenCode shows `Install OpenCode runtime`. +- Clicking install creates or reuses an OpenCode binary inside WSL, writes the helper script and user service, enables and starts `jcode-opencode.service`, and binds OpenCode to `127.0.0.1:4096`. +- Successful install creates or updates the `WSL OpenCode service` runtime profile and makes it active. +- The existing `provider.getRuntimeHealth` path reports the installed runtime as `healthy` or a clear actionable degraded/error state. +- Manual OpenCode binary/server fields remain available for advanced configuration. +- Re-running install or repair is idempotent and does not duplicate runtime profiles. +- Unsupported environments produce clear UI states and do not attempt partial installation. +- Failure messages are redacted and actionable. +- Focused tests cover WSL detection, service-file rendering, profile upsert behavior, RPC contract validation, Settings panel state transitions, and repair idempotence. +- Runtime docs are updated in the same branch and cover install, generated files, service health, diagnostics, repair, rollback, and the distinction between WSL runtime bootstrap and Windows installer work. +- Documentation stays portable, source-grounded, and contains no private local values. + +## Future Considerations + +- OS-dispatching the same Settings action to a Windows runtime installer when JCode is installed on Windows. +- Custom ports when `4096` is occupied. +- Service diagnostics export with redacted `systemctl` and health evidence. +- Full WSL JCode service installation lane for users installing JCode itself into WSL. +- Integration with ADR 0007's future backend registry once project-to-backend routing exists. From f92a7620ec122522077babc89eae8ec85a0b4f8f Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:06:13 -0400 Subject: [PATCH 08/12] docs: document local opencode runtime operations --- docs/runbooks/README.md | 21 +- docs/runbooks/local-deploy.md | 51 ++- docs/runbooks/local-opencode-runtime.md | 380 +++++++++++++++++++++ docs/runbooks/update-local-stable-jcode.md | 42 ++- 4 files changed, 473 insertions(+), 21 deletions(-) create mode 100644 docs/runbooks/local-opencode-runtime.md diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md index 56e093da..d0cfb306 100644 --- a/docs/runbooks/README.md +++ b/docs/runbooks/README.md @@ -8,21 +8,23 @@ | Audience | Maintainers, release owners, and automation agents | | Scope | Repeatable local development, CI, release, deployment, and troubleshooting procedures | | Canonical path | `docs/runbooks/README.md` | -| Last reviewed | 2026-05-22 | +| Last reviewed | 2026-06-11 | | Review cadence | Event-driven; review when package scripts, CI workflows, release flow, or local deployment assumptions change | | Source of truth | `package.json`, workspace package scripts, `.github/workflows`, release docs, and runtime source | | Verification | Run commands named by the changed runbook when feasible; otherwise document why not | ## Start Here By Scenario -| If you are doing this | Start here | Then check | -| ---------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------- | -| Starting local development | [Local Development Runbook](local-development.md) | [`../../AGENTS.md`](../../AGENTS.md) | -| Triaging CI | [CI Operations Runbook](ci-operations.md) | [Testing Strategy](../testing/strategy.md) | -| Preparing a desktop/server release | [Release Operations Runbook](release-operations.md) | [Release Checklist](../release.md), [Security Baseline](../security/baseline.md) | -| Running local deployment | [Local Deploy Notes](local-deploy.md) | [Repo Governance](../governance/repo-governance.md) | -| Opening JCode from another device | [Remote Access Setup](remote-access.md) | [Security Baseline](../security/baseline.md) | -| Updating local stable JCode | [Update Local Stable JCode](update-local-stable-jcode.md) | [Local Deploy Notes](local-deploy.md) | +| If you are doing this | Start here | Then check | +| ---------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Starting local development | [Local Development Runbook](local-development.md) | [`../../AGENTS.md`](../../AGENTS.md) | +| Triaging CI | [CI Operations Runbook](ci-operations.md) | [Testing Strategy](../testing/strategy.md) | +| Preparing a desktop/server release | [Release Operations Runbook](release-operations.md) | [Release Checklist](../release.md), [Security Baseline](../security/baseline.md) | +| Running local deployment | [Local Deploy Notes](local-deploy.md) | [Local OpenCode Runtime Service](local-opencode-runtime.md) | +| Inspecting local runtime services | [Local OpenCode Runtime Service](local-opencode-runtime.md) | [Local Deploy Notes](local-deploy.md) | +| Installing or repairing a WSL OpenCode runtime | [Local OpenCode Runtime Service](local-opencode-runtime.md) | [Provider Runtime Architecture](../architecture/provider-runtime.md) | +| Opening JCode from another device | [Remote Access Setup](remote-access.md) | [Security Baseline](../security/baseline.md) | +| Updating Jay's local stable JCode | [Update Local Stable JCode](update-local-stable-jcode.md) | [Local OpenCode Runtime Service](local-opencode-runtime.md) | ## Documents @@ -32,4 +34,5 @@ - [Release Operations Runbook](release-operations.md) - [Update Local Stable JCode](update-local-stable-jcode.md) - [Local Deploy Notes](local-deploy.md) +- [Local OpenCode Runtime Service](local-opencode-runtime.md) - [Release Checklist](../release.md) diff --git a/docs/runbooks/local-deploy.md b/docs/runbooks/local-deploy.md index 2f2ebad7..a615c84c 100644 --- a/docs/runbooks/local-deploy.md +++ b/docs/runbooks/local-deploy.md @@ -8,17 +8,21 @@ | Audience | Local operators, maintainers, and automation agents | | Scope | Local deployment shape, source checkout expectations, service boundaries, auth posture, promotion checks, and rollback posture | | Canonical path | `docs/runbooks/local-deploy.md` | -| Last reviewed | 2026-06-04 | +| Last reviewed | 2026-06-11 | | Review cadence | Event-driven; review when deployment scripts, publishability rules, local service assumptions, or security baseline changes | -| Source of truth | Package scripts, `AGENTS.md` publishability rules, and `docs/security/baseline.md` | +| Source of truth | Package scripts, `AGENTS.md` publishability rules, `docs/security/baseline.md`, and local service inspection | | Verification | Source-check deployment assumptions and run focused formatting with `bunx oxfmt@0.52.0 --check ` | These notes document the intended shape for a local JCode deployment without -committing user-specific runtime state as application defaults. +committing user-specific runtime state as application defaults. For the +JCode/OpenCode service topology, runtime profile boundary, and safe inspection +commands, use [Local OpenCode Runtime Service](local-opencode-runtime.md). ## Source Path -Use a local source checkout outside runtime state. Example: +Run the service from the operator's chosen installed checkout or package path, +kept separate from runtime state such as generated auth, logs, and pinned runtime +binaries. If the operator uses a source checkout, one example path is: ```text ~/code/jcode @@ -26,16 +30,45 @@ Use a local source checkout outside runtime state. Example: ## Target Service Shape -A local deployment should be a user systemd service with: +A local deployment should keep JCode app serving and OpenCode runtime serving as +separate, inspectable responsibilities. + +In one observed local deployment, the example service names and listeners are: + +| Example service name | Responsibility | Example observed listener | +| ------------------------ | ------------------------- | ------------------------- | +| `jcode.service` | JCode headless server | `http://127.0.0.1:3775/` | +| `jcode-opencode.service` | External OpenCode runtime | `http://127.0.0.1:4096/` | + +In that deployment, `jcode.service` has `After=` and `Wants=` dependencies on +`jcode-opencode.service`. Operators may choose different unit names or loopback +ports, but the same boundary should stay clear: JCode reads its OpenCode runtime +profile from settings; when that profile contains a `serverUrl`, JCode connects +to the external runtime instead of starting managed OpenCode. + +Each user systemd service should have: - `Restart=always` - short stop timeout - control-group kill mode -- a dedicated state directory outside the repo +- a dedicated state directory outside the checkout or package path - a local-only HTTP listener proxied by the user's chosen secure transport Machine-specific values can be kept in local service files, but the repo should -only carry generic examples. +only carry generic examples. Do not commit private hostnames, owner pairing +links, tokens, cookies, passwords, or private service URLs. + +## WSL OpenCode Runtime Bootstrap + +For WSL deployments, use **Settings -> Providers -> OpenCode** to install or +repair the OpenCode runtime service when that action is available. The Settings +flow lets the server own service file creation, runtime profile mutation, and +health verification, which keeps the deployment inspectable without requiring +users to hand-edit systemd files. + +Manual `jcode-opencode.service` files remain useful for diagnosis, unsupported +environments, or operator-managed layouts. They should not be the first-time WSL +setup path once the Settings bootstrap can run. ## Auth Mode @@ -48,8 +81,8 @@ For a public/default setup, app auth should remain enabled. Before switching a live local service to JCode: -1. Build JCode from the local source checkout. +1. Build or install JCode from the operator-chosen checkout or package path. 2. Start it locally with a separate state directory. 3. Verify project list, thread open, agent start, and restart recovery. 4. Tag the known-good state. -5. Switch the service path and keep the previous service path as rollback. +5. Switch the service source path and keep the previous source path as rollback. diff --git a/docs/runbooks/local-opencode-runtime.md b/docs/runbooks/local-opencode-runtime.md new file mode 100644 index 00000000..9429c540 --- /dev/null +++ b/docs/runbooks/local-opencode-runtime.md @@ -0,0 +1,380 @@ +# Local OpenCode Runtime Service + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Status | Active | +| Type | Operational runbook | +| Owner | Operations and Engineering | +| Audience | Local operators, maintainers, and automation agents | +| Scope | Local JCode systemd service topology, external OpenCode runtime wiring, safe inspection commands, upgrade risk checks, and future WSL bootstrap | +| Canonical path | `docs/runbooks/local-opencode-runtime.md` | +| Last reviewed | 2026-06-11 | +| Review cadence | Event-driven; review when service wiring, runtime profile settings, OpenCode server mode, or local promotion helpers change | +| Source of truth | Observed local user systemd units, `jcode-start`, `jcode-opencode-start`, `jcs`, provider runtime source, and OpenCode server/config docs | +| Verification | Source-check named commands and service assumptions; redact secrets before sharing command output | + +## Purpose + +Use this runbook when a local JCode deployment is wired to an external OpenCode +runtime service instead of letting JCode manage OpenCode internally. + +The core shape is two services: + +| Service | Role | Observed local deployment example | +| ------------------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `jcode.service` | JCode headless server | Runs from the operator's chosen installed checkout or package path and listens on `http://127.0.0.1:3775/` | +| `jcode-opencode.service` | External OpenCode runtime | Runs a pinned OpenCode binary with `opencode serve --hostname=127.0.0.1 --port=4096` | + +`jcode.service` has `After=` and `Wants=` dependencies on +`jcode-opencode.service`, so the OpenCode runtime is requested before the JCode +headless server starts. A separate SSH tunnel service may exist on a local +machine, but it is not part of the core JCode/OpenCode two-service topology. + +Do not copy local secrets, owner pairing links, cookies, private URLs, or private +hostnames into docs, issues, or support messages. Redact command output before +sharing it. + +## Topology + +```text +Browser or client + | + | JCode app auth and session cookies + v +jcode.service + | + | provider runtime profile + | optional OpenCode HTTP Basic auth when serverPassword is set + v +jcode-opencode.service +``` + +JCode app auth and dev automation access are separate from OpenCode server HTTP +auth. OpenCode server HTTP auth is handled at the OpenCode runtime HTTP +boundary, where JCode can build SDK clients with Basic auth when the configured +runtime profile includes a server password. + +## JCode Runtime Boundary + +The provider boundary is stored and resolved in code, not in the systemd unit: + +- `packages/contracts/src/settings.ts` stores + `providers.opencode.serverUrl`, `serverPassword`, `runtimeProfiles`, and + `activeRuntimeProfileId`. +- `apps/server/src/provider/openCodeRuntimeProfiles.ts` resolves a configured + `serverUrl` to an external OpenCode runtime. If no external URL is configured, + JCode falls back to managed OpenCode. +- `apps/server/src/provider/opencodeRuntime.ts` starts managed OpenCode with + `serve --hostname --port`, or connects to the configured external `serverUrl`. + SDK clients include optional Basic auth when `serverPassword` is set. +- `apps/server/src/provider/openCodeRuntimeHealth.ts` checks runtime + reachability, inventory, agents, commands, skills, plugins, models, and + required capabilities. + +In the observed local deployment, JCode settings use an active runtime profile +whose `serverUrl` points at loopback port `4096`. Treat that as local state, not +a universal default. + +## Service Details To Know + +### JCode service + +Observed local deployment: + +- `jcode.service` description: `JCode headless server`. +- It is enabled and active. +- In Jay's observed deployment, it runs from a stable checkout. That is an + example, not a default for every operator. +- It starts through a local `jcode-start` helper. +- The helper creates runtime home and state directories, exports `JCODE_HOME`, + keeps legacy home aliases, reads or mints a JCode app auth token from state, + trusts the installed checkout's toolchain file when needed, then starts + `apps/server` on loopback. +- The observed listener is `http://127.0.0.1:3775/`. + +Keep machine-specific paths and private URLs in local service files or local +state. They are examples, not committed application defaults. + +### OpenCode runtime service + +Observed local deployment: + +- `jcode-opencode.service` description: `JCode external OpenCode runtime`. +- It is enabled and active. +- It starts through a local `jcode-opencode-start` helper. +- The helper points at a pinned OpenCode binary under runtime state. +- The observed host and port are `127.0.0.1` and `4096`. +- The helper unsets `OPENCODE_CONFIG_CONTENT` before starting OpenCode. This + keeps the real OpenCode profile and config from being replaced by inline + managed config. +- The helper executes OpenCode server mode with: + + ```bash + opencode serve --hostname="$host" --port="$port" --print-logs --log-level INFO + ``` + +The service may carry local resource controls such as memory, task, and CPU +limits. Treat those as operator tuning unless the service helper documents them +as required. + +## WSL Settings Bootstrap + +On WSL, prefer the Settings-native bootstrap when it is available: + +1. Open **Settings -> Providers -> OpenCode**. +2. In the OpenCode runtime card, read the **Bootstrap** status. +3. If the state is `notInstalled`, choose **Install OpenCode runtime**. +4. If the state is `error`, choose **Repair runtime**. +5. Use **Check** to force a runtime health refresh after the service is ready. + +The UI calls server-owned RPCs. The browser does not write service files or edit +settings directly. The server detects WSL, checks user systemd, renders the +runtime helper and service unit, starts or restarts `jcode-opencode.service`, +smokes `http://127.0.0.1:4096/`, then upserts the `wsl-opencode-service` +runtime profile. + +Install and repair are owner-only operations because they write local service +files and mutate the active OpenCode runtime profile. Scoped remote client +sessions may read runtime status but must not be able to install or repair the +service. + +Unsupported states are informational. If the bootstrap card reports that WSL or +user systemd is unavailable, do not use the Settings action as a workaround; fix +the environment first or use manual service diagnostics. + +## Generated Files + +The Settings bootstrap keeps generated runtime files outside the JCode checkout. +Current server source renders these portable paths relative to the WSL user's +home directory: + +| Generated item | Current Settings bootstrap path | +| ---------------------- | ------------------------------------------------------------ | +| Runtime directory | `~/.local/share/jcode/runtime/opencode/` | +| OpenCode helper script | `~/.local/share/jcode/runtime/opencode/jcode-opencode-start` | +| User systemd service | `~/.config/systemd/user/jcode-opencode.service` | +| Runtime profile server | `http://127.0.0.1:4096/` | +| Runtime profile id | `wsl-opencode-service` | + +Older manual service examples may place the helper at +`~/.local/bin/jcode-opencode-start`. Treat that as an operator-chosen layout, +not the current Settings bootstrap default. + +The helper script unsets `OPENCODE_CONFIG_CONTENT` and then starts OpenCode with +loopback-only server mode: + +```bash +'' serve --hostname=127.0.0.1 --port=4096 +``` + +The bootstrap currently resolves an existing `opencode` binary from configured +settings or `PATH`. It does not download the Windows managed-runtime sidecar or +install Windows packaging assets in this branch. + +## Health Checks + +The runtime card uses two checks: + +- **Bootstrap status** checks whether the WSL lane is supported, whether user + systemd is available, whether `jcode-opencode.service` exists and is active, + whether port `4096` is free or reachable, and whether the OpenCode runtime + profile can be reached. +- **Runtime health** reuses the normal OpenCode runtime health path to verify + reachability, inventory, agents, commands, skills, plugins, models, and + capability requirements. + +For command-line inspection, use: + +```bash +systemctl --user status jcode-opencode.service +systemctl --user cat jcode-opencode.service +journalctl --user -u jcode-opencode.service +curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ +``` + +## Repair + +Use **Repair runtime** when the bootstrap status is `error`. Repair rewrites the +helper and service unit, runs `systemctl --user daemon-reload`, restarts +`jcode-opencode.service`, smokes the runtime URL, and upserts the same +`wsl-opencode-service` profile without creating duplicates. + +Use manual repair only when Settings cannot run: + +```bash +systemctl --user daemon-reload +systemctl --user restart jcode-opencode.service +curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ +``` + +## Safe Inspection Commands + +Run these on the local machine that owns the user systemd services: + +```bash +systemctl --user status jcode.service jcode-opencode.service +systemctl --user show jcode.service --property=Description,LoadState,ActiveState,SubState,FragmentPath,WorkingDirectory,ExecStart,After,Wants,Restart +systemctl --user show jcode-opencode.service --property=Description,LoadState,ActiveState,SubState,FragmentPath,WorkingDirectory,ExecStart,Restart,MemoryHigh,MemoryMax,TasksMax,CPUQuota +systemctl --user cat jcode.service +systemctl --user cat jcode-opencode.service +journalctl --user -u jcode.service +journalctl --user -u jcode-opencode.service +curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:3775/ +curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ +``` + +Before sharing output, redact secrets and machine-specific private values: + +- tokens, cookies, passwords, and Basic auth values; +- owner pairing links; +- private URLs and hostnames; +- private service environment values; +- local paths if they reveal sensitive account or project names. + +## Standard Health Check + +1. Check both services. + + ```bash + systemctl --user status jcode.service jcode-opencode.service + ``` + + Expected result: both services are loaded and active. + + If it fails: inspect the relevant unit and journal with the commands above. + +2. Check service dependency wiring. + + ```bash + systemctl --user show jcode.service --property=After,Wants + ``` + + Expected result: `jcode-opencode.service` appears in both `After=` and + `Wants=`. + + If it fails: treat the local service wiring as incomplete. Do not edit repo + code to compensate for a local unit issue. + +3. Smoke the loopback endpoints. + + ```bash + curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:3775/ + curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ + ``` + + Expected result: both commands exit successfully. + + If it fails: check whether the service is running, whether the listener host + and port changed, and whether the configured JCode runtime profile still + points at the intended OpenCode server URL. + +4. Use the local status helper when available. + + ```bash + jcs + ``` + + Expected result: `jcs` reports the configured local repo branch, SHA, and + status, both services, both loopback endpoint smoke checks, and the latest + promotion record when the local helper records one. + + If it fails: read the failing section first. `jcs` is an inspection helper, + not a repair script. + +## Promotion Helpers + +Use [Update Local Stable JCode](update-local-stable-jcode.md) for Jay's local +stable promotion flow. + +- `jcup` is the frequent local update wrapper. It runs the dry run, promotion, + and `jcs`. +- `jcu` is the lower-level promotion and rollback helper. +- `jcs` checks the configured repo, both services, both loopback endpoints, and + the latest promotion record when the local helper records one. + +## OpenCode Upgrade Risk Checklist + +Use this checklist before changing the OpenCode runtime package or pinned binary +used by `jcode-opencode.service`. + +- [ ] Record the current OpenCode binary path, version, and service status. +- [ ] Do not replace the pinned runtime service binary blindly. +- [ ] Check the OpenCode upstream server docs, config docs, changelog, and + release notes before upgrading. +- [ ] Validate config precedence assumptions, especially that + `OPENCODE_CONFIG_CONTENT` is not replacing the real runtime profile. +- [ ] Validate server mode assumptions, including `opencode serve`, loopback + hostname, port, and any HTTP auth settings. +- [ ] Restart only `jcode-opencode.service` first when possible. +- [ ] Smoke the OpenCode endpoint, then smoke JCode provider behavior through the + JCode app. +- [ ] Roll back by restoring the prior pinned runtime path or package and + restarting `jcode-opencode.service`. + +Do not claim version compatibility from this runbook alone. Confirm it from the +OpenCode release notes and local smoke checks. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------- | +| JCode is active but provider calls fail | Runtime profile points at an unreachable server | Check the profile `serverUrl`, `jcode-opencode.service`, and loopback port `4096` | +| OpenCode starts with the wrong config | Inline config replaced the real profile | Confirm the runtime helper unsets `OPENCODE_CONFIG_CONTENT` before `opencode serve` | +| JCode starts before OpenCode is available | Missing or changed user unit dependency | Check `After=` and `Wants=` on `jcode.service` | +| Endpoint smoke check fails | Service stopped, listener moved, or port blocked | Inspect service status, unit files, and journals, then re-run the loopback curl check | +| Runtime inventory or capabilities fail | OpenCode server reachable but incompatible config | Check runtime health output and compare agents, commands, skills, plugins, and models | + +## Rollback + +If a JCode promotion broke the local service, use the rollback path in +[Update Local Stable JCode](update-local-stable-jcode.md). + +If an OpenCode runtime upgrade broke provider behavior: + +1. Restore the prior pinned OpenCode runtime path or package in the local runtime + state. +2. Restart only the runtime service first when possible. + + ```bash + systemctl --user restart jcode-opencode.service + ``` + +3. Smoke the runtime endpoint. + + ```bash + curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:4096/ + ``` + +4. Smoke JCode. + + ```bash + jcs + curl --max-time 8 -fsS -o /dev/null http://127.0.0.1:3775/ + ``` + +5. Restart `jcode.service` only if JCode still has stale provider state after the + OpenCode runtime is healthy. + +If the Settings bootstrap itself created the WSL runtime service and the goal is +to remove that lane entirely: + +1. Stop and disable the user service. + + ```bash + systemctl --user disable --now jcode-opencode.service + ``` + +2. Remove the generated unit and helper only after confirming they are not + operator-maintained manual files. + + ```bash + rm -f ~/.config/systemd/user/jcode-opencode.service + rm -f ~/.local/share/jcode/runtime/opencode/jcode-opencode-start + systemctl --user daemon-reload + ``` + +3. In Settings, switch OpenCode back to another runtime profile or clear the WSL + profile before deleting runtime state. + +Keep the bootstrap portable. Do not bake private hostnames, tokens, cookies, +owner pairing links, or machine-specific service values into repo defaults. diff --git a/docs/runbooks/update-local-stable-jcode.md b/docs/runbooks/update-local-stable-jcode.md index a74e774e..de7148be 100644 --- a/docs/runbooks/update-local-stable-jcode.md +++ b/docs/runbooks/update-local-stable-jcode.md @@ -8,9 +8,9 @@ | Audience | JCode maintainers and local coding agents | | Scope | Promote the latest GitHub `main` into Jay's local stable JCode service | | Canonical path | `docs/runbooks/update-local-stable-jcode.md` | -| Last reviewed | 2026-05-31 | +| Last reviewed | 2026-06-11 | | Review cadence | Event-driven; review when `jcup`, `jcu`, or JCode service wiring changes | -| Source of truth | `/home/jay/.local/bin/jcode-stable-update`, `jcup`, `jcu`, `jcs`, `jcr` | +| Source of truth | `/home/jay/.local/bin/jcode-stable-update`, `jcup`, `jcu`, `jcs`, `jcr`, local services | | Verification | `jcup`, `jcup --fast`, `jcu main --dry-run`, `jcu main`, `jcs`, local HTTP smoke checks | ## Purpose @@ -19,10 +19,19 @@ Use this when Jay says something like: > Update my local stable JCode with the new stuff on GitHub. +This is Jay's local stable service runbook, not a general installation guide for +other JCode operators. + The live service does **not** run from the dev repo at `/home/jay/code/jcode`. It runs from the stable checkout at `/home/jay/code/jcode-stable`. +The live local shape is a two-service setup: `jcode.service` serves the JCode +headless server from the stable checkout, while `jcode-opencode.service` serves +the external OpenCode runtime on loopback for JCode's configured runtime profile. +For service topology, safe inspection commands, auth boundary notes, and runtime +rollback guidance, see [Local OpenCode Runtime Service](local-opencode-runtime.md). + The daily-driver promotion command is: ```bash @@ -36,7 +45,7 @@ the important checks, updates the stable checkout, builds, typechecks, restarts JCode, runs local smoke checks, records the promotion, and rolls back if a post-checkout failure happens. -## Quick Prompt For The JCode LLM +## Jay-Only Quick Prompt For The JCode LLM ```text Update Jay's local stable JCode from the latest GitHub main. @@ -149,6 +158,33 @@ preflight and smoke checks without writing a new promotion record. - smoke-check result; - any failure, rollback, or manual follow-up. +## OpenCode Runtime Upgrade Risk + +The stable JCode promotion flow checks that `jcode-opencode.service` is active +and that the loopback OpenCode endpoint responds. It does not prove that a new +OpenCode binary is compatible with every provider operation. Treat OpenCode +runtime changes as their own operational risk. + +Before upgrading the OpenCode runtime used by `jcode-opencode.service`: + +- [ ] Record the current OpenCode binary path, version, and service status. +- [ ] Do not replace the pinned runtime service binary blindly. +- [ ] Check the OpenCode upstream server docs, config docs, changelog, and + release notes before upgrading. +- [ ] Validate config precedence assumptions, especially that + `OPENCODE_CONFIG_CONTENT` is not replacing the real OpenCode profile. +- [ ] Validate server mode assumptions, including `opencode serve`, loopback + hostname, port, and any HTTP auth settings. +- [ ] Restart only `jcode-opencode.service` first where possible. +- [ ] Smoke the OpenCode endpoint, then smoke JCode provider behavior through the + JCode app. +- [ ] Roll back by restoring the prior pinned runtime path or package and + restarting `jcode-opencode.service`. + +Do not assert OpenCode version compatibility from a JCode promotion alone. Use +the runtime checklist in [Local OpenCode Runtime Service](local-opencode-runtime.md) +for the detailed inspection and rollback flow. + ## Optional Dev Repo Sync The dev repo is useful for source inspection and normal development: From ea4fffa64fb2199d3977846b8121086a2f2a5214 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:06:20 -0400 Subject: [PATCH 09/12] docs: clarify provider runtime bootstrap architecture --- ...07-parallel-windows-wsl-backend-routing.md | 2 ++ docs/architecture/provider-runtime.md | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/adr/0007-parallel-windows-wsl-backend-routing.md b/docs/adr/0007-parallel-windows-wsl-backend-routing.md index 1e4bc133..98700f57 100644 --- a/docs/adr/0007-parallel-windows-wsl-backend-routing.md +++ b/docs/adr/0007-parallel-windows-wsl-backend-routing.md @@ -8,6 +8,8 @@ JCode runs on macOS and Linux today. Windows users who develop inside WSL need JCode to run tooling (git, shell, provider CLI) inside the WSL Linux environment while the cockpit UI and desktop shell run on the Windows host. The two OS environments share a single machine but have different filesystem roots, process namespaces, and shell environments. +The Settings-native WSL OpenCode runtime bootstrap is a narrower provider-runtime service lane. It can create or repair `jcode-opencode.service` and activate an external OpenCode runtime profile inside the current WSL environment, but it does not implement the project-to-backend routing model described in this ADR. + The existing `ServerEnvironment` service describes one environment per JCode server: a single `ExecutionEnvironmentDescriptor` with one `environmentId`, one `platform.os`, one `cwd`, and one label. Process spawning (`processRunner.ts`), terminal PTY management (`terminal/`), git operations (`git/`), and the provider runtime (`codexAppServerManager.ts`) all assume they run in a single local OS. The CONTEXT.md decision states: "It should route each project or thread to the backend where its workspace lives rather than treating Windows and WSL as mutually exclusive global modes." The first slice is design-only: define project-to-backend routing, backend lifecycle, auth bootstrap, failure states, and user-visible mode transitions before implementation. diff --git a/docs/architecture/provider-runtime.md b/docs/architecture/provider-runtime.md index 32333e36..e8199afd 100644 --- a/docs/architecture/provider-runtime.md +++ b/docs/architecture/provider-runtime.md @@ -29,16 +29,34 @@ Provider process or external provider server ## Important Boundaries -| Area | Source | -| --------------------------------- | ---------------------------------------------------- | -| OpenCode adapter | `apps/server/src/provider/Layers/OpenCodeAdapter.ts` | -| Provider health/update advisories | `apps/server/src/provider/Layers/ProviderHealth.ts` | -| Runtime event contracts | `packages/contracts/src/providerRuntime.ts` | -| Ingestion/projection | `apps/server/src/orchestration` | +| Area | Source | +| --------------------------------- | ------------------------------------------------------ | +| OpenCode adapter | `apps/server/src/provider/Layers/OpenCodeAdapter.ts` | +| OpenCode WSL bootstrap | `apps/server/src/provider/openCodeRuntimeBootstrap.ts` | +| Provider health/update advisories | `apps/server/src/provider/Layers/ProviderHealth.ts` | +| Runtime event contracts | `packages/contracts/src/providerRuntime.ts` | +| Ingestion/projection | `apps/server/src/orchestration` | ## Invariants - Provider adapters should emit canonical turn lifecycle events instead of leaking raw provider events directly to the UI. - Do not suppress runtime errors globally; reproduce the provider event path and add a focused regression. - OpenCode can run as an external/remote runtime; do not infer external runtime freshness from the local `opencode` CLI. +- Settings may trigger provider runtime bootstrap actions, but the server owns service creation, runtime profile mutation, and runtime health verification. - Idle/completion behavior must distinguish truly missing assistant output from assistant output that arrived through snapshots, part updates, or newer provider events. + +## Settings-Owned Actions, Server-Owned Effects + +The Settings UI can expose provider-aware actions such as **Install OpenCode +runtime** and **Repair runtime**, but those actions are only triggers. Server RPC +handlers perform the side effects: detecting support, rendering service files, +calling user systemd, updating OpenCode runtime profiles through +`ServerSettingsService`, and running runtime health checks. Install and repair +are owner-only operations because they mutate local service files and active +provider settings. + +For the WSL OpenCode runtime lane, the server creates a loopback-only external +OpenCode profile (`wsl-opencode-service`) that points at +`http://127.0.0.1:4096/`. This is separate from the broader Windows/WSL backend +routing design: it bootstraps one provider runtime service and does not route +projects, terminals, git, or provider sessions across backend boundaries. From 96302546058af771213d4d0a0cdaa991e9ca9f17 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:30:37 -0400 Subject: [PATCH 10/12] fix(server): address opencode bootstrap review feedback --- .../provider/openCodeRuntimeBootstrap.test.ts | 12 ++++++++---- .../src/provider/openCodeRuntimeBootstrap.ts | 17 +++-------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts b/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts index 6c9f8abd..127378d1 100644 --- a/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts +++ b/apps/server/src/provider/openCodeRuntimeBootstrap.test.ts @@ -252,11 +252,11 @@ describe("openCodeRuntimeBootstrap", () => { it("renders a loopback-only user service unit", () => { const unit = renderJcodeOpenCodeServiceUnit({ - startScriptPath: "/home/alice/.local/bin/jcode-opencode-start", + startScriptPath: START_SCRIPT_PATH, }); expect(unit).toContain("Description=JCode external OpenCode runtime"); - expect(unit).toContain("ExecStart=/home/alice/.local/bin/jcode-opencode-start"); + expect(unit).toContain(`ExecStart=${START_SCRIPT_PATH}`); expect(unit).not.toContain("0.0.0.0"); }); @@ -380,10 +380,11 @@ describe("openCodeRuntimeBootstrap", () => { ); expect(redacted).not.toContain("abc"); - expect(redacted).not.toContain("secret"); + expect(redacted).not.toContain("password=secret"); expect(redacted).not.toContain("user:pass"); expect(redacted).toContain("token="); expect(redacted).toContain("password="); + expect(redacted).toContain("client_secret="); }); it("gets bootstrap status through the injected probe adapter", async () => { @@ -509,6 +510,9 @@ describe("openCodeRuntimeBootstrap", () => { ); await expect( bootstrapWslOpenCodeRuntime(adapter, { provider: "opencode" }), - ).rejects.not.toThrow("secret"); + ).rejects.not.toThrow("password=secret"); + await expect( + bootstrapWslOpenCodeRuntime(adapter, { provider: "opencode" }), + ).rejects.not.toThrow("client_secret=hidden"); }); }); diff --git a/apps/server/src/provider/openCodeRuntimeBootstrap.ts b/apps/server/src/provider/openCodeRuntimeBootstrap.ts index c25ad54a..f0fddcbf 100644 --- a/apps/server/src/provider/openCodeRuntimeBootstrap.ts +++ b/apps/server/src/provider/openCodeRuntimeBootstrap.ts @@ -154,25 +154,14 @@ export function detectWslOpenCodeBootstrapStatus( }); } - if (!input.profileReachable) { - return snapshot({ - checkedAt: input.now, - state: "error", - serviceName: JCODE_OPENCODE_SERVICE_NAME, - binaryPath: input.binaryPath, - serverUrl, - profileId: WSL_OPENCODE_PROFILE_ID, - message: `${JCODE_OPENCODE_SERVICE_NAME} is active but the OpenCode runtime is unreachable.`, - }); - } - return snapshot({ checkedAt: input.now, - state: "ready", + state: "error", serviceName: JCODE_OPENCODE_SERVICE_NAME, binaryPath: input.binaryPath, serverUrl, profileId: WSL_OPENCODE_PROFILE_ID, + message: `${JCODE_OPENCODE_SERVICE_NAME} is active but the OpenCode runtime is unreachable.`, }); } @@ -283,7 +272,7 @@ export function upsertWslOpenCodeRuntimeProfile( export function redactBootstrapMessage(message: string): string { return message - .replace(/\b(client_secret)=([^\s&]+)/gi, "client_=") + .replace(/\b(client_secret)=([^\s&]+)/gi, "$1=") .replace(/\b(token|password)=([^\s&]+)/gi, "$1=") .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1@"); } From 9d1672f0d4270800c94bca40474130d088eaff3a Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:30:46 -0400 Subject: [PATCH 11/12] fix(contracts): relax runtime bootstrap service names --- packages/contracts/src/providerDiscovery.ts | 2 +- packages/contracts/src/rpc.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/providerDiscovery.ts b/packages/contracts/src/providerDiscovery.ts index dc0dcf4f..0ba37fd0 100644 --- a/packages/contracts/src/providerDiscovery.ts +++ b/packages/contracts/src/providerDiscovery.ts @@ -612,7 +612,7 @@ export const ProviderRuntimeBootstrapSnapshot = Schema.Struct({ provider: Schema.Literal("opencode"), lane: OpenCodeRuntimeBootstrapLane, state: OpenCodeRuntimeBootstrapState, - serviceName: Schema.optional(Schema.Literal("jcode-opencode.service")), + serviceName: Schema.optional(TrimmedNonEmptyString), binaryPath: Schema.optional(TrimmedNonEmptyString), serverUrl: Schema.optional(TrimmedNonEmptyString), profileId: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 868f5695..fd2a61a0 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -9,6 +9,7 @@ import { WsRpcGroup, } from "./rpc"; import { WS_METHODS } from "./ws"; +import { ProviderRuntimeBootstrapSnapshot } from "./providerDiscovery"; describe("WS RPC contracts", () => { it("exports the additive Effect RPC group", () => { @@ -43,6 +44,18 @@ describe("WS RPC contracts", () => { ); }); + it("accepts provider runtime bootstrap snapshots with custom service names", () => { + const decoded = Schema.decodeUnknownSync(ProviderRuntimeBootstrapSnapshot)({ + provider: "opencode", + lane: "wsl-service", + state: "error", + serviceName: "jcode-opencode-dev.service", + checkedAt: "2026-06-11T12:00:00.000Z", + }); + + expect(decoded.serviceName).toBe("jcode-opencode-dev.service"); + }); + it("preserves typed voice transcription auth-expired details", () => { const decoded = Schema.decodeUnknownSync(WsRpcError)({ _tag: "WsRpcError", From 45e63a67e56144a2efb75972656ef3b568ff5920 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 11 Jun 2026 21:31:07 -0400 Subject: [PATCH 12/12] docs: align wsl opencode helper paths --- .../plans/2026-06-11-wsl-opencode-runtime-bootstrap.md | 8 +++++--- .../2026-06-11-wsl-opencode-runtime-bootstrap-design.md | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md b/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md index e26aa4b5..560ec22f 100644 --- a/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md +++ b/docs/superpowers/plans/2026-06-11-wsl-opencode-runtime-bootstrap.md @@ -335,11 +335,13 @@ describe("openCodeRuntimeBootstrap", () => { it("renders a loopback-only user service", () => { const unit = renderJcodeOpenCodeServiceUnit({ - startScriptPath: "/home/alice/.local/bin/jcode-opencode-start", + startScriptPath: "/home/alice/.local/share/jcode/runtime/opencode/jcode-opencode-start", }); expect(unit).toContain("Description=JCode external OpenCode runtime"); - expect(unit).toContain("ExecStart=/home/alice/.local/bin/jcode-opencode-start"); + expect(unit).toContain( + "ExecStart=/home/alice/.local/share/jcode/runtime/opencode/jcode-opencode-start", + ); expect(unit).not.toContain("0.0.0.0"); }); @@ -884,7 +886,7 @@ Include these concrete portable paths: ```text ~/.local/share/jcode/runtime/opencode/opencode -~/.local/bin/jcode-opencode-start +~/.local/share/jcode/runtime/opencode/jcode-opencode-start ~/.config/systemd/user/jcode-opencode.service http://127.0.0.1:4096/ ``` diff --git a/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md b/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md index 32b740c3..21c49d04 100644 --- a/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md +++ b/docs/superpowers/specs/2026-06-11-wsl-opencode-runtime-bootstrap-design.md @@ -128,10 +128,13 @@ Suggested generated artifacts: ```text ~/.local/share/jcode/runtime/opencode/opencode -~/.local/bin/jcode-opencode-start +~/.local/share/jcode/runtime/opencode/jcode-opencode-start ~/.config/systemd/user/jcode-opencode.service ``` +Older manual deployments may use `~/.local/bin/jcode-opencode-start`; the +Settings bootstrap keeps its generated helper with the runtime state instead. + The installer should run: ```bash @@ -276,7 +279,7 @@ Required documentation updates: The docs should include concrete examples but remain portable: ```text -~/.local/bin/jcode-opencode-start +~/.local/share/jcode/runtime/opencode/jcode-opencode-start ~/.config/systemd/user/jcode-opencode.service http://127.0.0.1:4096/ ```