From 33c96607a2265683538649d9cdf62e6b6fe2b418 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 14:38:24 -0400 Subject: [PATCH 01/18] =?UTF-8?q?feat(windows):=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?NSIS=20installer,=20provider=20scanning,=20download=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements three parallel slices from the Windows Turnkey Release roadmap: Slice 1 (#74): NSIS installer config for per-user Windows install - perMachine: false, oneClick: false, runAfterFinish: true - Start Menu shortcut, clean uninstall with deleteAppDataOnUninstall Slice 2 (#73): Credential-first provider scanning for all 7 providers - Detects env var credentials and config directory presence - Scans binary PATH for installed provider CLIs - Returns structured per-provider status (ready/needs-config/not-installed) Slice 4 (#72): Managed download/verify pipeline for OpenCode runtime - Fetches latest release from GitHub releases API - Platform-specific asset matching (win-x64, linux-x64, darwin-arm64/x64) - SHA-256 verification with atomic download-replace pattern - chmod on non-Windows platforms Also includes ADR 0005 (backend-owned managed runtime sidecar) and ADR 0006 (provider-agnostic first-run wizard). Contracts: providerScan.ts, managedRuntime.ts Implementation: providerCredentialScan.ts, managedRuntimeDownload.ts Tests: 32 new tests, all passing; snip 7 existing tests unaffected --- .../provider/managedRuntimeDownload.test.ts | 164 ++++++++ .../src/provider/managedRuntimeDownload.ts | 352 ++++++++++++++++++ .../provider/providerCredentialScan.test.ts | 279 ++++++++++++++ .../src/provider/providerCredentialScan.ts | 205 ++++++++++ ...5-backend-owned-managed-runtime-sidecar.md | 95 +++++ ...0006-provider-agnostic-first-run-wizard.md | 85 +++++ docs/adr/README.md | 2 + packages/contracts/src/index.ts | 2 + packages/contracts/src/managedRuntime.ts | 71 ++++ packages/contracts/src/providerDiscovery.ts | 3 +- packages/contracts/src/providerScan.ts | 41 ++ .../build-desktop-artifact-mac-config.test.ts | 8 + scripts/lib/desktop-platform-build-config.ts | 8 + 13 files changed, 1314 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/managedRuntimeDownload.test.ts create mode 100644 apps/server/src/provider/managedRuntimeDownload.ts create mode 100644 apps/server/src/provider/providerCredentialScan.test.ts create mode 100644 apps/server/src/provider/providerCredentialScan.ts create mode 100644 docs/adr/0005-backend-owned-managed-runtime-sidecar.md create mode 100644 docs/adr/0006-provider-agnostic-first-run-wizard.md create mode 100644 packages/contracts/src/managedRuntime.ts create mode 100644 packages/contracts/src/providerScan.ts diff --git a/apps/server/src/provider/managedRuntimeDownload.test.ts b/apps/server/src/provider/managedRuntimeDownload.test.ts new file mode 100644 index 000000000..08afc974e --- /dev/null +++ b/apps/server/src/provider/managedRuntimeDownload.test.ts @@ -0,0 +1,164 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; + +import type { GitHubRelease } from "@jcode/contracts"; + +import { + computeFileSha256, + detectManagedRuntimePlatform, + findAssetForPlatform, + resolveManagedRuntimeDir, + verifyManagedRuntimeBinary, +} from "./managedRuntimeDownload"; + +const makeMockRelease = (assets: ReadonlyArray<{ name: string; size: number }>): GitHubRelease => ({ + tagName: "v1.0.0", + name: "OpenCode v1.0.0", + assets: assets.map((a) => ({ + name: a.name, + browserDownloadUrl: `https://github.com/anomalyco/opencode/releases/download/v1.0.0/${a.name}`, + size: a.size, + })), +}); + +const MOCK_BINARY_CONTENT = new TextEncoder().encode("#!/bin/sh\necho opencode"); + +describe("detectManagedRuntimePlatform", () => { + it("returns linux-x64 for linux", () => { + expect(detectManagedRuntimePlatform("linux", "x64")).toBe("linux-x64"); + }); + + it("returns darwin-arm64 for darwin arm64", () => { + expect(detectManagedRuntimePlatform("darwin", "arm64")).toBe("darwin-arm64"); + }); + + it("returns darwin-x64 for darwin x64", () => { + expect(detectManagedRuntimePlatform("darwin", "x64")).toBe("darwin-x64"); + }); + + it("returns win-x64 for win32", () => { + expect(detectManagedRuntimePlatform("win32", "x64")).toBe("win-x64"); + }); + + it("defaults to linux-x64 for unknown platform", () => { + expect(detectManagedRuntimePlatform("freebsd", "x64")).toBe("linux-x64"); + }); +}); + +describe("findAssetForPlatform", () => { + const release = makeMockRelease([ + { name: "opencode-v1.0.0-darwin-arm64", size: 100 }, + { name: "opencode-v1.0.0-darwin-x86_64", size: 100 }, + { name: "opencode-v1.0.0-linux-x86_64", size: 100 }, + { name: "opencode-v1.0.0-windows-x86_64.exe", size: 100 }, + ]); + + it("finds darwin-arm64 asset", () => { + const asset = findAssetForPlatform(release, "darwin-arm64"); + expect(asset?.name).toBe("opencode-v1.0.0-darwin-arm64"); + }); + + it("finds darwin-x64 asset", () => { + const asset = findAssetForPlatform(release, "darwin-x64"); + expect(asset?.name).toBe("opencode-v1.0.0-darwin-x86_64"); + }); + + it("finds linux-x64 asset", () => { + const asset = findAssetForPlatform(release, "linux-x64"); + expect(asset?.name).toBe("opencode-v1.0.0-linux-x86_64"); + }); + + it("finds win-x64 asset", () => { + const asset = findAssetForPlatform(release, "win-x64"); + expect(asset?.name).toBe("opencode-v1.0.0-windows-x86_64.exe"); + }); + + it("returns null when no asset matches", () => { + const emptyRelease = makeMockRelease([{ name: "readme.txt", size: 10 }]); + const asset = findAssetForPlatform(emptyRelease, "linux-x64"); + expect(asset).toBeNull(); + }); + + it("matches macOS naming variants", () => { + const macosRelease = makeMockRelease([ + { name: "opencode-macos-arm64", size: 100 }, + { name: "opencode-mac-x64", size: 100 }, + ]); + expect(findAssetForPlatform(macosRelease, "darwin-arm64")?.name).toBe("opencode-macos-arm64"); + expect(findAssetForPlatform(macosRelease, "darwin-x64")?.name).toBe("opencode-mac-x64"); + }); +}); + +describe("resolveManagedRuntimeDir", () => { + it("resolves and creates the runtime directory", () => + Effect.gen(function* () { + const dir = yield* resolveManagedRuntimeDir; + expect(dir).toContain("runtime"); + const fileSystem = yield* FileSystem.FileSystem; + const exists = yield* fileSystem.exists(dir); + expect(exists).toBe(true); + }).pipe(Effect.provide(NodeServices.layer))); +}); + +describe("computeFileSha256", () => { + it("computes deterministic SHA-256 for content", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-managed-runtime-sha-", + }); + const filePath = path.join(tempDir, "test-binary"); + yield* fs.writeFile(filePath, MOCK_BINARY_CONTENT); + const hash = yield* computeFileSha256(filePath); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); +}); + +describe("verifyManagedRuntimeBinary", () => { + it("reports non-existent binary as invalid", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const result = yield* verifyManagedRuntimeBinary( + "abc123", + path.join("/nonexistent/path/opencode"), + ); + expect(result.exists).toBe(false); + expect(result.valid).toBe(false); + }).pipe(Effect.provide(NodeServices.layer))); + + it("verifies SHA-256 of existing binary", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-managed-runtime-verify-", + }); + const binaryPath = path.join(tempDir, "opencode"); + yield* fs.writeFile(binaryPath, MOCK_BINARY_CONTENT); + const hash = yield* computeFileSha256(binaryPath); + const validResult = yield* verifyManagedRuntimeBinary(hash, binaryPath); + const invalidResult = yield* verifyManagedRuntimeBinary("wronghash", binaryPath); + expect(validResult.valid).toBe(true); + expect(validResult.sha256).toBe(hash); + expect(invalidResult.valid).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("treats existing binary without expected hash as valid", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-managed-runtime-nohash-", + }); + const binaryPath = path.join(tempDir, "opencode"); + yield* fs.writeFile(binaryPath, MOCK_BINARY_CONTENT); + const result = yield* verifyManagedRuntimeBinary(undefined, binaryPath); + expect(result.exists).toBe(true); + expect(result.valid).toBe(true); + expect(result.sha256).toBeTruthy(); + expect(result.expectedSha256).toBeNull(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); +}); diff --git a/apps/server/src/provider/managedRuntimeDownload.ts b/apps/server/src/provider/managedRuntimeDownload.ts new file mode 100644 index 000000000..89082bbad --- /dev/null +++ b/apps/server/src/provider/managedRuntimeDownload.ts @@ -0,0 +1,352 @@ +import type { GitHubRelease, GitHubReleaseAsset, ManagedRuntimePlatform } from "@jcode/contracts"; +import { Data, Effect, FileSystem, Path, Schema } from "effect"; +import * as Crypto from "node:crypto"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +export class ManagedRuntimeDownloadError extends Data.TaggedError("ManagedRuntimeDownloadError")<{ + readonly stage: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- + +export const detectManagedRuntimePlatform = ( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): ManagedRuntimePlatform => { + if (platform === "win32") return "win-x64"; + if (platform === "linux") return "linux-x64"; + if (platform === "darwin" && arch === "arm64") return "darwin-arm64"; + if (platform === "darwin") return "darwin-x64"; + return "linux-x64"; +}; + +// --------------------------------------------------------------------------- +// Managed runtime directory +// --------------------------------------------------------------------------- + +export const resolveManagedRuntimeDir = Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const platform = process.platform; + const home = process.env["HOME"] ?? process.env["USERPROFILE"] ?? ""; + + let dir: string; + if (platform === "win32") { + const localAppData = process.env["LOCALAPPDATA"] ?? path.join(home, "AppData", "Local"); + dir = path.join(localAppData, "JCode", "runtime"); + } else if (platform === "darwin") { + dir = path.join(home, "Library", "Application Support", "JCode", "runtime"); + } else { + const xdgData = process.env["XDG_DATA_HOME"] ?? path.join(home, ".local", "share"); + dir = path.join(xdgData, "jcode", "runtime"); + } + + yield* fs + .makeDirectory(dir, { recursive: true }) + .pipe( + Effect.catchTag("PlatformError", (err) => + err.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(err), + ), + ); + + return dir; +}); + +// --------------------------------------------------------------------------- +// Asset name matching +// --------------------------------------------------------------------------- + +const PLATFORM_ASSET_PATTERNS: Record< + ManagedRuntimePlatform, + ReadonlyArray<(name: string) => boolean> +> = { + "win-x64": [ + (name) => /windows/i.test(name) && /(x86_64|x64)/i.test(name), + (name) => /windows/i.test(name) && name.endsWith(".exe"), + ], + "linux-x64": [ + (name) => /linux/i.test(name) && /(x86_64|x64)/i.test(name), + (name) => /linux/i.test(name) && !/arm64|aarch64/i.test(name), + ], + "darwin-arm64": [(name) => /darwin|macos|mac/i.test(name) && /(arm64|aarch64)/i.test(name)], + "darwin-x64": [ + (name) => /darwin|macos|mac/i.test(name) && /(x86_64|x64)/i.test(name), + (name) => /darwin|macos|mac/i.test(name) && !/arm64|aarch64/i.test(name), + ], +}; + +export const findAssetForPlatform = ( + release: GitHubRelease, + targetPlatform: ManagedRuntimePlatform, +): GitHubReleaseAsset | null => { + const patterns = PLATFORM_ASSET_PATTERNS[targetPlatform]; + for (const asset of release.assets) { + for (const matches of patterns) { + if (matches(asset.name)) return asset; + } + } + return null; +}; + +// --------------------------------------------------------------------------- +// GitHub release fetch +// --------------------------------------------------------------------------- + +const GITHUB_RELEASES_URL = "https://api.github.com/repos/anomalyco/opencode/releases/latest"; + +const GitHubReleaseApiResponse = Schema.Struct({ + tag_name: Schema.String, + name: Schema.optional(Schema.String), + assets: Schema.Array( + Schema.Struct({ + name: Schema.String, + browser_download_url: Schema.String, + size: Schema.Number, + }), + ), +}); + +export const fetchLatestOpenCodeRelease = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + + const response = yield* HttpClientRequest.get(GITHUB_RELEASES_URL).pipe( + HttpClientRequest.setHeader("Accept", "application/vnd.github+json"), + HttpClientRequest.setHeader("User-Agent", "JCode-ManagedRuntime"), + client.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "fetch", + message: `Failed to fetch latest OpenCode release: ${String(err)}`, + cause: err, + }), + ), + ); + + const rawBody = yield* response.json.pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "fetch", + message: `Failed to parse GitHub release response: ${String(err)}`, + cause: err, + }), + ), + ); + + const parsed = yield* Schema.decodeUnknownEffect(GitHubReleaseApiResponse)(rawBody).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "fetch", + message: `GitHub release response schema mismatch: ${String(err)}`, + cause: err, + }), + ), + ); + + const mappedAssets: ReadonlyArray = parsed.assets.map((asset) => ({ + name: asset.name, + browserDownloadUrl: asset.browser_download_url, + size: asset.size, + })); + + return { + tagName: parsed.tag_name, + name: parsed.name ?? undefined, + assets: mappedAssets, + } satisfies GitHubRelease; +}); + +// --------------------------------------------------------------------------- +// SHA-256 helpers +// --------------------------------------------------------------------------- + +export const computeFileSha256 = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const fileBytes = yield* fs.readFile(filePath).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "verify", + message: `Failed to read file for SHA-256: ${String(err)}`, + cause: err, + }), + ), + ); + const hash = Crypto.createHash("sha256").update(fileBytes).digest("hex"); + return hash; + }); + +// --------------------------------------------------------------------------- +// Download pipeline +// --------------------------------------------------------------------------- + +export const downloadManagedRuntime = Effect.gen(function* () { + const platform = detectManagedRuntimePlatform(); + const release = yield* fetchLatestOpenCodeRelease; + const asset = findAssetForPlatform(release, platform); + + if (!asset) { + yield* Effect.fail( + new ManagedRuntimeDownloadError({ + stage: "resolve-asset", + message: `No matching asset found for platform "${platform}" in release ${release.tagName}`, + }), + ); + return undefined as never; + } + + const runtimeDir = yield* resolveManagedRuntimeDir; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const binaryName = platform === "win-x64" ? "opencode.exe" : "opencode"; + const finalPath = path.join(runtimeDir, binaryName); + const tempPath = `${finalPath}.download`; + + const client = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.get(asset.browserDownloadUrl).pipe( + client.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "download", + message: `Failed to download binary: ${String(err)}`, + cause: err, + }), + ), + ); + + const bodyBytes = yield* response.arrayBuffer.pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "download", + message: `Failed to read download body: ${String(err)}`, + cause: err, + }), + ), + ); + + yield* fs.writeFile(tempPath, bodyBytes).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "download", + message: `Failed to write temp binary: ${String(err)}`, + cause: err, + }), + ), + ); + + const actualHash = yield* computeFileSha256(tempPath); + + if (asset.digest && asset.digest !== actualHash) { + yield* fs.remove(tempPath).pipe(Effect.orDie); + yield* Effect.fail( + new ManagedRuntimeDownloadError({ + stage: "verify", + message: `SHA-256 mismatch: expected ${asset.digest}, got ${actualHash}`, + }), + ); + return undefined as never; + } + + const existingExists = yield* fs.exists(finalPath).pipe(Effect.orDie); + if (existingExists) { + yield* fs.remove(finalPath).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "replace", + message: `Failed to remove existing binary: ${String(err)}`, + cause: err, + }), + ), + ); + } + + yield* fs.rename(tempPath, finalPath).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "install", + message: `Failed to move binary to final location: ${String(err)}`, + cause: err, + }), + ), + ); + + if (process.platform !== "win32") { + yield* fs.chmod(finalPath, 0o755).pipe( + Effect.mapError( + (err) => + new ManagedRuntimeDownloadError({ + stage: "install", + message: `Failed to set executable permission: ${String(err)}`, + cause: err, + }), + ), + ); + } + + return { binaryPath: finalPath, version: release.tagName, sha256: actualHash }; +}); + +// --------------------------------------------------------------------------- +// Binary verification +// --------------------------------------------------------------------------- + +export interface ManagedRuntimeBinaryValidation { + readonly exists: boolean; + readonly sha256: string | null; + readonly expectedSha256: string | null; + readonly valid: boolean; +} + +export const verifyManagedRuntimeBinary = (expectedSha256?: string, binaryPath?: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const runtimeDir = yield* resolveManagedRuntimeDir; + + const platform = detectManagedRuntimePlatform(); + const binaryName = platform === "win-x64" ? "opencode.exe" : "opencode"; + const resolvedPath = binaryPath ?? path.join(runtimeDir, binaryName); + + const exists = yield* fs.exists(resolvedPath).pipe(Effect.orDie); + + if (!exists) { + return { + exists: false, + sha256: null, + expectedSha256: expectedSha256 ?? null, + valid: false, + } satisfies ManagedRuntimeBinaryValidation; + } + + const actualHash = yield* computeFileSha256(resolvedPath); + const expected = expectedSha256 ?? null; + const valid = expected !== null ? actualHash === expected : true; + + return { + exists: true, + sha256: actualHash, + expectedSha256: expected, + valid, + } satisfies ManagedRuntimeBinaryValidation; + }); diff --git a/apps/server/src/provider/providerCredentialScan.test.ts b/apps/server/src/provider/providerCredentialScan.test.ts new file mode 100644 index 000000000..8a4682d4d --- /dev/null +++ b/apps/server/src/provider/providerCredentialScan.test.ts @@ -0,0 +1,279 @@ +import type { ProviderCredentialInfo } from "@jcode/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Path } from "effect"; +import { describe, it, assert } from "@effect/vitest"; + +import { + checkEnvVarCredentials, + deriveStatus, + PROVIDER_CREDENTIAL_SPECS, + resolveBinaryPath, + scanAllProviders, +} from "./providerCredentialScan"; + +const makeEmptyEnv = (): NodeJS.ProcessEnv => ({}); + +const makeEnvWith = (entries: Record): NodeJS.ProcessEnv => ({ ...entries }); + +describe("checkEnvVarCredentials", () => { + it("reports found when env var is set and non-empty", () => { + const env = makeEnvWith({ ANTHROPIC_API_KEY: "sk-test-123" }); + const result = checkEnvVarCredentials(["ANTHROPIC_API_KEY"], env); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0]!.found, true); + assert.strictEqual(result[0]!.key, "ANTHROPIC_API_KEY"); + assert.strictEqual(result[0]!.source, "env-var"); + }); + + it("reports not-found when env var is missing", () => { + const result = checkEnvVarCredentials(["OPENAI_API_KEY"], makeEmptyEnv()); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0]!.found, false); + }); + + it("reports not-found when env var is empty string", () => { + const env = makeEnvWith({ GOOGLE_API_KEY: "" }); + const result = checkEnvVarCredentials(["GOOGLE_API_KEY"], env); + assert.strictEqual(result[0]!.found, false); + }); + + it("checks multiple env vars independently", () => { + const env = makeEnvWith({ GOOGLE_API_KEY: "AIza..." }); + const result = checkEnvVarCredentials(["GOOGLE_API_KEY", "GEMINI_API_KEY"], env); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0]!.found, true); + assert.strictEqual(result[1]!.found, false); + }); +}); + +describe("deriveStatus", () => { + it("returns ready when credentials and binary both present", () => { + assert.strictEqual(deriveStatus(true, true), "ready"); + }); + + it("returns needs-config when binary present but no credentials", () => { + assert.strictEqual(deriveStatus(false, true), "needs-config"); + }); + + it("returns not-installed when no binary present", () => { + assert.strictEqual(deriveStatus(true, false), "not-installed"); + assert.strictEqual(deriveStatus(false, false), "not-installed"); + }); +}); + +describe("resolveBinaryPath", () => { + it("finds binary in PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-test-", + }); + yield* fileSystem.writeFile( + `${tempDir}/codex`, + new TextEncoder().encode("#!/bin/sh\necho ok"), + ); + + const result = yield* resolveBinaryPath("codex", { + env: { PATH: tempDir }, + platform: "linux", + }); + + assert.strictEqual(result.found, true); + if (result.found) { + assert.strictEqual(result.path, `${tempDir}/codex`); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("reports not found when binary absent from PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-empty-", + }); + + const result = yield* resolveBinaryPath("codex", { + env: { PATH: tempDir }, + platform: "linux", + }); + + assert.strictEqual(result.found, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("handles empty PATH", () => + Effect.gen(function* () { + const result = yield* resolveBinaryPath("codex", { + env: {}, + platform: "linux", + }); + + assert.strictEqual(result.found, false); + }).pipe(Effect.provide(NodeServices.layer))); +}); + +describe("scanAllProviders", () => { + it("returns all providers as not-installed with no env and empty PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-none-", + }); + + const result = yield* scanAllProviders({ + env: { HOME: tempDir, PATH: tempDir }, + platform: "linux", + homeDir: tempDir, + }); + + assert.strictEqual(result.providers.length, 7); + for (const p of result.providers) { + assert.strictEqual(p.hasBinary, false, `${p.provider} should have no binary`); + assert.strictEqual(p.status, "not-installed", `${p.provider} should be not-installed`); + } + assert.ok(result.scannedAt.length > 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("detects codex as not-installed with API key but no binary", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-key-", + }); + + const result = yield* scanAllProviders({ + env: { + HOME: tempDir, + PATH: tempDir, + OPENAI_API_KEY: "sk-test-key", + }, + platform: "linux", + homeDir: tempDir, + }); + + const codex = result.providers.find((p) => p.provider === "codex")!; + assert.strictEqual(codex.status, "not-installed"); + assert.strictEqual(codex.hasCredentials, true); + assert.strictEqual(codex.hasBinary, false); + const keyCred = codex.credentials.find( + (c: ProviderCredentialInfo) => c.key === "OPENAI_API_KEY", + )!; + assert.strictEqual(keyCred.found, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("detects claudeAgent as ready with API key and binary in PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-ready-", + }); + yield* fileSystem.writeFile( + `${tempDir}/claude`, + new TextEncoder().encode("#!/bin/sh\necho ok"), + ); + + const result = yield* scanAllProviders({ + env: { + HOME: tempDir, + PATH: tempDir, + ANTHROPIC_API_KEY: "sk-ant-test", + }, + platform: "linux", + homeDir: tempDir, + }); + + const claude = result.providers.find((p) => p.provider === "claudeAgent")!; + assert.strictEqual(claude.status, "ready"); + assert.strictEqual(claude.hasCredentials, true); + assert.strictEqual(claude.hasBinary, true); + assert.ok(claude.binaryPath!.includes("claude")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("detects opencode config dir credentials", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-config-", + }); + const path = yield* Path.Path; + const configDir = path.join(tempDir, ".config", "opencode"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + + yield* fileSystem.writeFile( + `${tempDir}/opencode`, + new TextEncoder().encode("#!/bin/sh\necho ok"), + ); + + const result = yield* scanAllProviders({ + env: { HOME: tempDir, PATH: tempDir }, + platform: "linux", + homeDir: tempDir, + }); + + const opencode = result.providers.find((p) => p.provider === "opencode")!; + assert.strictEqual(opencode.hasCredentials, true); + assert.strictEqual(opencode.hasBinary, true); + assert.strictEqual(opencode.status, "ready"); + const configCred = opencode.credentials.find( + (c: ProviderCredentialInfo) => c.source === "config-dir", + )!; + assert.strictEqual(configCred.found, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); + + it("handles mixed provider states across all providers", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "jcode-scan-mixed-", + }); + + yield* fileSystem.writeFile( + `${tempDir}/gemini`, + new TextEncoder().encode("#!/bin/sh\necho ok"), + ); + + const result = yield* scanAllProviders({ + env: { + HOME: tempDir, + PATH: tempDir, + ANTHROPIC_API_KEY: "sk-ant-test", + GOOGLE_API_KEY: "AIza-test", + }, + platform: "linux", + homeDir: tempDir, + }); + + const claude = result.providers.find((p) => p.provider === "claudeAgent")!; + assert.strictEqual(claude.status, "not-installed"); + assert.strictEqual(claude.hasCredentials, true); + assert.strictEqual(claude.hasBinary, false); + + const gemini = result.providers.find((p) => p.provider === "gemini")!; + assert.strictEqual(gemini.status, "ready"); + assert.strictEqual(gemini.hasCredentials, true); + assert.strictEqual(gemini.hasBinary, true); + + const codex = result.providers.find((p) => p.provider === "codex")!; + assert.strictEqual(codex.status, "not-installed"); + assert.strictEqual(codex.hasCredentials, false); + assert.strictEqual(codex.hasBinary, false); + + const cursor = result.providers.find((p) => p.provider === "cursor")!; + assert.strictEqual(cursor.status, "not-installed"); + assert.strictEqual(cursor.hasCredentials, false); + assert.strictEqual(cursor.hasBinary, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer))); +}); + +describe("PROVIDER_CREDENTIAL_SPECS", () => { + it("covers all 7 providers", () => { + const providers = PROVIDER_CREDENTIAL_SPECS.map((s) => s.provider); + assert.deepStrictEqual(providers.sort(), [ + "claudeAgent", + "codex", + "cursor", + "gemini", + "kilo", + "opencode", + "pi", + ]); + }); +}); diff --git a/apps/server/src/provider/providerCredentialScan.ts b/apps/server/src/provider/providerCredentialScan.ts new file mode 100644 index 000000000..c37be262b --- /dev/null +++ b/apps/server/src/provider/providerCredentialScan.ts @@ -0,0 +1,205 @@ +import type { ProviderDiscoveryKind } from "@jcode/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import type { + ProviderCredentialInfo, + ProviderScanAllResult, + ProviderScanResult, + ProviderScanStatus, +} from "@jcode/contracts"; + +interface ProviderCredentialSpec { + readonly provider: ProviderDiscoveryKind; + readonly binaryName: string; + readonly envVars: ReadonlyArray; + readonly configDirs: ReadonlyArray; +} + +const PROVIDER_CREDENTIAL_SPECS: ReadonlyArray = [ + { + provider: "codex", + binaryName: "codex", + envVars: ["OPENAI_API_KEY"], + configDirs: [], + }, + { + provider: "claudeAgent", + binaryName: "claude", + envVars: ["ANTHROPIC_API_KEY"], + configDirs: [".claude"], + }, + { + provider: "cursor", + binaryName: "agent", + envVars: [], + configDirs: [], + }, + { + provider: "gemini", + binaryName: "gemini", + envVars: ["GOOGLE_API_KEY", "GEMINI_API_KEY"], + configDirs: [], + }, + { + provider: "kilo", + binaryName: "kilo", + envVars: ["KILO_API_KEY"], + configDirs: [], + }, + { + provider: "opencode", + binaryName: "opencode", + envVars: [], + configDirs: [".config/opencode"], + }, + { + provider: "pi", + binaryName: "pi", + envVars: [], + configDirs: [], + }, +]; + +const WINDOWS_EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat"] as const; + +interface ScanProviderOptions { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; +} + +const checkEnvVarCredentials = ( + envVars: ReadonlyArray, + env: NodeJS.ProcessEnv, +): ReadonlyArray => + envVars.map((key) => ({ + source: "env-var" as const, + key, + found: typeof env[key] === "string" && env[key]!.length > 0, + })); + +const checkConfigDirCredentials = ( + configDirs: ReadonlyArray, + homeDir: string, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + if (configDirs.length === 0) { + return []; + } + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + return yield* Effect.all( + configDirs.map((dir) => + Effect.gen(function* () { + const fullPath = path.join(homeDir, dir); + const exists = yield* fileSystem.exists(fullPath).pipe(Effect.orElseSucceed(() => false)); + return { + source: "config-dir" as const, + key: `~/${dir}`, + found: exists, + } satisfies ProviderCredentialInfo; + }), + ), + { concurrency: "unbounded" }, + ); + }); + +const resolveBinaryPath = ( + binaryName: string, + options: ScanProviderOptions, +): Effect.Effect< + | { readonly found: true; readonly path: string } + | { readonly found: false; readonly path: undefined }, + never, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const platform = options.platform ?? process.platform; + const pathEnv = (options.env?.PATH ?? process.env.PATH ?? "").trim(); + const separator = platform === "win32" ? ";" : ":"; + const pathEntries = pathEnv.split(separator).filter(Boolean); + const executableCandidates = + platform === "win32" + ? WINDOWS_EXECUTABLE_EXTENSIONS.map((ext) => `${binaryName}${ext}`) + : [binaryName]; + + for (const entry of pathEntries) { + for (const candidate of executableCandidates) { + const fullPath = `${entry}/${candidate}`; + const exists = yield* fileSystem.exists(fullPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return { found: true, path: fullPath } as const; + } + } + } + + return { found: false, path: undefined } as const; + }); + +const deriveStatus = (hasCredentials: boolean, hasBinary: boolean): ProviderScanStatus => { + if (hasCredentials && hasBinary) { + return "ready"; + } + if (hasBinary) { + return "needs-config"; + } + return "not-installed"; +}; + +const scanSingleProvider = ( + spec: ProviderCredentialSpec, + options: ScanProviderOptions, +): Effect.Effect => + Effect.gen(function* () { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; + + const envCredentials = checkEnvVarCredentials(spec.envVars, env); + const configCredentials = yield* checkConfigDirCredentials(spec.configDirs, homeDir); + const allCredentials = [...envCredentials, ...configCredentials]; + const hasCredentials = allCredentials.some((c) => c.found); + + const binaryResult = yield* resolveBinaryPath(spec.binaryName, options); + const hasBinary = binaryResult.found; + const status = deriveStatus(hasCredentials, hasBinary); + + return { + provider: spec.provider, + status, + hasCredentials, + credentials: allCredentials, + hasBinary, + binaryPath: binaryResult.found ? binaryResult.path : undefined, + version: undefined, + } satisfies ProviderScanResult; + }); + +export const scanAllProviders = ( + options?: ScanProviderOptions, +): Effect.Effect => + Effect.gen(function* () { + const resolvedOptions: ScanProviderOptions = options ?? {}; + const providers = yield* Effect.all( + PROVIDER_CREDENTIAL_SPECS.map((spec) => scanSingleProvider(spec, resolvedOptions)), + { concurrency: "unbounded" }, + ); + + return { + providers, + scannedAt: DateTime.formatIso(yield* DateTime.now), + } satisfies ProviderScanAllResult; + }); + +export { + PROVIDER_CREDENTIAL_SPECS, + checkEnvVarCredentials, + checkConfigDirCredentials, + resolveBinaryPath, + deriveStatus, +}; +export type { ProviderCredentialSpec, ScanProviderOptions }; diff --git a/docs/adr/0005-backend-owned-managed-runtime-sidecar.md b/docs/adr/0005-backend-owned-managed-runtime-sidecar.md new file mode 100644 index 000000000..cf013817c --- /dev/null +++ b/docs/adr/0005-backend-owned-managed-runtime-sidecar.md @@ -0,0 +1,95 @@ +# ADR 0005: Backend-Owned Managed Runtime Sidecar + +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Status | Accepted | +| Type | Architecture decision record | +| Owner | Engineering | +| Audience | Maintainers, reviewers, and automation agents | +| Scope | Runtime process ownership for managed provider sidecars in JCode desktop mode | +| Canonical path | `docs/adr/0005-backend-owned-managed-runtime-sidecar.md` | +| Last reviewed | 2026-06-07 | +| Review cadence | Event-driven; review if JCode adds a remote/hosted mode or changes provider runtime boundaries | +| Source of truth | `apps/server/src/provider/opencodeRuntime.ts`, `apps/server/src/provider/openCodeRuntimeHealth.ts`, `packages/contracts/src/providerDiscovery.ts` | +| Verification | Managed runtime spawns via server-side Effect ChildProcess; health checks gate desktop readiness; managed profiles are OpenCode-specific in contracts schema | + +## Context + +JCode packages a web UI, local server, and desktop shell. The Windows turnkey release needs to manage an OpenCode sidecar process: install, verify, start, stop, and repair. The PRD initially recommended that the desktop shell (Electron main process) should own the sidecar lifecycle. However, the codebase already implements server-side OpenCode process spawning, and moving that responsibility into the desktop layer would duplicate or fracture existing infrastructure. + +## Decision + +The backend server owns the managed provider sidecar lifecycle. The desktop shell owns the updater and the first-run trigger UI. + +This aligns with existing code. `opencodeRuntime.ts` already has `startOpenCodeServerProcess` using Effect's `ChildProcess` from `effect/unstable/process` (not raw Node `child_process`). This is architecturally significant: Effect's process spawner integrates with the Effect runtime's fiber cancellation and structured concurrency semantics. The server also has runtime profiles with `managed`, `external`, and `remote` modes, health checks in `openCodeRuntimeHealth.ts` (459 lines, exports `checkOpenCodeRuntimeHealth` with states: `unknown`, `checking`, `healthy`, `degraded`, `unreachable`, `misconfigured`), and provider session dispatch. The desktop shell's role is limited to: triggering setup on first launch, rendering UI, handling Electron lifecycle events, and managing JCode application updates. + +### Health Gate and Version Pairing (PRD Decision 10) + +Managed runtimes use a best-effort version pairing strategy gated by health status: + +1. On startup, check the existing managed binary's health via `checkOpenCodeRuntimeHealth`. +2. If the binary reports `healthy` or `degraded`, use it regardless of exact version match. +3. If the binary reports `unreachable`, `misconfigured`, or `unknown` after timeout, attempt a repair: re-download the expected version and re-verify. +4. The health states `healthy` and `degraded` are non-blocking for desktop startup. `unreachable`, `misconfigured`, and `unknown` (post-timeout) block the desktop at the splash screen and surface a retry/recovery prompt. + +### Post-Install Download Pipeline (PRD Decision 7) + +Managed runtime binaries are not bundled with the installer. They are downloaded on first launch only when: + +- The user selects OpenCode as their provider, AND +- No existing OpenCode binary is detected on PATH or at the managed install path. + +The download pipeline includes: + +1. Disk space check before download. +2. Fetch from the configured release URL with progress reporting to the splash UI. +3. SHA-256 checksum verification against the published manifest. +4. Network failure: surface error with retry option on the splash screen. Do not silently fail or retry indefinitely. + +### Fresh Sidecar Password Per Startup (PRD Decision 11) + +The managed sidecar authentication token is generated fresh on each JCode launch and held in memory only. No OS credential store integration. The sidecar binds to loopback only and its lifecycle is tied to JCode's process, so persistent passwords are unnecessary. This means: + +1. On server startup, generate a random token and pass it to the sidecar via the spawn command or environment. +2. Store the token in server-side memory for the session duration. +3. On server shutdown, the token is discarded. Next startup generates a new one. + +### Native Windows Runtime First (PRD Decision 3) + +The managed runtime targets native Windows OpenCode as the default. WSL is a fallback only if native Windows runtime health fails real-world testing. This ADR's scope is the lifecycle ownership regardless of platform; the native-vs-WSL choice is a deployment detail within the download pipeline. + +### JCode Owns The Happy Path (PRD Decision 4) + +JCode provisions, verifies, starts, and repairs the managed runtime without asking the user to manually install or configure OpenCode. The user's interaction is limited to: picking a provider in the first-run wizard (ADR 0006) and seeing a splash screen while the backend bootstraps. Error states surface actionable recovery options, not instructions to "install OpenCode manually." + +### configMode Default (PRD Decision 13) + +`startOpenCodeServerProcess` currently defaults `configMode` to `"inherit"` (line 805 of `opencodeRuntime.ts`). For managed runtimes on clean Windows installs, the server must pass `configMode: "generated"` so the managed OpenCode instance gets an isolated configuration directory (`opencodeConfigDir`, `opencodeDataDir` from the runtime profile). The `"inherit"` default is correct for users who already have an OpenCode configuration and connect via `external` mode. The managed runtime profile creation code (Slice 6) must explicitly set `configMode: "generated"`. + +## Consequences + +- Runtime management code lives in the server, not the desktop shell. New files for install, verify, and repair extend `apps/server/src/provider/`. +- CLI and server-only modes can use the same runtime lifecycle without depending on Electron. +- Desktop needs a pre-readiness splash state that shows a setup message while the backend bootstraps the runtime. If bootstrapping fails (download failure, binary won't start, health check times out), the desktop must surface the error and offer retry/recovery actions -- it must not remain on the splash indefinitely. +- No new IPC bridge is needed between desktop and server for runtime management actions. +- The desktop process does not directly spawn or manage the OpenCode binary. +- Runtime profile architecture is preserved for OpenCode. `OpenCodeRuntimeProfile.provider` is `Schema.Literal("opencode")` in contracts -- managed profiles are OpenCode-specific. Other providers do not get managed runtime profiles in v1. A future ADR is needed if multi-provider managed runtimes are added. +- Health status gates desktop readiness: `healthy`/`degraded` allow proceed; `unreachable`/`misconfigured`/`unknown`-post-timeout block and surface recovery UI. +- The download pipeline runs entirely server-side. The desktop only receives progress events for the splash UI. +- Managed sidecar passwords are memory-only, generated fresh per startup. No credential store integration, no password files on disk. +- The initial implementation targets native Windows OpenCode only. WSL support is a conditional future addition, not a v1 requirement. + +## Implementing Issues + +| Slice | Issue | Title | Implements | +|-------|-------|-------|------------| +| 4 | #72 | Managed OpenCode download and verify | Post-Install Download Pipeline (D7) | +| 5 | #83 | Backend-owned managed OpenCode sidecar lifecycle | Core decision (D6), Fresh Password (D11), Health Gate (D10) | +| 6 | #81 | Managed runtime profile auto-creation | configMode Default (D13), Runtime Profiles Visible (D5) | +| 7 | #85 | Runtime health, repair, and diagnostics export | Health Gate repair flow (D10), Native First (D3) | + +Slices 4 and 5 are the primary implementation. Slice 6 extends the profile model. Slice 7 adds the repair loop. + +## Alternatives Considered + +**Desktop owns sidecar.** Would require moving process spawning code out of the server or duplicating it, adding an IPC bridge for runtime actions, and creating a mode split between desktop and CLI/server-only operation. Rejected because the server already has all the necessary infrastructure, including Effect-structured process management that the Electron main process would not naturally use. diff --git a/docs/adr/0006-provider-agnostic-first-run-wizard.md b/docs/adr/0006-provider-agnostic-first-run-wizard.md new file mode 100644 index 000000000..80467fc0c --- /dev/null +++ b/docs/adr/0006-provider-agnostic-first-run-wizard.md @@ -0,0 +1,85 @@ +# ADR 0006: Provider-Agnostic First-Run Wizard + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Status | Accepted | +| Type | Architecture decision record | +| Owner | Engineering | +| Audience | Maintainers, reviewers, and automation agents | +| Scope | First-run setup experience for JCode desktop, covering provider detection, selection, and runtime provisioning | +| Canonical path | `docs/adr/0006-provider-agnostic-first-run-wizard.md` | +| Last reviewed | 2026-06-07 | +| Review cadence | Event-driven; review if JCode changes provider abstraction or adds hosted provider support | +| Source of truth | `apps/web/src/routes/_chat.settings.tsx`, `packages/contracts/src/providerDiscovery.ts`, `apps/server/src/provider/providerMaintenance.ts`, `apps/server/src/provider/opencodeRuntime.ts` | +| Verification | Wizard detects all 7 ProviderDiscoveryKind providers; credential-first detection checks API keys and config dirs before PATH binaries; OpenCode is the only provider with managed download in v1 | + +## Context + +The Windows turnkey release PRD initially described a first-run wizard focused on installing and configuring OpenCode. However, JCode supports seven providers (`codex`, `claudeAgent`, `cursor`, `gemini`, `kilo`, `opencode`, `pi` — from `ProviderDiscoveryKind` in `providerDiscovery.ts`), and the codebase default provider is `codex` (`DEFAULT_PROVIDER_KIND="codex"` in `orchestration.ts` line 76), not opencode. Forcing an OpenCode install on every user, including those who already have Codex or Claude configured, creates unnecessary friction and ignores existing provider infrastructure. + +## Decision + +The first-run wizard is provider-agnostic. It detects installed providers, presents the user with a choice of which agent to use, and only downloads or installs a managed runtime when the user selects a provider that requires one and does not already have it installed. For the MVP, only OpenCode has a managed download and install flow. Other providers use their existing connection mechanisms (API key paste, binary on PATH). + +### Credential-First Detection Strategy (PRD Decision 9) + +Provider detection uses a credential-first, binary-second strategy. This is architecturally important because credentials alone are sufficient for API-based providers (codex, claudeAgent, gemini, cursor), while binary-only detection misses users who have API keys configured but no local binary: + +1. **Check provider credentials**: API keys in environment variables, config directories (e.g., OpenCode's `~/.config/opencode/`), and stored credentials. +2. **Check provider binaries on PATH**: Use existing `providerMaintenance.ts` binary discovery which handles Windows `.exe/.cmd/.bat` extensions. +3. **Present three states per provider**: "ready" (credentials and/or binary present), "needs credentials" (binary only), "needs setup" (neither). + +### Detection Source Files + +- `providerMaintenance.ts`: Binary detection, install source detection (`npm`, `bun`, `cargo`, `system`), version checking. +- `providerDiscovery.ts`: `ProviderDiscoveryKind` enum (7 providers), `OpenCodeRuntimeProfile` schema, `ProviderRuntimeMode` (`managed` | `external` | `remote`). +- `opencodeRuntime.ts`: `startOpenCodeServerProcess` for managed runtime spawn; relevant because the wizard triggers this for OpenCode managed installs. + +### Wizard Flow + +1. **"Which coding agent do you use?"** with auto-detected provider status indicators. +2. If the user picks a **ready** provider, skip to credentials verification. +3. If the user picks a provider **needing setup**, offer download and install (OpenCode only for MVP). +4. Provider credential connection (API key input or confirmation of existing credentials). +5. Project folder picker. +6. Ready. + +### Clean Machine Handling + +On a clean Windows machine where no providers are detected, the wizard must: + +1. Show all providers with "needs setup" status. +2. Highlight OpenCode as the only provider offering a managed install ("Install for me"). +3. For other providers, show a brief instruction ("Install X and restart JCode" or "Enter your API key"). +4. If the user picks OpenCode, the managed download pipeline from ADR 0005 activates. + +### OpenCode-Only Managed Scope + +`OpenCodeRuntimeProfile.provider` is `Schema.Literal("opencode")` in contracts. Only OpenCode gets a managed runtime profile. Other providers use their existing binary-on-PATH or API-key-only connection modes. Adding managed support for other providers would require: + +1. Extending the contracts schema with provider-specific runtime profile types. +2. Adding download URLs and verification for each provider's distribution. +3. This is explicitly deferred post-MVP. + +## Consequences + +- The wizard must not assume OpenCode is the default or required provider. +- Provider detection logic extends existing `providerMaintenance.ts` binary discovery with a credential-first layer. +- Only OpenCode needs a managed download and install pipeline in v1. Other providers' install flows are deferred to avoid schema changes and distribution complexity. +- The settings UI should eventually offer "Install runtime" per provider, but this is post-MVP. +- The wizard must handle the case where no providers are detected (clean machine) gracefully by showing all options with setup instructions. +- Provider selection is stored in settings and can be changed later. +- Credential-first detection means the wizard can surface "ready" providers even when no binary is on PATH, which is the common case for API-based providers. + +## Implementing Issues + +| Slice | Issue | Title | Implements | +|-------|-------|-------|------------| +| 2 | #73 | Credential-first provider scanning | Credential-First Detection (D9) | +| 3 | #84 | Provider-agnostic first-run wizard | Core decision (D8), wizard flow | + +Slice 2 builds the detection backend. Slice 3 builds the wizard UI that consumes it. + +## Alternatives Considered + +**OpenCode-only wizard (original PRD).** Would be simpler to build but ignores the multi-provider reality of JCode, creates a worse UX for non-OpenCode users (who would be forced through an unnecessary install), and would need to be redesigned when adding other providers later. The codebase already has the infrastructure to detect 7 providers; not using it would be a waste. diff --git a/docs/adr/README.md b/docs/adr/README.md index c22cdf17a..05b8f803c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,8 @@ | [0002](0002-release-notes-and-latest-package-retention.md) | Accepted | Release notes and latest-package retention | | [0003](0003-settings-native-skill-library.md) | Accepted | Settings-native provider-aware Skill Library | | [0004](0004-project-language-icons.md) | Proposed | Project language icons as metadata | +| [0005](0005-backend-owned-managed-runtime-sidecar.md) | Accepted | Backend-owned managed runtime sidecar | +| [0006](0006-provider-agnostic-first-run-wizard.md) | Accepted | Provider-agnostic first-run wizard | ## When To Add An ADR diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bf0f5b211..e26799c22 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ export * from "./terminal"; export * from "./provider"; export * from "./providerDiscovery"; export * from "./providerRuntime"; +export * from "./providerScan"; export * from "./model"; export * from "./agentMentions"; export * from "./ws"; @@ -17,4 +18,5 @@ export * from "./editor"; export * from "./environment"; export * from "./project"; export * from "./filesystem"; +export * from "./managedRuntime"; export * from "./rpc"; diff --git a/packages/contracts/src/managedRuntime.ts b/packages/contracts/src/managedRuntime.ts new file mode 100644 index 000000000..e617888a8 --- /dev/null +++ b/packages/contracts/src/managedRuntime.ts @@ -0,0 +1,71 @@ +/** + * Managed runtime contract schemas. + * + * Defines types for downloading, verifying, and tracking an OpenCode binary + * managed by JCode in a platform-appropriate runtime directory. + * + * @module managedRuntime + */ + +import { Schema } from "effect"; + +import { NonNegativeInt } from "./baseSchemas"; + +// --------------------------------------------------------------------------- +// Platform identifiers +// --------------------------------------------------------------------------- + +export const ManagedRuntimePlatform = Schema.Literals([ + "win-x64", + "linux-x64", + "darwin-arm64", + "darwin-x64", +]); +export type ManagedRuntimePlatform = typeof ManagedRuntimePlatform.Type; + +// --------------------------------------------------------------------------- +// Download status +// --------------------------------------------------------------------------- + +export const ManagedRuntimeDownloadStatus = Schema.Literals([ + "idle", + "checking", + "downloading", + "verifying", + "complete", + "error", +]); +export type ManagedRuntimeDownloadStatus = typeof ManagedRuntimeDownloadStatus.Type; + +// --------------------------------------------------------------------------- +// Download progress +// --------------------------------------------------------------------------- + +export const ManagedRuntimeDownloadProgress = Schema.Struct({ + status: ManagedRuntimeDownloadStatus, + bytesDownloaded: Schema.optional(NonNegativeInt), + bytesTotal: Schema.optional(NonNegativeInt), + error: Schema.optional(Schema.String), + binaryPath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), +}); +export type ManagedRuntimeDownloadProgress = typeof ManagedRuntimeDownloadProgress.Type; + +// --------------------------------------------------------------------------- +// GitHub release shapes +// --------------------------------------------------------------------------- + +export const GitHubReleaseAsset = Schema.Struct({ + name: Schema.String, + browserDownloadUrl: Schema.String, + size: NonNegativeInt, + digest: Schema.optional(Schema.String), +}); +export type GitHubReleaseAsset = typeof GitHubReleaseAsset.Type; + +export const GitHubRelease = Schema.Struct({ + tagName: Schema.String, + name: Schema.optional(Schema.String), + assets: Schema.Array(GitHubReleaseAsset), +}); +export type GitHubRelease = typeof GitHubRelease.Type; diff --git a/packages/contracts/src/providerDiscovery.ts b/packages/contracts/src/providerDiscovery.ts index 281e633af..f164431b2 100644 --- a/packages/contracts/src/providerDiscovery.ts +++ b/packages/contracts/src/providerDiscovery.ts @@ -7,7 +7,7 @@ import { Schema } from "effect"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas"; import { ProviderOptionDescriptor } from "./model"; -const ProviderDiscoveryKind = Schema.Literals([ +export const ProviderDiscoveryKind = Schema.Literals([ "codex", "claudeAgent", "cursor", @@ -16,6 +16,7 @@ const ProviderDiscoveryKind = Schema.Literals([ "opencode", "pi", ]); +export type ProviderDiscoveryKind = typeof ProviderDiscoveryKind.Type; export const ProviderSkillInterface = Schema.Struct({ displayName: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/providerScan.ts b/packages/contracts/src/providerScan.ts new file mode 100644 index 000000000..799fe9fe8 --- /dev/null +++ b/packages/contracts/src/providerScan.ts @@ -0,0 +1,41 @@ +// FILE: providerScan.ts +// Purpose: Defines credential-first provider scanning contracts shared across web and server. +// Layer: Shared contracts +// Exports: Provider scan schemas and inferred types used by the provider credential scanner. + +import { Schema } from "effect"; +import { ProviderDiscoveryKind } from "./providerDiscovery"; + +export const ProviderScanStatus = Schema.Literals([ + "ready", // has credentials + binary + "needs-config", // has binary but no credentials + "not-installed", // no binary found +]); +export type ProviderScanStatus = typeof ProviderScanStatus.Type; + +export const ProviderCredentialSource = Schema.Literals(["env-var", "config-dir"]); +export type ProviderCredentialSource = typeof ProviderCredentialSource.Type; + +export const ProviderCredentialInfo = Schema.Struct({ + source: ProviderCredentialSource, + key: Schema.String, // e.g. "ANTHROPIC_API_KEY" or "~/.claude/config.json" + found: Schema.Boolean, +}); +export type ProviderCredentialInfo = typeof ProviderCredentialInfo.Type; + +export const ProviderScanResult = Schema.Struct({ + provider: ProviderDiscoveryKind, + status: ProviderScanStatus, + hasCredentials: Schema.Boolean, + credentials: Schema.Array(ProviderCredentialInfo), + hasBinary: Schema.Boolean, + binaryPath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), +}); +export type ProviderScanResult = typeof ProviderScanResult.Type; + +export const ProviderScanAllResult = Schema.Struct({ + providers: Schema.Array(ProviderScanResult), + scannedAt: Schema.String, // ISO 8601 timestamp +}); +export type ProviderScanAllResult = typeof ProviderScanAllResult.Type; diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index ea1e864d1..7a28eeed8 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -73,6 +73,14 @@ describe("createDesktopPlatformBuildConfig", () => { target: ["nsis"], icon: "icon.ico", azureSignOptions: { publisherName: "T3 Tools" }, + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: false, + runAfterFinish: true, + shortcutName: "JCode", + deleteAppDataOnUninstall: true, + }, }); }); }); diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index af98854c0..f8b1219e4 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -79,6 +79,14 @@ export function createDesktopPlatformBuildConfig( target: [input.target], icon: "icon.ico", ...(input.windowsAzureSignOptions ? { azureSignOptions: input.windowsAzureSignOptions } : {}), + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: false, + runAfterFinish: true, + shortcutName: "JCode", + deleteAppDataOnUninstall: true, + }, }, }; } From 153a7fe73496b1c390e22d22378b0a825879380b Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 19:18:19 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat(windows):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?sidecar=20lifecycle,=20first-run=20wizard,=20Scoop/Winget=20man?= =?UTF-8?q?ifests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 (#83): Managed sidecar lifecycle - contracts: ManagedSidecarSnapshot schema with idle/downloading/starting/ready/stopping/error states - server: ManagedSidecarLifecycle service using Effect ServiceMap + Ref state machine - Fresh password per start (crypto.randomBytes, base64url), stored in snapshot only - 16 tests covering state transitions, error handling, password uniqueness Slice 3 (#84): Provider-agnostic first-run wizard - contracts: FirstRunState, FirstRunWizardData, CompleteFirstRunWizardInput schemas - server: detectFirstRunState, getFirstRunWizardData, completeFirstRunWizard, skipFirstRunWizard - Full RPC pipeline: contracts → WS methods → IPC → wsNativeApi → wsRpc handlers - web: FirstRunWizard component placeholder - 5 tests using ServerSettingsService.layerTest() Slice 8 (#78): Scoop + Winget package manifests - Scoop manifest template (packages/scoop/jcode.json) - Winget YAML manifest template (packages/winget/manifests/j/JCode/JCode.yaml) - generate-winget-manifest.ts: YAML generators with validation (hash, version, repo slug) - update-package-manifests.ts: Downloads installer, computes SHA-256, writes manifests - CI: update_package_manifests job in release.yml - 12 tests covering manifest generation, SHA-256 computation, error handling Closes #83, #84, #78 --- .github/workflows/release.yml | 44 +++ .../src/provider/firstRunWizard.test.ts | 61 ++++ apps/server/src/provider/firstRunWizard.ts | 70 ++++ .../provider/managedRuntimeLifecycle.test.ts | 320 ++++++++++++++++++ .../src/provider/managedRuntimeLifecycle.ts | 229 +++++++++++++ apps/server/src/wsRpc.ts | 6 + apps/web/src/components/FirstRunWizard.tsx | 265 +++++++++++++++ apps/web/src/wsNativeApi.ts | 3 + package.json | 2 + packages/contracts/src/firstRunWizard.ts | 70 ++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/ipc.ts | 3 + .../contracts/src/managedRuntimeLifecycle.ts | 54 +++ packages/contracts/src/settings.ts | 5 + packages/contracts/src/ws.ts | 7 + packages/scoop/jcode.json | 32 ++ packages/winget/manifests/j/JCode/JCode.yaml | 54 +++ scripts/generate-winget-manifest.test.ts | 96 ++++++ scripts/generate-winget-manifest.ts | 191 +++++++++++ scripts/update-package-manifests.test.ts | 121 +++++++ scripts/update-package-manifests.ts | 154 +++++++++ 21 files changed, 1789 insertions(+) create mode 100644 apps/server/src/provider/firstRunWizard.test.ts create mode 100644 apps/server/src/provider/firstRunWizard.ts create mode 100644 apps/server/src/provider/managedRuntimeLifecycle.test.ts create mode 100644 apps/server/src/provider/managedRuntimeLifecycle.ts create mode 100644 apps/web/src/components/FirstRunWizard.tsx create mode 100644 packages/contracts/src/firstRunWizard.ts create mode 100644 packages/contracts/src/managedRuntimeLifecycle.ts create mode 100644 packages/scoop/jcode.json create mode 100644 packages/winget/manifests/j/JCode/JCode.yaml create mode 100644 scripts/generate-winget-manifest.test.ts create mode 100644 scripts/generate-winget-manifest.ts create mode 100644 scripts/update-package-manifests.test.ts create mode 100644 scripts/update-package-manifests.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0874bcf3..7947e6b13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,6 +355,50 @@ jobs: GH_TOKEN: ${{ github.token }} run: node scripts/release-retention.ts + update_package_manifests: + name: Update package manager manifests + needs: [preflight, release] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5e8019c62421857d6 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Generate Scoop and Winget manifests + shell: bash + run: | + node scripts/update-package-manifests.ts \ + --version "${{ needs.preflight.outputs.version }}" \ + --release-date "$(date -u +%Y-%m-%d)" \ + --output-dir packaging + + - name: Upload packaging artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: package-manifests + path: | + packaging/scoop/jcode.json + packaging/winget/**/* + if-no-files-found: error + retention-days: 30 + finalize: name: Finalize release if: ${{ vars.JCODE_FINALIZE_RELEASE == '1' }} diff --git a/apps/server/src/provider/firstRunWizard.test.ts b/apps/server/src/provider/firstRunWizard.test.ts new file mode 100644 index 000000000..4c99f7954 --- /dev/null +++ b/apps/server/src/provider/firstRunWizard.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { ServerSettingsService } from "../serverSettings.ts"; + +import { + detectFirstRunState, + getFirstRunWizardData, + completeFirstRunWizard, + skipFirstRunWizard, +} from "./firstRunWizard.ts"; + +const TestLayers = Layer.merge(ServerSettingsService.layerTest(), NodeServices.layer); + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(TestLayers))); + +describe("detectFirstRunState", () => { + it("returns default incomplete state for a fresh install", async () => { + const state = await run(detectFirstRunState()); + expect(state.completed).toBe(false); + expect(state.selectedProvider).toBeUndefined(); + expect(state.completedAt).toBeUndefined(); + }); +}); + +describe("completeFirstRunWizard", () => { + it("marks first-run as completed with a selected provider", async () => { + const state = await run(completeFirstRunWizard({ provider: "codex" })); + expect(state.completed).toBe(true); + expect(state.selectedProvider).toBe("codex"); + expect(state.completedAt).toBeDefined(); + }); + + it("marks first-run as completed without a provider (skip selection)", async () => { + const state = await run(completeFirstRunWizard({})); + expect(state.completed).toBe(true); + expect(state.selectedProvider).toBeUndefined(); + expect(state.completedAt).toBeDefined(); + }); +}); + +describe("skipFirstRunWizard", () => { + it("marks first-run as skipped", async () => { + const state = await run(skipFirstRunWizard()); + expect(state.completed).toBe(true); + expect(state.skipped).toBe(true); + expect(state.completedAt).toBeDefined(); + }); +}); + +describe("getFirstRunWizardData", () => { + it("returns wizard data with scan results for fresh install", async () => { + const data = await run(getFirstRunWizardData()); + expect(data.state.completed).toBe(false); + expect(Array.isArray(data.scanResults.providers)).toBe(true); + expect(data.scanResults.scannedAt).toBeTruthy(); + expect(["scanning", "select-provider", "configure"]).toContain(data.currentStep); + }); +}); diff --git a/apps/server/src/provider/firstRunWizard.ts b/apps/server/src/provider/firstRunWizard.ts new file mode 100644 index 000000000..75d8929ed --- /dev/null +++ b/apps/server/src/provider/firstRunWizard.ts @@ -0,0 +1,70 @@ +import type { + CompleteFirstRunWizardInput, + FirstRunState, + FirstRunWizardData, + FirstRunWizardStep, +} from "@jcode/contracts"; +import { DEFAULT_FIRST_RUN_STATE } from "@jcode/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { scanAllProviders } from "./providerCredentialScan.ts"; + +const resolveCurrentStep = (state: FirstRunState): FirstRunWizardStep => { + if (state.skipped) { + return "skipped"; + } + if (state.completed) { + return "complete"; + } + return "select-provider"; +}; + +export const detectFirstRunState = (): Effect.Effect => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const serverSettings = yield* settings.getSettings; + return serverSettings.firstRun ?? DEFAULT_FIRST_RUN_STATE; + }); + +export const getFirstRunWizardData = (): Effect.Effect< + FirstRunWizardData, + never, + ServerSettingsService +> => + Effect.gen(function* () { + const [state, scanResults] = yield* Effect.all([detectFirstRunState(), scanAllProviders()], { + concurrency: "unbounded", + }); + const currentStep = state.completed || state.skipped ? resolveCurrentStep(state) : "scanning"; + return { state, scanResults, currentStep }; + }); + +export const completeFirstRunWizard = ( + input: CompleteFirstRunWizardInput, +): Effect.Effect => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const now = yield* DateTime.now; + const next: FirstRunState = { + completed: true, + completedAt: DateTime.formatIso(now), + ...(input.provider ? { selectedProvider: input.provider } : {}), + }; + yield* settings.updateSettings({ firstRun: next }); + return next; + }); + +export const skipFirstRunWizard = (): Effect.Effect => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const now = yield* DateTime.now; + const next: FirstRunState = { + completed: true, + skipped: true, + completedAt: DateTime.formatIso(now), + }; + yield* settings.updateSettings({ firstRun: next }); + return next; + }); diff --git a/apps/server/src/provider/managedRuntimeLifecycle.test.ts b/apps/server/src/provider/managedRuntimeLifecycle.test.ts new file mode 100644 index 000000000..c3e43b053 --- /dev/null +++ b/apps/server/src/provider/managedRuntimeLifecycle.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Data, Effect, Layer, Ref, Exit } from "effect"; + +import type { ManagedSidecarSnapshot } from "@jcode/contracts"; + +import { + generateSidecarPassword, + ManagedSidecarError, + type ManagedSidecarLifecycleShape, +} from "./managedRuntimeLifecycle.ts"; + +import { OpenCodeRuntime, type OpenCodeRuntimeShape } from "./opencodeRuntime.ts"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +const FAKE_BINARY_PATH = "/tmp/jcode-test-runtime/opencode"; +const FAKE_SERVER_URL = "http://127.0.0.1:9999"; + +const makeMockRuntime = (overrides?: Partial): OpenCodeRuntimeShape => + ({ + startOpenCodeServerProcess: vi.fn(() => + Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }), + ), + ...overrides, + }) as unknown as OpenCodeRuntimeShape; + +class TestOpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError")<{ + readonly operation: string; + readonly detail: string; +}> {} + +// --------------------------------------------------------------------------- +// Test lifecycle factory +// --------------------------------------------------------------------------- + +function makeTestLifecycle(mockRuntime: OpenCodeRuntimeShape) { + return Effect.gen(function* () { + const stateRef = yield* Ref.make(Object.freeze({ state: "idle" })); + + const updateState = (patch: Partial) => + Ref.update(stateRef, (prev) => ({ ...prev, ...patch })); + + const getSnapshot = () => Ref.get(stateRef); + + const startManagedRuntime = (_request?: Readonly<{ readonly forceDownload?: boolean }>) => + Effect.gen(function* () { + yield* updateState({ state: "starting" }); + const password = generateSidecarPassword(); + const server = yield* mockRuntime.startOpenCodeServerProcess({ + binaryPath: FAKE_BINARY_PATH, + configMode: "generated", + }); + const snapshot: ManagedSidecarSnapshot = { + state: "ready", + binaryPath: FAKE_BINARY_PATH, + serverUrl: server.url, + serverPassword: password, + }; + yield* Ref.set(stateRef, snapshot); + return snapshot; + }).pipe( + Effect.catchTag("OpenCodeRuntimeError", (err) => + Effect.gen(function* () { + yield* Ref.set(stateRef, { state: "error", error: err.detail }); + return yield* Effect.fail( + new ManagedSidecarError({ + stage: "spawn", + message: err.detail, + cause: err, + }), + ); + }), + ), + ); + + const stopManagedRuntime = () => + Effect.gen(function* () { + const current = yield* getSnapshot(); + if (current.state === "idle") return current; + yield* updateState({ state: "stopping" }); + yield* Ref.set(stateRef, Object.freeze({ state: "idle" })); + return Object.freeze({ state: "idle" }); + }); + + const restartManagedRuntime = () => + Effect.gen(function* () { + yield* stopManagedRuntime(); + return yield* startManagedRuntime(); + }); + + const getManagedRuntimeStatus = () => getSnapshot(); + + return { + startManagedRuntime, + stopManagedRuntime, + restartManagedRuntime, + getManagedRuntimeStatus, + } satisfies ManagedSidecarLifecycleShape; + }); +} + +// --------------------------------------------------------------------------- +// generateSidecarPassword +// --------------------------------------------------------------------------- + +describe("generateSidecarPassword", () => { + it("returns a non-empty base64url string", () => { + const password = generateSidecarPassword(); + expect(password).toBeTruthy(); + expect(password).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("generates unique passwords per call", () => { + const passwords = new Set(Array.from({ length: 20 }, () => generateSidecarPassword())); + expect(passwords.size).toBe(20); + }); + + it("produces a 32-character base64url string from 24 random bytes", () => { + const password = generateSidecarPassword(); + expect(password).toHaveLength(32); + }); +}); + +// --------------------------------------------------------------------------- +// ManagedSidecarError +// --------------------------------------------------------------------------- + +describe("ManagedSidecarError", () => { + it("is a tagged error with stage and message", () => { + const err = new ManagedSidecarError({ + stage: "spawn", + message: "Failed to spawn", + }); + expect(err._tag).toBe("ManagedSidecarError"); + expect(err.stage).toBe("spawn"); + expect(err.message).toBe("Failed to spawn"); + }); + + it("accepts an optional cause", () => { + const cause = new Error("root cause"); + const err = new ManagedSidecarError({ + stage: "download", + message: "Download failed", + cause, + }); + expect(err.cause).toBe(cause); + }); +}); + +// --------------------------------------------------------------------------- +// Status tracking (state transitions) +// --------------------------------------------------------------------------- + +describe("lifecycle state transitions", () => { + it("starts in idle state", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("transitions to ready after successful start", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.startManagedRuntime(); + expect(result.state).toBe("ready"); + expect(result.serverUrl).toBe(FAKE_SERVER_URL); + expect(result.serverPassword).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("transitions back to idle after stop", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + yield* lifecycle.startManagedRuntime(); + const stopped = yield* lifecycle.stopManagedRuntime(); + expect(stopped.state).toBe("idle"); + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); +}); + +// --------------------------------------------------------------------------- +// Start lifecycle +// --------------------------------------------------------------------------- + +describe("startManagedRuntime", () => { + it("generates a fresh server password on each start", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const first = yield* lifecycle.startManagedRuntime(); + yield* lifecycle.stopManagedRuntime(); + const second = yield* lifecycle.startManagedRuntime(); + expect(first.serverPassword).toBeTruthy(); + expect(second.serverPassword).toBeTruthy(); + expect(first.serverPassword).not.toBe(second.serverPassword); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("uses configMode generated when spawning", () => + Effect.gen(function* () { + const startFn = vi.fn(() => + Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }), + ); + const mock = makeMockRuntime({ + startOpenCodeServerProcess: startFn, + }); + const lifecycle = yield* makeTestLifecycle(mock); + yield* lifecycle.startManagedRuntime(); + + expect(startFn).toHaveBeenCalledTimes(1); + const callArgs = startFn.mock.calls[0]![0] as Record; + expect(callArgs["configMode"]).toBe("generated"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("returns binaryPath in the snapshot", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.startManagedRuntime(); + expect(result.binaryPath).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); +}); + +// --------------------------------------------------------------------------- +// Stop lifecycle +// --------------------------------------------------------------------------- + +describe("stopManagedRuntime", () => { + it("returns idle when already idle", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.stopManagedRuntime(); + expect(result.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); +}); + +// --------------------------------------------------------------------------- +// Restart lifecycle +// --------------------------------------------------------------------------- + +describe("restartManagedRuntime", () => { + it("generates a new password on restart (no reuse)", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const first = yield* lifecycle.startManagedRuntime(); + const restarted = yield* lifecycle.restartManagedRuntime(); + expect(restarted.serverPassword).toBeTruthy(); + expect(restarted.serverPassword).not.toBe(first.serverPassword); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("returns to ready state after restart", () => + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const restarted = yield* lifecycle.restartManagedRuntime(); + expect(restarted.state).toBe("ready"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe("error handling", () => { + it("transitions to error state on spawn failure", () => + Effect.gen(function* () { + const failingMock = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.fail( + new TestOpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "spawn failed", + }), + ), + ), + }); + const lifecycle = yield* makeTestLifecycle(failingMock); + const exit = yield* Effect.exit(lifecycle.startManagedRuntime()); + expect(Exit.isFailure(exit)).toBe(true); + + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("error"); + expect(status.error).toContain("spawn failed"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + + it("does not reuse password after a failed start", () => + Effect.gen(function* () { + let callCount = 0; + const failThenSucceed = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => { + callCount++; + if (callCount === 1) { + return Effect.fail( + new TestOpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "first attempt fails", + }), + ); + } + return Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }); + }), + }); + const lifecycle = yield* makeTestLifecycle(failThenSucceed); + + yield* Effect.flip(lifecycle.startManagedRuntime()); + + const success = yield* lifecycle.startManagedRuntime(); + expect(success.state).toBe("ready"); + expect(success.serverPassword).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); +}); diff --git a/apps/server/src/provider/managedRuntimeLifecycle.ts b/apps/server/src/provider/managedRuntimeLifecycle.ts new file mode 100644 index 000000000..7abbde033 --- /dev/null +++ b/apps/server/src/provider/managedRuntimeLifecycle.ts @@ -0,0 +1,229 @@ +// FILE: managedRuntimeLifecycle.ts +// Purpose: Managed OpenCode sidecar lifecycle service — detect, download, spawn, health, stop. +// Layer: apps/server — provider runtime orchestration. +// Depends on: managedRuntimeDownload, opencodeRuntime, @jcode/contracts, effect + +import type { ManagedSidecarSnapshot, ManagedSidecarStartRequest } from "@jcode/contracts"; +import { Data, Effect, Layer, Path, Ref, Scope, ServiceMap } from "effect"; +import * as Crypto from "node:crypto"; + +import { + downloadManagedRuntime, + resolveManagedRuntimeDir, + verifyManagedRuntimeBinary, +} from "./managedRuntimeDownload.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeLive, + OPENCODE_CLI_SPEC, + type OpenCodeServerProcess, +} from "./opencodeRuntime.ts"; + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +export class ManagedSidecarError extends Data.TaggedError("ManagedSidecarError")<{ + readonly stage: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +// --------------------------------------------------------------------------- +// Password generation +// --------------------------------------------------------------------------- + +export const generateSidecarPassword = (): string => Crypto.randomBytes(24).toString("base64url"); + +// --------------------------------------------------------------------------- +// Idle snapshot +// --------------------------------------------------------------------------- + +const IDLE_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "idle", +}); + +// --------------------------------------------------------------------------- +// Lifecycle service interface +// --------------------------------------------------------------------------- + +export interface ManagedSidecarLifecycleShape { + readonly startManagedRuntime: ( + request?: ManagedSidecarStartRequest, + ) => Effect.Effect; + + readonly stopManagedRuntime: () => Effect.Effect; + + readonly restartManagedRuntime: () => Effect.Effect< + ManagedSidecarSnapshot, + ManagedSidecarError, + Scope.Scope + >; + + readonly getManagedRuntimeStatus: () => Effect.Effect; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export const makeManagedSidecarLifecycle = Effect.gen(function* () { + const stateRef = yield* Ref.make(IDLE_SNAPSHOT); + const runtime = yield* OpenCodeRuntime; + + const updateState = (patch: Partial) => + Ref.update(stateRef, (prev) => ({ ...prev, ...patch })); + + const getSnapshot = () => Ref.get(stateRef); + + // ----------------------------------------------------------------------- + // startManagedRuntime + // ----------------------------------------------------------------------- + + const startManagedRuntime = (request?: ManagedSidecarStartRequest) => + Effect.gen(function* () { + const forceDownload = request?.forceDownload ?? false; + + yield* updateState({ state: "downloading" }); + + const validation = yield* verifyManagedRuntimeBinary().pipe( + Effect.catchTag("PlatformError", (err) => + Effect.fail( + new ManagedSidecarError({ + stage: "verify", + message: `Failed to verify managed runtime binary: ${String(err)}`, + cause: err, + }), + ), + ), + ); + + let binaryPath: string; + + if (!validation.exists || !validation.valid || forceDownload) { + const downloadResult = yield* downloadManagedRuntime.pipe( + Effect.mapError( + (err) => + new ManagedSidecarError({ + stage: "download", + message: `Failed to download managed runtime: ${err.message}`, + cause: err, + }), + ), + ); + binaryPath = downloadResult.binaryPath; + } else { + const runtimeDir = yield* resolveManagedRuntimeDir.pipe( + Effect.mapError( + (err) => + new ManagedSidecarError({ + stage: "resolve-dir", + message: `Failed to resolve runtime directory: ${String(err)}`, + cause: err, + }), + ), + ); + const path = yield* Path.Path; + const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode"; + binaryPath = path.join(runtimeDir, binaryName); + } + + yield* updateState({ state: "starting", binaryPath }); + + const password = generateSidecarPassword(); + + const server: OpenCodeServerProcess = yield* runtime + .startOpenCodeServerProcess({ + binaryPath, + cliSpec: OPENCODE_CLI_SPEC, + configMode: "generated", + }) + .pipe( + Effect.mapError( + (err) => + new ManagedSidecarError({ + stage: "spawn", + message: `Failed to start managed sidecar: ${err.detail}`, + cause: err, + }), + ), + ); + + const snapshot: ManagedSidecarSnapshot = { + state: "ready", + binaryPath, + serverUrl: server.url, + serverPassword: password, + }; + + yield* Ref.set(stateRef, snapshot); + + return snapshot; + }).pipe( + Effect.catchTag("ManagedSidecarError", (err) => + Effect.gen(function* () { + yield* Ref.set(stateRef, { + state: "error", + error: err.message, + }); + return yield* Effect.fail(err); + }), + ), + ); + + // ----------------------------------------------------------------------- + // stopManagedRuntime + // ----------------------------------------------------------------------- + + const stopManagedRuntime = (): Effect.Effect => + Effect.gen(function* () { + const current = yield* getSnapshot(); + + if (current.state === "idle") { + return current; + } + + yield* updateState({ state: "stopping" }); + + yield* Ref.set(stateRef, IDLE_SNAPSHOT); + + return IDLE_SNAPSHOT; + }); + + // ----------------------------------------------------------------------- + // restartManagedRuntime + // ----------------------------------------------------------------------- + + const restartManagedRuntime = () => + Effect.gen(function* () { + yield* stopManagedRuntime(); + return yield* startManagedRuntime(); + }); + + // ----------------------------------------------------------------------- + // getManagedRuntimeStatus + // ----------------------------------------------------------------------- + + const getManagedRuntimeStatus = () => getSnapshot(); + + return { + startManagedRuntime, + stopManagedRuntime, + restartManagedRuntime, + getManagedRuntimeStatus, + }; +}); + +// --------------------------------------------------------------------------- +// Service tag +// --------------------------------------------------------------------------- + +export class ManagedSidecarLifecycle extends ServiceMap.Service< + ManagedSidecarLifecycle, + ManagedSidecarLifecycleShape +>()("jcode/provider/managedSidecarLifecycle") {} + +export const ManagedSidecarLifecycleLive = Layer.effect( + ManagedSidecarLifecycle, + makeManagedSidecarLifecycle, +).pipe(Layer.provide(OpenCodeRuntimeLive)); diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index abe04445d..d8b9128ac 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -61,6 +61,7 @@ import { OpenCodeRuntimeLive, } from "./provider/opencodeRuntime"; import { checkOpenCodeRuntimeHealth } from "./provider/openCodeRuntimeHealth"; +import { makeFirstRunWizardState } from "./provider/firstRunWizard"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; @@ -258,6 +259,7 @@ export const makeWsRpcLayer = () => const terminalManager = yield* TerminalManager; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const firstRunWizard = yield* makeFirstRunWizardState; const getOpenCodeRuntimeHealth = (input: { readonly provider: "opencode"; @@ -738,6 +740,10 @@ export const makeWsRpcLayer = () => ), "Failed to reset keybindings", ), + [WS_METHODS.serverGetFirstRunWizardData]: () => + rpcEffect(firstRunWizard.getWizardData, "Failed to get first-run wizard data"), + [WS_METHODS.serverCompleteFirstRunWizard]: (input) => + rpcEffect(firstRunWizard.completeWizard(input), "Failed to complete first-run wizard"), [WS_METHODS.subscribeServerLifecycle]: () => Stream.concat( Stream.fromEffect( diff --git a/apps/web/src/components/FirstRunWizard.tsx b/apps/web/src/components/FirstRunWizard.tsx new file mode 100644 index 000000000..58e87b83f --- /dev/null +++ b/apps/web/src/components/FirstRunWizard.tsx @@ -0,0 +1,265 @@ +import type { + FirstRunWizardData, + ProviderDiscoveryKind, + ProviderScanResult, +} from "@jcode/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; + +import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "../lib/icons"; +import { ensureNativeApi } from "../nativeApi"; +import { Button } from "./ui/button"; + +const FIRST_RUN_QUERY_KEY = ["server", "firstRunWizard"] as const; + +const PROVIDER_DISPLAY_NAMES: Record = { + codex: "Codex", + claudeAgent: "Claude", + cursor: "Cursor", + gemini: "Gemini", + kilo: "Kilo", + opencode: "OpenCode", + pi: "Pi", +}; + +function statusBadge(status: ProviderScanResult["status"]) { + switch (status) { + case "ready": + return ( + + + Ready + + ); + case "needs-config": + return ( + + + Needs config + + ); + case "not-installed": + return ( + + + Not installed + + ); + } +} + +function ProviderCard({ + result, + selected, + onSelect, +}: { + result: ProviderScanResult; + selected: boolean; + onSelect: (provider: ProviderDiscoveryKind) => void; +}) { + const isSelectable = result.status === "ready" || result.status === "needs-config"; + return ( + + ); +} + +function ScanningStep() { + return ( +
+ +

Scanning for available providers...

+
+ ); +} + +function ProviderSelectionStep({ + providers, + onComplete, + onSkip, +}: { + providers: ProviderScanResult[]; + onComplete: (provider: ProviderDiscoveryKind | undefined) => void; + onSkip: () => void; +}) { + const [selected, setSelected] = useState(null); + const readyProviders = providers.filter((p) => p.status === "ready"); + + const handleSelect = useCallback( + (provider: ProviderDiscoveryKind) => { + setSelected(provider === selected ? null : provider); + }, + [selected], + ); + + const handleConfirm = useCallback(() => { + onComplete(selected ?? undefined); + }, [selected, onComplete]); + + return ( +
+
+

Choose a provider

+

+ {readyProviders.length > 0 + ? `${readyProviders.length} provider${readyProviders.length !== 1 ? "s" : ""} ready to use. Select one to get started.` + : "No providers are fully configured yet. You can skip and configure later."} +

+
+ +
+ {providers.map((result) => ( + + ))} +
+ +
+ + +
+
+ ); +} + +function CompleteStep() { + return ( +
+
+ +
+

You're all set!

+

JCode is ready to go.

+
+ ); +} + +export function FirstRunWizard() { + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: FIRST_RUN_QUERY_KEY, + queryFn: async () => { + const api = ensureNativeApi(); + return api.server.getFirstRunWizardData(); + }, + staleTime: 0, + }); + + const completeMutation = useMutation({ + mutationFn: async (provider: ProviderDiscoveryKind | undefined) => { + const api = ensureNativeApi(); + return api.server.completeFirstRunWizard( + provider ? { provider } : {}, + ); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: FIRST_RUN_QUERY_KEY }); + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +

+ Failed to load provider scan: {(error as Error).message} +

+ +
+
+ ); + } + + const wizardData: FirstRunWizardData | undefined = data; + + if (!wizardData) return null; + + if (wizardData.state.completed || wizardData.currentStep === "complete") { + return ( +
+ +
+ ); + } + + if (wizardData.currentStep === "scanning") { + return ( +
+ +
+ ); + } + + const handleComplete = (provider: ProviderDiscoveryKind | undefined) => { + completeMutation.mutate(provider); + }; + + const handleSkip = () => { + completeMutation.mutate(undefined); + }; + + return ( +
+
+
+

Welcome to JCode

+

+ Let's find a coding agent to get you started. +

+
+ +
+
+ ); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index e76bb6115..d10d9099d 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -666,6 +666,9 @@ export function createWsNativeApi(): NativeApi { upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), resetKeybinding: (input) => transport.request(WS_METHODS.serverResetKeybinding, input), resetAllKeybindings: () => transport.request(WS_METHODS.serverResetAllKeybindings, {}), + getFirstRunWizardData: () => transport.request(WS_METHODS.serverGetFirstRunWizardData, {}), + completeFirstRunWizard: (input) => + transport.request(WS_METHODS.serverCompleteFirstRunWizard, input), }, provider: { getComposerCapabilities: (input) => diff --git a/package.json b/package.json index 21a0e0661..6d14bf878 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "test:desktop-smoke": "turbo run smoke-test --filter=@jcode/desktop", "release:notes": "bun scripts/release-notes.ts", "release:scoop": "node scripts/generate-scoop-manifest.ts", + "release:winget": "node scripts/generate-winget-manifest.ts", + "update-manifests": "node scripts/update-package-manifests.ts", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "fmt:check:local": "oxfmt --check --threads=4", diff --git a/packages/contracts/src/firstRunWizard.ts b/packages/contracts/src/firstRunWizard.ts new file mode 100644 index 000000000..a6c7b4fb4 --- /dev/null +++ b/packages/contracts/src/firstRunWizard.ts @@ -0,0 +1,70 @@ +// FILE: firstRunWizard.ts +// Purpose: Contract schemas for the first-run wizard shared across web and server. +// Layer: packages/contracts — shared types across server, web, and desktop. +// Depends on: effect (Schema), baseSchemas, providerDiscovery, providerScan + +// FILE: firstRunWizard.ts +// Purpose: First-run wizard contracts — provider-agnostic setup flow for new installs. +// Layer: Shared contracts +// Exports: First-run state schemas, wizard step literals, input types, and default state. + +import { Schema } from "effect"; +import { ProviderDiscoveryKind } from "./providerDiscovery"; +import { ProviderScanAllResult } from "./providerScan"; + +// --------------------------------------------------------------------------- +// First-run state +// --------------------------------------------------------------------------- + +export const FirstRunState = Schema.Struct({ + completed: Schema.Boolean, + selectedProvider: Schema.optional(ProviderDiscoveryKind), + completedAt: Schema.optional(Schema.String), + skipped: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type FirstRunState = typeof FirstRunState.Type; + +// --------------------------------------------------------------------------- +// Wizard steps +// --------------------------------------------------------------------------- + +export const FirstRunWizardStep = Schema.Literals([ + "scanning", + "select-provider", + "configure", + "complete", + "skipped", +]); +export type FirstRunWizardStep = typeof FirstRunWizardStep.Type; + +// --------------------------------------------------------------------------- +// Wizard data bundle +// --------------------------------------------------------------------------- + +export const FirstRunWizardData = Schema.Struct({ + state: FirstRunState, + scanResults: ProviderScanAllResult, + currentStep: FirstRunWizardStep, +}); +export type FirstRunWizardData = typeof FirstRunWizardData.Type; + +// --------------------------------------------------------------------------- +// RPC input types +// --------------------------------------------------------------------------- + +export const CompleteFirstRunWizardInput = Schema.Struct({ + provider: Schema.optional(ProviderDiscoveryKind), +}); +export type CompleteFirstRunWizardInput = typeof CompleteFirstRunWizardInput.Type; + +export const SkipFirstRunWizardInput = Schema.Struct({}); +export type SkipFirstRunWizardInput = typeof SkipFirstRunWizardInput.Type; + +// --------------------------------------------------------------------------- +// Default state +// --------------------------------------------------------------------------- + +export const DEFAULT_FIRST_RUN_STATE: FirstRunState = { + completed: false, + skipped: false, +}; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e26799c22..639c662c2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,4 +19,6 @@ export * from "./environment"; export * from "./project"; export * from "./filesystem"; export * from "./managedRuntime"; +export * from "./managedRuntimeLifecycle"; export * from "./rpc"; +export * from "./firstRunWizard"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3e6e797fd..2daae7123 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -77,6 +77,7 @@ import type { ServerVoiceTranscriptionInput, ServerVoiceTranscriptionResult, } from "./server"; +import type { CompleteFirstRunWizardInput, FirstRunWizardData } from "./firstRunWizard"; import type { TerminalClearInput, TerminalCloseInput, @@ -463,6 +464,8 @@ export interface NativeApi { upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; resetKeybinding: (input: ServerResetKeybindingInput) => Promise; resetAllKeybindings: () => Promise; + getFirstRunWizardData: () => Promise; + completeFirstRunWizard: (input: CompleteFirstRunWizardInput) => Promise; }; provider: { getComposerCapabilities: ( diff --git a/packages/contracts/src/managedRuntimeLifecycle.ts b/packages/contracts/src/managedRuntimeLifecycle.ts new file mode 100644 index 000000000..e7de37013 --- /dev/null +++ b/packages/contracts/src/managedRuntimeLifecycle.ts @@ -0,0 +1,54 @@ +// FILE: managedRuntimeLifecycle.ts +// Purpose: Contract schemas for the managed OpenCode sidecar lifecycle service. +// Layer: packages/contracts — shared types across server, web, and desktop. +// Depends on: effect (Schema) + +/** + * Managed sidecar lifecycle contract schemas. + * + * Defines the state machine, snapshot, and request types for the managed + * OpenCode binary lifecycle: detect → download → spawn → health → stop. + * + * @module managedRuntimeLifecycle + */ + +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Sidecar state machine +// --------------------------------------------------------------------------- + +export const ManagedSidecarState = Schema.Literals([ + "idle", + "downloading", + "starting", + "ready", + "stopping", + "error", +]); +export type ManagedSidecarState = typeof ManagedSidecarState.Type; + +// --------------------------------------------------------------------------- +// Snapshot — current sidecar status +// --------------------------------------------------------------------------- + +export const ManagedSidecarSnapshot = Schema.Struct({ + state: ManagedSidecarState, + binaryPath: Schema.optional(Schema.String), + serverUrl: Schema.optional(Schema.String), + serverPassword: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); +export type ManagedSidecarSnapshot = typeof ManagedSidecarSnapshot.Type; + +// --------------------------------------------------------------------------- +// Request types +// --------------------------------------------------------------------------- + +export const ManagedSidecarStartRequest = Schema.Struct({ + forceDownload: Schema.optional(Schema.Boolean), +}); +export type ManagedSidecarStartRequest = typeof ManagedSidecarStartRequest.Type; + +export const ManagedSidecarStopRequest = Schema.Struct({}); +export type ManagedSidecarStopRequest = typeof ManagedSidecarStopRequest.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 342cbe7b8..53d23a047 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,6 +3,7 @@ import { TrimmedString } from "./baseSchemas"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model"; import { ModelSelection, ProviderKind, ThreadEnvironmentMode } from "./orchestration"; import { OpenCodeRuntimeProfile } from "./providerDiscovery"; +import { FirstRunState } from "./firstRunWizard"; const StringSetting = TrimmedString.check(Schema.isMaxLength(4096)); const CustomModels = Schema.Array(Schema.String.check(Schema.isMaxLength(256))).pipe( @@ -91,6 +92,9 @@ export const ServerSettings = Schema.Struct({ opencode: OpenCodeServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), pi: PiServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), + firstRun: FirstRunState.pipe( + Schema.withDecodingDefault(() => ({ completed: false, skipped: false })), + ), }); export type ServerSettings = typeof ServerSettings.Type; @@ -113,6 +117,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvironmentMode), addProjectBaseDirectory: Schema.optionalKey(StringSetting), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + firstRun: Schema.optionalKey(FirstRunState), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey( diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 175210282..528edd94e 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -72,6 +72,7 @@ import { ServerResetKeybindingInput, ServerVoiceTranscriptionInput, } from "./server"; +import { CompleteFirstRunWizardInput, SkipFirstRunWizardInput } from "./firstRunWizard"; import { ProviderListCommandsInput, ProviderGetRuntimeHealthInput, @@ -149,6 +150,9 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverResetKeybinding: "server.resetKeybinding", serverResetAllKeybindings: "server.resetAllKeybindings", + serverGetFirstRunWizardData: "server.getFirstRunWizardData", + serverCompleteFirstRunWizard: "server.completeFirstRunWizard", + serverSkipFirstRun: "server.skipFirstRun", subscribeServerLifecycle: "server.subscribeLifecycle", subscribeServerConfig: "server.subscribeConfig", subscribeServerProviderStatuses: "server.subscribeProviderStatuses", @@ -274,6 +278,9 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), tagRequestBody(WS_METHODS.serverResetKeybinding, ServerResetKeybindingInput), tagRequestBody(WS_METHODS.serverResetAllKeybindings, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverGetFirstRunWizardData, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverCompleteFirstRunWizard, CompleteFirstRunWizardInput), + tagRequestBody(WS_METHODS.serverSkipFirstRun, SkipFirstRunWizardInput), tagRequestBody(WS_METHODS.subscribeAuthAccess, Schema.Struct({})), // Provider discovery diff --git a/packages/scoop/jcode.json b/packages/scoop/jcode.json new file mode 100644 index 000000000..e33d244bb --- /dev/null +++ b/packages/scoop/jcode.json @@ -0,0 +1,32 @@ +{ + "version": "$VERSION", + "description": "Local cockpit for coding agents tuned for low-level and cybersecurity workflows.", + "homepage": "https://github.com/Jay1/jcode", + "license": "MIT", + "architecture": { + "64bit": { + "url": "https://github.com/Jay1/jcode/releases/download/v$VERSION/JCode-$VERSION-x64.exe", + "hash": "$SHA256" + } + }, + "installer": { + "script": ["Start-Process \"$dir\\$fname\" -ArgumentList @('/S', \"/D=$dir\") -Wait"] + }, + "uninstaller": { + "script": [ + "if (Test-Path \"$dir\\Uninstall JCode.exe\") {", + " Start-Process \"$dir\\Uninstall JCode.exe\" -ArgumentList '/S' -Wait", + "}" + ] + }, + "checkver": { + "github": "https://github.com/Jay1/jcode" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/Jay1/jcode/releases/download/v$version/JCode-$version-x64.exe" + } + } + } +} diff --git a/packages/winget/manifests/j/JCode/JCode.yaml b/packages/winget/manifests/j/JCode/JCode.yaml new file mode 100644 index 000000000..0d7cabf7d --- /dev/null +++ b/packages/winget/manifests/j/JCode/JCode.yaml @@ -0,0 +1,54 @@ +# Winget manifest template for JCode. +# The release CI generates the three canonical split files +# (version, installer, locale) from scripts/update-package-manifests.ts. + +PackageIdentifier: Jay1.JCode +PackageVersion: "$VERSION" +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0 + +--- # installer + +PackageIdentifier: Jay1.JCode +PackageVersion: "$VERSION" +InstallerLocale: en-US +InstallerType: nullsoft +InstallModes: + - interactive + - silent +InstallerSwitches: + Silent: /S + SilentWithProgress: /S +UpgradeBehavior: install +ReleaseDate: "$RELEASE_DATE" +Installers: + - Architecture: x64 + InstallerUrl: "https://github.com/Jay1/jcode/releases/download/v$VERSION/JCode-$VERSION-x64.exe" + InstallerSha256: $SHA256 +ManifestType: installer +ManifestVersion: 1.10.0 + +--- # defaultLocale + +PackageIdentifier: Jay1.JCode +PackageVersion: "$VERSION" +PackageLocale: en-US +Publisher: Jay1 +PublisherUrl: https://github.com/Jay1 +PackageName: JCode +PackageUrl: https://github.com/Jay1/jcode +License: MIT +LicenseUrl: https://github.com/Jay1/jcode/blob/main/LICENSE +ShortDescription: Local cockpit for coding agents. +Description: >- + JCode is a local cockpit for coding agents, built around OpenCode with a web + UI, desktop packaging, and a local server for managing coding-agent sessions. +Moniker: jcode +Tags: + - agent + - coding-agent + - developer-tools + - opencode +ManifestType: defaultLocale +ManifestVersion: 1.10.0 diff --git a/scripts/generate-winget-manifest.test.ts b/scripts/generate-winget-manifest.test.ts new file mode 100644 index 000000000..7dc026e5b --- /dev/null +++ b/scripts/generate-winget-manifest.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + createWingetInstallerManifest, + createWingetLocaleManifest, + createWingetVersionManifest, +} from "./generate-winget-manifest.ts"; + +describe("generate-winget-manifest", () => { + const input = { + hash: "a".repeat(64), + releaseDate: "2026-06-07", + version: "0.0.50", + }; + + it("renders a Winget version manifest", () => { + const manifest = createWingetVersionManifest(input); + + expect(manifest).toContain("PackageIdentifier: Jay1.JCode"); + expect(manifest).toContain('PackageVersion: "0.0.50"'); + expect(manifest).toContain("DefaultLocale: en-US"); + expect(manifest).toContain("ManifestType: version"); + expect(manifest).toContain("ManifestVersion: 1.10.0"); + }); + + it("renders a Winget installer manifest with the Windows x64 installer URL and hash", () => { + const manifest = createWingetInstallerManifest(input); + + expect(manifest).toContain( + 'InstallerUrl: "https://github.com/Jay1/jcode/releases/download/v0.0.50/JCode-0.0.50-x64.exe"', + ); + expect(manifest).toContain(`InstallerSha256: ${"A".repeat(64)}`); + expect(manifest).toContain("InstallerType: nullsoft"); + expect(manifest).toContain(" Silent: /S"); + expect(manifest).toContain('ReleaseDate: "2026-06-07"'); + expect(manifest).toContain(" - Architecture: x64"); + }); + + it("renders a Winget locale manifest with package metadata", () => { + const manifest = createWingetLocaleManifest(input); + + expect(manifest).toContain("PackageIdentifier: Jay1.JCode"); + expect(manifest).toContain("Publisher: Jay1"); + expect(manifest).toContain("PackageName: JCode"); + expect(manifest).toContain("License: MIT"); + expect(manifest).toContain("Moniker: jcode"); + expect(manifest).toContain(" - agent"); + expect(manifest).toContain(" - coding-agent"); + }); + + it("uses a custom repository slug when provided", () => { + const custom = { ...input, repository: "MyOrg/my-app" }; + const version = createWingetVersionManifest(custom); + const installer = createWingetInstallerManifest(custom); + const locale = createWingetLocaleManifest(custom); + + expect(version).toContain("PackageIdentifier: MyOrg.My-app"); + expect(installer).toContain("PackageIdentifier: MyOrg.My-app"); + expect(locale).toContain("PackageIdentifier: MyOrg.My-app"); + expect(installer).toContain( + 'InstallerUrl: "https://github.com/MyOrg/my-app/releases/download/v0.0.50/JCode-0.0.50-x64.exe"', + ); + expect(locale).toContain("Publisher: MyOrg"); + }); + + it("rejects invalid installer hashes", () => { + expect(() => + createWingetInstallerManifest({ + hash: "not-a-sha256", + releaseDate: "2026-06-07", + version: "0.0.50", + }), + ).toThrow(/Expected a 64-character SHA256 hash/); + }); + + it("rejects invalid versions", () => { + expect(() => + createWingetInstallerManifest({ + hash: "a".repeat(64), + releaseDate: "2026-06-07", + version: "not-a-version", + }), + ).toThrow(/Invalid version/); + }); + + it("rejects invalid repository slugs", () => { + expect(() => + createWingetInstallerManifest({ + hash: "a".repeat(64), + releaseDate: "2026-06-07", + repository: "bad slug", + version: "0.0.50", + }), + ).toThrow(/Invalid GitHub repository slug/); + }); +}); diff --git a/scripts/generate-winget-manifest.ts b/scripts/generate-winget-manifest.ts new file mode 100644 index 000000000..d17320fff --- /dev/null +++ b/scripts/generate-winget-manifest.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// FILE: generate-winget-manifest.ts +// Purpose: Generates concrete Winget manifest YAML files for a published JCode Windows release asset. +// Layer: Release/package-manager helper + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_REPOSITORY = "Jay1/jcode"; + +export interface CreateWingetManifestInput { + readonly hash: string; + readonly releaseDate: string; + readonly repository?: string; + readonly version: string; +} + +function assertVersion(version: string): void { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[.-][0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid version: ${version}`); + } +} + +function assertSha256(hash: string): void { + if (!/^[a-fA-F0-9]{64}$/.test(hash)) { + throw new Error("Expected a 64-character SHA256 hash."); + } +} + +function assertRepository(repository: string): void { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error(`Invalid GitHub repository slug: ${repository}`); + } +} + +function buildWindowsInstallerUrl(repository: string, version: string): string { + return `https://github.com/${repository}/releases/download/v${version}/JCode-${version}-x64.exe`; +} + +export function createWingetVersionManifest(input: CreateWingetManifestInput): string { + const repository = input.repository ?? DEFAULT_REPOSITORY; + assertRepository(repository); + assertVersion(input.version); + + return ( + [ + `PackageIdentifier: ${repositoryToWingetId(repository)}`, + `PackageVersion: "${input.version}"`, + "DefaultLocale: en-US", + "ManifestType: version", + "ManifestVersion: 1.10.0", + ].join("\n") + "\n" + ); +} + +export function createWingetInstallerManifest(input: CreateWingetManifestInput): string { + const repository = input.repository ?? DEFAULT_REPOSITORY; + assertRepository(repository); + assertVersion(input.version); + assertSha256(input.hash); + + return ( + [ + `PackageIdentifier: ${repositoryToWingetId(repository)}`, + `PackageVersion: "${input.version}"`, + "InstallerLocale: en-US", + "InstallerType: nullsoft", + "InstallModes:", + " - interactive", + " - silent", + "InstallerSwitches:", + " Silent: /S", + " SilentWithProgress: /S", + "UpgradeBehavior: install", + `ReleaseDate: "${input.releaseDate}"`, + "Installers:", + " - Architecture: x64", + ` InstallerUrl: "${buildWindowsInstallerUrl(repository, input.version)}"`, + ` InstallerSha256: ${input.hash.toUpperCase()}`, + "ManifestType: installer", + "ManifestVersion: 1.10.0", + ].join("\n") + "\n" + ); +} + +export function createWingetLocaleManifest(input: CreateWingetManifestInput): string { + const repository = input.repository ?? DEFAULT_REPOSITORY; + assertRepository(repository); + assertVersion(input.version); + + const [owner] = repository.split("/"); + + return ( + [ + `PackageIdentifier: ${repositoryToWingetId(repository)}`, + `PackageVersion: "${input.version}"`, + "PackageLocale: en-US", + `Publisher: ${owner}`, + `PublisherUrl: https://github.com/${owner}`, + "PackageName: JCode", + `PackageUrl: https://github.com/${repository}`, + "License: MIT", + `LicenseUrl: https://github.com/${repository}/blob/main/LICENSE`, + "ShortDescription: Local cockpit for coding agents.", + "Description: JCode is a local cockpit for coding agents, built around OpenCode with a web UI, desktop packaging, and a local server for managing coding-agent sessions.", + "Moniker: jcode", + "Tags:", + " - agent", + " - coding-agent", + " - developer-tools", + " - opencode", + "ManifestType: defaultLocale", + "ManifestVersion: 1.10.0", + ].join("\n") + "\n" + ); +} + +const WINGET_ID = "Jay1.JCode"; + +export function repositoryToWingetId(repository: string): string { + if (repository === DEFAULT_REPOSITORY) return WINGET_ID; + const [owner, repo] = repository.split("/"); + const pascalRepo = repo.slice(0, 1).toUpperCase() + repo.slice(1); + return `${owner}.${pascalRepo}`; +} + +interface CliOptions { + readonly hash: string; + readonly output: string; + readonly releaseDate: string; + readonly repository: string; + readonly version: string; +} + +function readCliOptions(args: ReadonlyArray): CliOptions { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || !value) { + throw new Error( + "Usage: node scripts/generate-winget-manifest.ts --version X.Y.Z --hash SHA256 --release-date YYYY-MM-DD [--output packaging/winget/Jay1.JCode] [--repository owner/repo]", + ); + } + options.set(key, value); + } + + const version = options.get("--version"); + const hash = options.get("--hash"); + const releaseDate = options.get("--release-date"); + if (!version || !hash || !releaseDate) { + throw new Error("Missing required --version, --hash, or --release-date option."); + } + + return { + version, + hash, + releaseDate, + repository: options.get("--repository") ?? DEFAULT_REPOSITORY, + output: + options.get("--output") ?? + `packaging/winget/${repositoryToWingetId(options.get("--repository") ?? DEFAULT_REPOSITORY)}`, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const options = readCliOptions(process.argv.slice(2)); + const input: CreateWingetManifestInput = { + hash: options.hash, + releaseDate: options.releaseDate, + repository: options.repository, + version: options.version, + }; + const wingetId = repositoryToWingetId(options.repository); + + const versionPath = join(options.output, `${wingetId}.yaml`); + const installerPath = join(options.output, `${wingetId}.installer.yaml`); + const localePath = join(options.output, `${wingetId}.locale.en-US.yaml`); + + mkdirSync(dirname(versionPath), { recursive: true }); + + writeFileSync(versionPath, createWingetVersionManifest(input)); + writeFileSync(installerPath, createWingetInstallerManifest(input)); + writeFileSync(localePath, createWingetLocaleManifest(input)); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/update-package-manifests.test.ts b/scripts/update-package-manifests.test.ts new file mode 100644 index 000000000..0035019e1 --- /dev/null +++ b/scripts/update-package-manifests.test.ts @@ -0,0 +1,121 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { computeSha256FromResponse, updatePackageManifests } from "./update-package-manifests.ts"; + +describe("update-package-manifests", () => { + const testOutputDir = join(tmpdir(), `jcode-update-manifests-test-${Date.now()}`); + + afterAll(() => { + rmSync(testOutputDir, { recursive: true, force: true }); + }); + + it("downloads the installer, computes SHA-256, and generates Scoop + Winget manifests", async () => { + const fakeExe = new Uint8Array([0x4d, 0x5a, 0x90, 0x00]); + + const mockFetch = async (url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.includes("JCode-0.0.50-x64.exe")) { + return new Response(fakeExe, { status: 200 }); + } + return new Response("not found", { status: 404 }); + }; + + const result = await updatePackageManifests( + { + outputDir: testOutputDir, + releaseDate: "2026-06-07", + version: "0.0.50", + }, + mockFetch as typeof globalThis.fetch, + ); + + expect(result.hash).toBeTruthy(); + expect(result.scoopManifestPath).toBeTruthy(); + expect(result.wingetVersionPath).toBeTruthy(); + expect(result.wingetInstallerPath).toBeTruthy(); + expect(result.wingetLocalePath).toBeTruthy(); + + expect(existsSync(result.scoopManifestPath!)).toBe(true); + expect(existsSync(result.wingetVersionPath!)).toBe(true); + expect(existsSync(result.wingetInstallerPath!)).toBe(true); + expect(existsSync(result.wingetLocalePath!)).toBe(true); + + const scoopManifest = JSON.parse(readFileSync(result.scoopManifestPath!, "utf-8")); + expect(scoopManifest.version).toBe("0.0.50"); + expect(scoopManifest.license).toBe("MIT"); + expect(scoopManifest.architecture["64bit"].hash).toBeTruthy(); + expect(scoopManifest.architecture["64bit"].url).toContain("JCode-0.0.50-x64.exe"); + + const wingetInstaller = readFileSync(result.wingetInstallerPath!, "utf-8"); + expect(wingetInstaller).toContain("PackageIdentifier: Jay1.JCode"); + expect(wingetInstaller).toContain("JCode-0.0.50-x64.exe"); + expect(wingetInstaller).toContain('ReleaseDate: "2026-06-07"'); + + const wingetVersion = readFileSync(result.wingetVersionPath!, "utf-8"); + expect(wingetVersion).toContain("ManifestType: version"); + + const wingetLocale = readFileSync(result.wingetLocalePath!, "utf-8"); + expect(wingetLocale).toContain("Moniker: jcode"); + }); + + it("throws on failed download", async () => { + const mockFetch = async () => new Response("not found", { status: 404 }); + + await expect( + updatePackageManifests( + { + outputDir: testOutputDir, + releaseDate: "2026-06-07", + version: "0.0.99", + }, + mockFetch as typeof globalThis.fetch, + ), + ).rejects.toThrow(/Failed to download Windows installer/); + }); + + it("uses a custom repository when provided", async () => { + const fakeExe = new Uint8Array([0x4d, 0x5a]); + const mockFetch = async (url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.includes("MyOrg/my-app")) { + return new Response(fakeExe, { status: 200 }); + } + return new Response("not found", { status: 404 }); + }; + + const result = await updatePackageManifests( + { + outputDir: testOutputDir, + releaseDate: "2026-06-07", + repository: "MyOrg/my-app", + version: "0.0.50", + }, + mockFetch as typeof globalThis.fetch, + ); + + const wingetInstaller = readFileSync(result.wingetInstallerPath!, "utf-8"); + expect(wingetInstaller).toContain("PackageIdentifier: MyOrg.My-app"); + }); +}); + +describe("computeSha256FromResponse", () => { + it("computes SHA-256 from a streaming response body", async () => { + const data = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const response = new Response(data); + const hash = await computeSha256FromResponse(response); + + expect(hash).toMatch(/^[a-f0-9]{64}$/); + expect(hash).toBe("9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"); + }); + + it("throws on a response with no body", async () => { + const response = new Response(null, { status: 200 }); + + await expect(computeSha256FromResponse(response)).rejects.toThrow( + /Response body is not readable/, + ); + }); +}); diff --git a/scripts/update-package-manifests.ts b/scripts/update-package-manifests.ts new file mode 100644 index 000000000..505639085 --- /dev/null +++ b/scripts/update-package-manifests.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// FILE: update-package-manifests.ts +// Purpose: Downloads the Windows installer from a published GitHub release, computes SHA-256, and generates Scoop + Winget manifests. +// Layer: Release/package-manager orchestrator + +import { mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { createScoopManifest } from "./generate-scoop-manifest.ts"; +import { + createWingetInstallerManifest, + createWingetLocaleManifest, + createWingetVersionManifest, + repositoryToWingetId, +} from "./generate-winget-manifest.ts"; + +const DEFAULT_REPOSITORY = "Jay1/jcode"; + +export interface UpdatePackageManifestsOptions { + readonly outputDir?: string; + readonly releaseDate: string; + readonly repository?: string; + readonly version: string; +} + +export interface UpdatePackageManifestsResult { + readonly hash: string; + readonly scoopManifestPath: string; + readonly wingetVersionPath: string; + readonly wingetInstallerPath: string; + readonly wingetLocalePath: string; +} + +export function buildWindowsInstallerUrl(repository: string, version: string): string { + return `https://github.com/${repository}/releases/download/v${version}/JCode-${version}-x64.exe`; +} + +export async function computeSha256FromResponse(response: Response): Promise { + const hash = createHash("sha256"); + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Response body is not readable."); + } + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + } + + return hash.digest("hex"); +} + +export async function updatePackageManifests( + options: UpdatePackageManifestsOptions, + fetchFn: typeof globalThis.fetch = globalThis.fetch, +): Promise { + const repository = options.repository ?? DEFAULT_REPOSITORY; + const outputDir = options.outputDir ?? "packaging"; + + const installerUrl = buildWindowsInstallerUrl(repository, options.version); + const response = await fetchFn(installerUrl); + if (!response.ok) { + throw new Error( + `Failed to download Windows installer from ${installerUrl}: ${response.status} ${response.statusText}`, + ); + } + + const hash = await computeSha256FromResponse(response); + + const manifestInput = { + hash, + releaseDate: options.releaseDate, + repository, + version: options.version, + }; + + const scoopDir = join(outputDir, "scoop"); + const wingetId = repositoryToWingetId(repository); + const wingetDir = join(outputDir, "winget", wingetId); + + const scoopManifestPath = join(scoopDir, "jcode.json"); + mkdirSync(dirname(scoopManifestPath), { recursive: true }); + writeFileSync(scoopManifestPath, createScoopManifest(manifestInput)); + + const wingetVersionPath = join(wingetDir, `${wingetId}.yaml`); + const wingetInstallerPath = join(wingetDir, `${wingetId}.installer.yaml`); + const wingetLocalePath = join(wingetDir, `${wingetId}.locale.en-US.yaml`); + + mkdirSync(dirname(wingetVersionPath), { recursive: true }); + writeFileSync(wingetVersionPath, createWingetVersionManifest(manifestInput)); + writeFileSync(wingetInstallerPath, createWingetInstallerManifest(manifestInput)); + writeFileSync(wingetLocalePath, createWingetLocaleManifest(manifestInput)); + + return { + hash, + scoopManifestPath, + wingetInstallerPath, + wingetLocalePath, + wingetVersionPath, + }; +} + +interface CliOptions { + readonly outputDir: string; + readonly releaseDate: string; + readonly repository: string; + readonly version: string; +} + +function readCliOptions(args: ReadonlyArray): CliOptions { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || !value) { + throw new Error( + "Usage: node scripts/update-package-manifests.ts --version X.Y.Z --release-date YYYY-MM-DD [--output-dir packaging] [--repository owner/repo]", + ); + } + options.set(key, value); + } + + const version = options.get("--version"); + const releaseDate = options.get("--release-date"); + if (!version || !releaseDate) { + throw new Error("Missing required --version or --release-date option."); + } + + return { + version, + releaseDate, + outputDir: options.get("--output-dir") ?? "packaging", + repository: options.get("--repository") ?? DEFAULT_REPOSITORY, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const cliOptions = readCliOptions(process.argv.slice(2)); + const result = await updatePackageManifests(cliOptions); + + console.log(`SHA256: ${result.hash}`); + console.log(`Scoop manifest: ${result.scoopManifestPath}`); + console.log(`Winget version: ${result.wingetVersionPath}`); + console.log(`Winget installer: ${result.wingetInstallerPath}`); + console.log(`Winget locale: ${result.wingetLocalePath}`); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} From 47cbc1efda1afcbf126ba2e1883c038c36f14b5f Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 19:54:35 -0400 Subject: [PATCH 03/18] =?UTF-8?q?feat(windows):=20Phase=203=20=E2=80=94=20?= =?UTF-8?q?managed=20runtime=20profile=20auto-creation=20+=20sidecar=20hea?= =?UTF-8?q?lth/diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Slice 6 (#81) and Slice 7 (#85) of the Windows Turnkey Release. Slice 6 — Managed Runtime Profile Auto-Creation: - Contract schemas: ManagedRuntimeProfileAutoCreateRequest/Result - Auto-creates OpenCodeRuntimeProfile from sidecar snapshot - Clean install defaults: mode=managed, configMode=generated - Existing config defaults: mode=external, configMode=inherit - Profile deduplication: returns existing profile if ID matches - applyAutoCreatedProfile() returns ServerSettingsPatch - 7/7 tests passing Slice 7 — Sidecar Health Check, Repair, and Diagnostics: - Contract schemas: ManagedSidecarHealthCheck, RepairRequest/Result, Diagnostics - Health status mapping: ready+valid→healthy, ready+missing→unhealthy, error→unhealthy, idle/stopping→not_running, downloading→degraded - Repair: stop + optional forceRedownload + restart + health recheck - Diagnostics: health + platform info + sidecar snapshot - 18/18 tests passing Phase 2 files restored after Slice 7 agent regression. Total: 46/46 tests passing across all Phase 2+3 slices. --- .../src/provider/managedRuntimeHealth.test.ts | 290 ++++++++++++++++++ .../src/provider/managedRuntimeHealth.ts | 132 ++++++++ .../provider/managedRuntimeProfile.test.ts | 189 ++++++++++++ .../src/provider/managedRuntimeProfile.ts | 99 ++++++ packages/contracts/src/index.ts | 2 + .../contracts/src/managedRuntimeHealth.ts | 82 +++++ .../contracts/src/managedRuntimeProfile.ts | 44 +++ 7 files changed, 838 insertions(+) create mode 100644 apps/server/src/provider/managedRuntimeHealth.test.ts create mode 100644 apps/server/src/provider/managedRuntimeHealth.ts create mode 100644 apps/server/src/provider/managedRuntimeProfile.test.ts create mode 100644 apps/server/src/provider/managedRuntimeProfile.ts create mode 100644 packages/contracts/src/managedRuntimeHealth.ts create mode 100644 packages/contracts/src/managedRuntimeProfile.ts diff --git a/apps/server/src/provider/managedRuntimeHealth.test.ts b/apps/server/src/provider/managedRuntimeHealth.test.ts new file mode 100644 index 000000000..094f51c87 --- /dev/null +++ b/apps/server/src/provider/managedRuntimeHealth.test.ts @@ -0,0 +1,290 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +import type { ManagedSidecarSnapshot } from "@jcode/contracts"; + +import { + checkManagedSidecarHealth, + deriveHealthStatus, + exportManagedSidecarDiagnostics, + repairManagedSidecar, +} from "./managedRuntimeHealth.ts"; +import { ManagedSidecarError } from "./managedRuntimeLifecycle.ts"; + +import * as managedRuntimeDownload from "./managedRuntimeDownload.ts"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn((path: string) => typeof path === "string" && path.includes("opencode")), +})); + +vi.mock("./managedRuntimeDownload.ts", () => ({ + verifyManagedRuntimeBinary: vi.fn(() => + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ), +})); + +const mockVerify = vi.mocked(managedRuntimeDownload.verifyManagedRuntimeBinary); + +const READY_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "ready", + binaryPath: "/usr/local/bin/opencode", + serverUrl: "http://127.0.0.1:9876", + serverPassword: "test-password", +}); + +const ERROR_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "error", + error: "Spawn failed", +}); + +const IDLE_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "idle", +}); + +const DOWNLOADING_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "downloading", +}); + +function makeMockLifecycle(overrides?: { + startResult?: ManagedSidecarSnapshot; + startError?: ManagedSidecarError; + currentSnapshot?: ManagedSidecarSnapshot; +}) { + let currentSnapshot: ManagedSidecarSnapshot = overrides?.currentSnapshot ?? IDLE_SNAPSHOT; + + return { + startManagedRuntime: vi.fn(() => { + if (overrides?.startError) { + currentSnapshot = { state: "error", error: overrides.startError.message }; + return Effect.fail(overrides.startError); + } + currentSnapshot = overrides?.startResult ?? READY_SNAPSHOT; + return Effect.succeed(currentSnapshot); + }), + stopManagedRuntime: vi.fn(() => { + currentSnapshot = { state: "idle" }; + return Effect.succeed(currentSnapshot); + }), + restartManagedRuntime: vi.fn(() => Effect.succeed(READY_SNAPSHOT)), + getManagedRuntimeStatus: vi.fn(() => Effect.succeed(currentSnapshot)), + }; +} + +describe("deriveHealthStatus", () => { + it("returns healthy when ready with binary and reachable server", () => { + expect(deriveHealthStatus("ready", true, true, true)).toBe("healthy"); + }); + + it("returns degraded when ready but server unreachable", () => { + expect(deriveHealthStatus("ready", true, true, false)).toBe("degraded"); + }); + + it("returns unhealthy when ready but binary missing", () => { + expect(deriveHealthStatus("ready", false, false, true)).toBe("unhealthy"); + }); + + it("returns unhealthy when error state", () => { + expect(deriveHealthStatus("error", false, false, false)).toBe("unhealthy"); + }); + + it("returns not_running when idle", () => { + expect(deriveHealthStatus("idle", false, false, false)).toBe("not_running"); + }); + + it("returns degraded when downloading (transient)", () => { + expect(deriveHealthStatus("downloading", false, false, false)).toBe("degraded"); + }); +}); + +describe("checkManagedSidecarHealth", () => { + it("returns healthy when ready with valid binary and reachable server", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: READY_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("healthy"); + expect(result.sidecarState).toBe("ready"); + expect(result.serverReachable).toBe(true); + expect(result.binaryExists).toBe(true); + expect(result.checkedAt).toBeTruthy(); + }); + + it("returns unhealthy when ready with missing binary", async () => { + const snapshot: ManagedSidecarSnapshot = { state: "ready", serverUrl: "http://127.0.0.1:9999" }; + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: READY_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("healthy"); + expect(result.sidecarState).toBe("ready"); + expect(result.serverReachable).toBe(true); + expect(result.binaryExists).toBe(true); + expect(result.checkedAt).toBeTruthy(); + }); + + it("returns unhealthy when ready with missing binary", async () => { + const snapshot: ManagedSidecarSnapshot = { state: "ready", serverUrl: "http://127.0.0.1:9999" }; + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: snapshot }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("unhealthy"); + expect(result.binaryExists).toBe(false); + expect(result.binaryValid).toBe(false); + }); + + it("returns unhealthy with error message when in error state", async () => { + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: ERROR_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("unhealthy"); + expect(result.sidecarState).toBe("error"); + expect(result.error).toBe("Spawn failed"); + }); + + it("returns not_running when idle", async () => { + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: IDLE_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("not_running"); + expect(result.sidecarState).toBe("idle"); + }); + + it("returns degraded when downloading (transient)", async () => { + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: DOWNLOADING_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("degraded"); + expect(result.sidecarState).toBe("downloading"); + }); + + it("returns not_running when stopping", async () => { + const snapshot: ManagedSidecarSnapshot = { state: "stopping" }; + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: snapshot }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.status).toBe("not_running"); + }); +}); + +describe("repairManagedSidecar", () => { + it("performs successful repair and returns healthy result", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const lifecycle = makeMockLifecycle(); + + const result = await Effect.runPromise( + repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.success).toBe(true); + expect(lifecycle.stopManagedRuntime).toHaveBeenCalledOnce(); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledOnce(); + }); + + it("calls startManagedRuntime with forceDownload when forceRedownload is true", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const lifecycle = makeMockLifecycle(); + + await Effect.runPromise( + repairManagedSidecar({ + sidecarSnapshot: IDLE_SNAPSHOT, + lifecycle, + forceRedownload: true, + }).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + }); + + it("returns error result when startManagedRuntime fails", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: false, sha256: null, expectedSha256: null, valid: false }), + ); + + const startError = new ManagedSidecarError({ + stage: "spawn", + message: "Binary failed to start", + }); + + const lifecycle = makeMockLifecycle({ startError: startError }); + + const result = await Effect.runPromise( + repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Binary failed to start"); + }); +}); + +describe("exportManagedSidecarDiagnostics", () => { + it("returns full diagnostics bundle with correct shape", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + exportManagedSidecarDiagnostics({ sidecarSnapshot: READY_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.generatedAt).toBeTruthy(); + expect(result.health).toBeDefined(); + expect(result.health.status).toBe("healthy"); + expect(result.platform).toBeDefined(); + expect(result.sidecarSnapshot).toBe(READY_SNAPSHOT); + }); + + it("populates platform info from process globals", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + exportManagedSidecarDiagnostics({ sidecarSnapshot: READY_SNAPSHOT }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + + expect(result.platform.os).toBe(process.platform); + expect(result.platform.arch).toBe(process.arch); + expect(result.platform.nodeVersion).toBe(process.version); + }); +}); diff --git a/apps/server/src/provider/managedRuntimeHealth.ts b/apps/server/src/provider/managedRuntimeHealth.ts new file mode 100644 index 000000000..3572cab7a --- /dev/null +++ b/apps/server/src/provider/managedRuntimeHealth.ts @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs"; + +import type { + ManagedSidecarDiagnostics, + ManagedSidecarHealthCheck, + ManagedSidecarHealthStatus, + ManagedSidecarRepairResult, + ManagedSidecarSnapshot, +} from "@jcode/contracts"; +import { Effect } from "effect"; + +import { verifyManagedRuntimeBinary } from "./managedRuntimeDownload.ts"; +import type { ManagedSidecarLifecycleShape } from "./managedRuntimeLifecycle.ts"; +import { ManagedSidecarError } from "./managedRuntimeLifecycle.ts"; + +const isoNow = (): string => new Date().toISOString(); + +export const checkBinaryExists = (binaryPath: string | undefined): boolean => + binaryPath != null && binaryPath.length > 0 && existsSync(binaryPath); + +const checkServerReachable = (serverUrl: string | undefined): boolean => + serverUrl != null && serverUrl.length > 0; + +export const deriveHealthStatus = ( + state: ManagedSidecarSnapshot["state"], + binaryExists: boolean, + binaryValid: boolean, + serverReachable: boolean, +): ManagedSidecarHealthStatus => { + if (state === "ready") { + if (!binaryExists || !binaryValid) return "unhealthy"; + if (!serverReachable) return "degraded"; + return "healthy"; + } + if (state === "error") return "unhealthy"; + if (state === "idle" || state === "stopping") return "not_running"; + return "degraded"; +}; + +export const checkManagedSidecarHealth = (input: { + sidecarSnapshot: ManagedSidecarSnapshot; +}): Effect.Effect => + Effect.gen(function* () { + const snapshot = input.sidecarSnapshot; + const binaryExists = checkBinaryExists(snapshot.binaryPath); + const serverReachable = checkServerReachable(snapshot.serverUrl); + + let binaryValid = false; + if (binaryExists && snapshot.binaryPath) { + const verification = yield* verifyManagedRuntimeBinary(undefined, snapshot.binaryPath); + binaryValid = verification.valid; + } + + const status = deriveHealthStatus(snapshot.state, binaryExists, binaryValid, serverReachable); + + return { + status, + sidecarState: snapshot.state, + binaryPath: snapshot.binaryPath, + binaryExists, + binaryValid, + serverUrl: snapshot.serverUrl, + serverReachable, + error: snapshot.error, + checkedAt: isoNow(), + } satisfies ManagedSidecarHealthCheck; + }); + +export const repairManagedSidecar = (input: { + sidecarSnapshot: ManagedSidecarSnapshot; + lifecycle: ManagedSidecarLifecycleShape; + forceRedownload?: boolean; +}): Effect.Effect => { + const { lifecycle, forceRedownload = false } = input; + + return Effect.gen(function* () { + yield* lifecycle + .stopManagedRuntime() + .pipe(Effect.catchTag("ManagedSidecarError", () => Effect.void)); + + const startRequest = forceRedownload ? { forceDownload: true } : undefined; + + const startResult = yield* lifecycle.startManagedRuntime(startRequest).pipe( + Effect.mapError((err) => + err instanceof ManagedSidecarError + ? err + : new ManagedSidecarError({ + stage: "repair", + message: String(err), + cause: err, + }), + ), + ); + + const health = yield* checkManagedSidecarHealth({ sidecarSnapshot: startResult }); + + return { + success: health.status === "healthy" || health.status === "degraded", + health, + } satisfies ManagedSidecarRepairResult; + }).pipe( + Effect.catchTag("ManagedSidecarError", (err) => + Effect.gen(function* () { + const latestSnapshot = yield* lifecycle.getManagedRuntimeStatus(); + const health = yield* checkManagedSidecarHealth({ sidecarSnapshot: latestSnapshot }); + return { + success: false, + health, + error: err.message, + } satisfies ManagedSidecarRepairResult; + }), + ), + ); +}; + +export const exportManagedSidecarDiagnostics = (input: { + sidecarSnapshot: ManagedSidecarSnapshot; +}): Effect.Effect => + Effect.gen(function* () { + const health = yield* checkManagedSidecarHealth(input); + + return { + generatedAt: isoNow(), + health, + platform: { + os: process.platform, + arch: process.arch, + nodeVersion: process.version, + }, + sidecarSnapshot: input.sidecarSnapshot, + } satisfies ManagedSidecarDiagnostics; + }); diff --git a/apps/server/src/provider/managedRuntimeProfile.test.ts b/apps/server/src/provider/managedRuntimeProfile.test.ts new file mode 100644 index 000000000..d18cd357a --- /dev/null +++ b/apps/server/src/provider/managedRuntimeProfile.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import type { ManagedSidecarSnapshot, ServerSettings } from "@jcode/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@jcode/contracts"; + +import { + autoCreateManagedRuntimeProfile, + applyAutoCreatedProfile, +} from "./managedRuntimeProfile.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const READY_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "ready", + binaryPath: "/opt/jcode/opencode-sidecar", + serverUrl: "http://127.0.0.1:42001", + serverPassword: "s3cret", +}); + +const EMPTY_SETTINGS: ServerSettings = DEFAULT_SERVER_SETTINGS; + +function settingsWithProfiles( + ...profiles: ServerSettings["providers"]["opencode"]["runtimeProfiles"] +): ServerSettings { + return { + ...EMPTY_SETTINGS, + providers: { + ...EMPTY_SETTINGS.providers, + opencode: { + ...EMPTY_SETTINGS.providers.opencode, + runtimeProfiles: profiles, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// autoCreateManagedRuntimeProfile +// --------------------------------------------------------------------------- + +describe("autoCreateManagedRuntimeProfile", () => { + it("creates a managed profile for clean install", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + settings: EMPTY_SETTINGS, + }); + + expect(result.created).toBe(true); + expect(result.profile.id).toBe("managed-opencode-sidecar"); + expect(result.profile.label).toBe("Managed OpenCode (sidecar)"); + expect(result.profile.provider).toBe("opencode"); + expect(result.profile.mode).toBe("managed"); + expect(result.profile.configMode).toBe("generated"); + expect(result.profile.binaryPath).toBe("/opt/jcode/opencode-sidecar"); + expect(result.profile.serverUrl).toBe("http://127.0.0.1:42001"); + expect(result.profile.capabilityPolicy).toBe("warn"); + expect(result.activeProfileId).toBe("managed-opencode-sidecar"); + }); + + it("creates an external profile when existing config detected", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: true, + settings: EMPTY_SETTINGS, + }); + + expect(result.created).toBe(true); + expect(result.profile.id).toBe("external-opencode-existing"); + expect(result.profile.label).toBe("OpenCode (existing config)"); + expect(result.profile.provider).toBe("opencode"); + expect(result.profile.mode).toBe("external"); + expect(result.profile.configMode).toBe("inherit"); + expect(result.profile.binaryPath).toBe("/opt/jcode/opencode-sidecar"); + expect(result.profile.serverUrl).toBeUndefined(); + expect(result.profile.capabilityPolicy).toBe("warn"); + expect(result.activeProfileId).toBe("external-opencode-existing"); + }); + + it("returns existing profile without duplication when ID matches", () => { + const existing = { + id: "managed-opencode-sidecar", + label: "Managed OpenCode (sidecar)", + provider: "opencode" as const, + mode: "managed" as const, + configMode: "generated" as const, + binaryPath: "/old/path", + serverUrl: "http://old-url", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn" as const, + }; + + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + settings: settingsWithProfiles(existing), + }); + + expect(result.created).toBe(false); + expect(result.profile).toBe(existing); + expect(result.activeProfileId).toBe("managed-opencode-sidecar"); + }); + + it("sets configMode to generated for managed sidecar", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + settings: EMPTY_SETTINGS, + }); + + expect(result.profile.configMode).toBe("generated"); + }); + + it("sets configMode to inherit for external with existing config", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: true, + settings: EMPTY_SETTINGS, + }); + + expect(result.profile.configMode).toBe("inherit"); + }); +}); + +// --------------------------------------------------------------------------- +// applyAutoCreatedProfile +// --------------------------------------------------------------------------- + +describe("applyAutoCreatedProfile", () => { + it("returns patch that appends profile and sets active ID when created", () => { + const autoResult = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + settings: EMPTY_SETTINGS, + }); + + const patch = applyAutoCreatedProfile(autoResult, EMPTY_SETTINGS); + + expect(patch.providers?.opencode?.activeRuntimeProfileId).toBe("managed-opencode-sidecar"); + const profiles = patch.providers?.opencode?.runtimeProfiles; + expect(profiles).toHaveLength(1); + expect(profiles?.[0]?.id).toBe("managed-opencode-sidecar"); + }); + + it("does not duplicate profiles when created is false", () => { + const existing = { + id: "managed-opencode-sidecar", + label: "Managed OpenCode (sidecar)", + provider: "opencode" as const, + mode: "managed" as const, + configMode: "generated" as const, + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn" as const, + }; + + const settings = settingsWithProfiles(existing); + + const autoResult = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + settings, + }); + + expect(autoResult.created).toBe(false); + + const patch = applyAutoCreatedProfile(autoResult, settings); + + expect(patch.providers?.opencode?.activeRuntimeProfileId).toBe("managed-opencode-sidecar"); + expect(patch.providers?.opencode?.runtimeProfiles).toHaveLength(1); + }); +}); diff --git a/apps/server/src/provider/managedRuntimeProfile.ts b/apps/server/src/provider/managedRuntimeProfile.ts new file mode 100644 index 000000000..2f2d3cca9 --- /dev/null +++ b/apps/server/src/provider/managedRuntimeProfile.ts @@ -0,0 +1,99 @@ +import type { + ManagedSidecarSnapshot, + OpenCodeRuntimeProfile, + ServerSettings, + ServerSettingsPatch, +} from "@jcode/contracts"; + +const EMPTY_CAPABILITY_ARRAYS = { + skillRoots: [] as readonly string[], + pluginRoots: [] as readonly string[], + requiredCommands: [] as readonly string[], + requiredSkills: [] as readonly string[], + requiredPlugins: [] as readonly string[], + requiredAgents: [] as readonly string[], + requiredModels: [] as readonly string[], + requiredEnv: [] as readonly string[], + requirements: [] as readonly unknown[], + capabilityPolicy: "warn" as const, +}; + +function makeManagedProfile(snapshot: ManagedSidecarSnapshot): OpenCodeRuntimeProfile { + return { + id: "managed-opencode-sidecar", + label: "Managed OpenCode (sidecar)", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: snapshot.binaryPath ?? undefined, + serverUrl: snapshot.serverUrl ?? undefined, + ...EMPTY_CAPABILITY_ARRAYS, + }; +} + +function makeExternalProfile(snapshot: ManagedSidecarSnapshot): OpenCodeRuntimeProfile { + return { + id: "external-opencode-existing", + label: "OpenCode (existing config)", + provider: "opencode", + mode: "external", + configMode: "inherit", + binaryPath: snapshot.binaryPath ?? undefined, + ...EMPTY_CAPABILITY_ARRAYS, + }; +} + +export interface AutoCreateProfileInput { + readonly sidecarSnapshot: ManagedSidecarSnapshot; + readonly existingConfigDetected: boolean; + readonly settings: ServerSettings; +} + +export interface AutoCreateProfileResult { + readonly profile: OpenCodeRuntimeProfile; + readonly created: boolean; + readonly activeProfileId: string; +} + +export function autoCreateManagedRuntimeProfile( + input: AutoCreateProfileInput, +): AutoCreateProfileResult { + const desiredProfile = input.existingConfigDetected + ? makeExternalProfile(input.sidecarSnapshot) + : makeManagedProfile(input.sidecarSnapshot); + + const existingProfile = input.settings.providers.opencode.runtimeProfiles.find( + (p) => p.id === desiredProfile.id, + ); + + if (existingProfile) { + return { + profile: existingProfile, + created: false, + activeProfileId: existingProfile.id, + }; + } + + return { + profile: desiredProfile, + created: true, + activeProfileId: desiredProfile.id, + }; +} + +export function applyAutoCreatedProfile( + result: AutoCreateProfileResult, + settings: ServerSettings, +): ServerSettingsPatch { + const currentProfiles = settings.providers.opencode.runtimeProfiles; + const updatedProfiles = result.created ? [...currentProfiles, result.profile] : currentProfiles; + + return { + providers: { + opencode: { + runtimeProfiles: updatedProfiles, + activeRuntimeProfileId: result.activeProfileId, + }, + }, + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 639c662c2..66bb44939 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -22,3 +22,5 @@ export * from "./managedRuntime"; export * from "./managedRuntimeLifecycle"; export * from "./rpc"; export * from "./firstRunWizard"; +export * from "./managedRuntimeProfile"; +export * from "./managedRuntimeHealth"; diff --git a/packages/contracts/src/managedRuntimeHealth.ts b/packages/contracts/src/managedRuntimeHealth.ts new file mode 100644 index 000000000..b0f60d373 --- /dev/null +++ b/packages/contracts/src/managedRuntimeHealth.ts @@ -0,0 +1,82 @@ +// FILE: managedRuntimeHealth.ts +// Purpose: Contract schemas for managed sidecar health check, repair, and diagnostics. +// Layer: packages/contracts — shared types across server, web, and desktop. +// Depends on: effect (Schema), ./managedRuntimeLifecycle, ./baseSchemas + +/** + * Managed sidecar health, repair, and diagnostics contract schemas. + * + * Extends the lifecycle contracts with runtime health assessment, repair + * operations (re-download + restart), and structured diagnostics export. + * + * @module managedRuntimeHealth + */ + +import { Schema } from "effect"; + +import { IsoDateTime, NonNegativeInt } from "./baseSchemas"; +import { ManagedSidecarSnapshot, ManagedSidecarState } from "./managedRuntimeLifecycle"; + +// --------------------------------------------------------------------------- +// Health status +// --------------------------------------------------------------------------- + +export const ManagedSidecarHealthStatus = Schema.Literals([ + "healthy", + "degraded", + "unhealthy", + "not_running", + "repairing", +]); +export type ManagedSidecarHealthStatus = typeof ManagedSidecarHealthStatus.Type; + +// --------------------------------------------------------------------------- +// Health check result +// --------------------------------------------------------------------------- + +export const ManagedSidecarHealthCheck = Schema.Struct({ + status: ManagedSidecarHealthStatus, + sidecarState: ManagedSidecarState, + binaryPath: Schema.optional(Schema.String), + binaryExists: Schema.Boolean, + binaryValid: Schema.Boolean, + serverUrl: Schema.optional(Schema.String), + serverReachable: Schema.Boolean, + uptimeSeconds: Schema.optional(NonNegativeInt), + error: Schema.optional(Schema.String), + checkedAt: IsoDateTime, +}); +export type ManagedSidecarHealthCheck = typeof ManagedSidecarHealthCheck.Type; + +// --------------------------------------------------------------------------- +// Repair +// --------------------------------------------------------------------------- + +export const ManagedSidecarRepairRequest = Schema.Struct({ + forceRedownload: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type ManagedSidecarRepairRequest = typeof ManagedSidecarRepairRequest.Type; + +export const ManagedSidecarRepairResult = Schema.Struct({ + success: Schema.Boolean, + health: ManagedSidecarHealthCheck, + error: Schema.optional(Schema.String), +}); +export type ManagedSidecarRepairResult = typeof ManagedSidecarRepairResult.Type; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +export const ManagedSidecarDiagnostics = Schema.Struct({ + generatedAt: IsoDateTime, + health: ManagedSidecarHealthCheck, + platform: Schema.Struct({ + os: Schema.String, + arch: Schema.String, + nodeVersion: Schema.String, + }), + binaryVersion: Schema.optional(Schema.String), + sidecarSnapshot: ManagedSidecarSnapshot, +}); +export type ManagedSidecarDiagnostics = typeof ManagedSidecarDiagnostics.Type; diff --git a/packages/contracts/src/managedRuntimeProfile.ts b/packages/contracts/src/managedRuntimeProfile.ts new file mode 100644 index 000000000..42e3b36bd --- /dev/null +++ b/packages/contracts/src/managedRuntimeProfile.ts @@ -0,0 +1,44 @@ +// FILE: managedRuntimeProfile.ts +// Purpose: Contract schemas for managed runtime profile auto-creation. +// Layer: packages/contracts — shared types across server, web, and desktop. +// Depends on: effect (Schema), ./managedRuntimeLifecycle, ./baseSchemas + +/** + * Managed runtime profile auto-creation contract schemas. + * + * When the managed sidecar starts successfully, the server auto-creates an + * `OpenCodeRuntimeProfile` so that future sessions can reconnect without + * re-discovery. This module defines the request/result shapes for that flow. + * + * @module managedRuntimeProfile + */ + +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ManagedSidecarSnapshot } from "./managedRuntimeLifecycle"; +import { OpenCodeRuntimeProfile } from "./providerDiscovery"; + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +export const ManagedRuntimeProfileAutoCreateRequest = Schema.Struct({ + sidecarSnapshot: ManagedSidecarSnapshot, + existingConfigDetected: Schema.Boolean, + profileId: Schema.optional(TrimmedNonEmptyString), +}); +export type ManagedRuntimeProfileAutoCreateRequest = + typeof ManagedRuntimeProfileAutoCreateRequest.Type; + +// --------------------------------------------------------------------------- +// Result +// --------------------------------------------------------------------------- + +export const ManagedRuntimeProfileAutoCreateResult = Schema.Struct({ + profile: OpenCodeRuntimeProfile, + created: Schema.Boolean, + activeProfileId: TrimmedNonEmptyString, +}); +export type ManagedRuntimeProfileAutoCreateResult = + typeof ManagedRuntimeProfileAutoCreateResult.Type; From c48eab0effe178702e70dd545a667ffd24aa6792 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 21:48:04 -0400 Subject: [PATCH 04/18] test(windows): remove duplicate managed runtime health case --- .../src/provider/managedRuntimeHealth.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/server/src/provider/managedRuntimeHealth.test.ts b/apps/server/src/provider/managedRuntimeHealth.test.ts index 094f51c87..9677b74fd 100644 --- a/apps/server/src/provider/managedRuntimeHealth.test.ts +++ b/apps/server/src/provider/managedRuntimeHealth.test.ts @@ -116,22 +116,6 @@ describe("checkManagedSidecarHealth", () => { expect(result.checkedAt).toBeTruthy(); }); - it("returns unhealthy when ready with missing binary", async () => { - const snapshot: ManagedSidecarSnapshot = { state: "ready", serverUrl: "http://127.0.0.1:9999" }; - - const result = await Effect.runPromise( - checkManagedSidecarHealth({ sidecarSnapshot: READY_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), - ), - ); - - expect(result.status).toBe("healthy"); - expect(result.sidecarState).toBe("ready"); - expect(result.serverReachable).toBe(true); - expect(result.binaryExists).toBe(true); - expect(result.checkedAt).toBeTruthy(); - }); - it("returns unhealthy when ready with missing binary", async () => { const snapshot: ManagedSidecarSnapshot = { state: "ready", serverUrl: "http://127.0.0.1:9999" }; From 11ef946de62d72f1bb33564c78140ff346066257 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 21:53:28 -0400 Subject: [PATCH 05/18] fix(windows): type managed runtime profile requirements --- apps/server/src/provider/managedRuntimeProfile.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/managedRuntimeProfile.ts b/apps/server/src/provider/managedRuntimeProfile.ts index 2f2d3cca9..cff1fa08e 100644 --- a/apps/server/src/provider/managedRuntimeProfile.ts +++ b/apps/server/src/provider/managedRuntimeProfile.ts @@ -1,5 +1,6 @@ import type { ManagedSidecarSnapshot, + OpenCodeRuntimeCapabilityRequirement, OpenCodeRuntimeProfile, ServerSettings, ServerSettingsPatch, @@ -14,7 +15,7 @@ const EMPTY_CAPABILITY_ARRAYS = { requiredAgents: [] as readonly string[], requiredModels: [] as readonly string[], requiredEnv: [] as readonly string[], - requirements: [] as readonly unknown[], + requirements: [] as readonly OpenCodeRuntimeCapabilityRequirement[], capabilityPolicy: "warn" as const, }; From 9adb38863cf59e40fa9075d3cd98210d9fa09aee Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 7 Jun 2026 21:53:28 -0400 Subject: [PATCH 06/18] fix(windows): type managed runtime health effects --- .../src/provider/managedRuntimeHealth.test.ts | 32 ++++++++++++------- .../src/provider/managedRuntimeHealth.ts | 32 ++++++++++++------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/managedRuntimeHealth.test.ts b/apps/server/src/provider/managedRuntimeHealth.test.ts index 9677b74fd..e2e5aae0c 100644 --- a/apps/server/src/provider/managedRuntimeHealth.test.ts +++ b/apps/server/src/provider/managedRuntimeHealth.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it, vi } from "vitest"; -import { Effect } from "effect"; +import { Effect, Scope } from "effect"; import type { ManagedSidecarSnapshot } from "@jcode/contracts"; @@ -184,11 +184,13 @@ describe("repairManagedSidecar", () => { ); const lifecycle = makeMockLifecycle(); + const scope = await Effect.runPromise(Scope.make()); const result = await Effect.runPromise( - repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }).pipe( - Effect.provide(NodeServices.layer), - ), + Scope.provide( + repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }), + scope, + ).pipe(Effect.provide(NodeServices.layer)), ); expect(result.success).toBe(true); @@ -202,13 +204,17 @@ describe("repairManagedSidecar", () => { ); const lifecycle = makeMockLifecycle(); + const scope = await Effect.runPromise(Scope.make()); await Effect.runPromise( - repairManagedSidecar({ - sidecarSnapshot: IDLE_SNAPSHOT, - lifecycle, - forceRedownload: true, - }).pipe(Effect.provide(NodeServices.layer)), + Scope.provide( + repairManagedSidecar({ + sidecarSnapshot: IDLE_SNAPSHOT, + lifecycle, + forceRedownload: true, + }), + scope, + ).pipe(Effect.provide(NodeServices.layer)), ); expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); @@ -225,11 +231,13 @@ describe("repairManagedSidecar", () => { }); const lifecycle = makeMockLifecycle({ startError: startError }); + const scope = await Effect.runPromise(Scope.make()); const result = await Effect.runPromise( - repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }).pipe( - Effect.provide(NodeServices.layer), - ), + Scope.provide( + repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }), + scope, + ).pipe(Effect.provide(NodeServices.layer)), ); expect(result.success).toBe(false); diff --git a/apps/server/src/provider/managedRuntimeHealth.ts b/apps/server/src/provider/managedRuntimeHealth.ts index 3572cab7a..335a24b28 100644 --- a/apps/server/src/provider/managedRuntimeHealth.ts +++ b/apps/server/src/provider/managedRuntimeHealth.ts @@ -7,7 +7,9 @@ import type { ManagedSidecarRepairResult, ManagedSidecarSnapshot, } from "@jcode/contracts"; -import { Effect } from "effect"; +import { Effect, Scope } from "effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { verifyManagedRuntimeBinary } from "./managedRuntimeDownload.ts"; import type { ManagedSidecarLifecycleShape } from "./managedRuntimeLifecycle.ts"; @@ -39,30 +41,32 @@ export const deriveHealthStatus = ( export const checkManagedSidecarHealth = (input: { sidecarSnapshot: ManagedSidecarSnapshot; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const snapshot = input.sidecarSnapshot; const binaryExists = checkBinaryExists(snapshot.binaryPath); const serverReachable = checkServerReachable(snapshot.serverUrl); - let binaryValid = false; - if (binaryExists && snapshot.binaryPath) { - const verification = yield* verifyManagedRuntimeBinary(undefined, snapshot.binaryPath); - binaryValid = verification.valid; - } + const binaryValid = + binaryExists && snapshot.binaryPath + ? yield* verifyManagedRuntimeBinary(undefined, snapshot.binaryPath).pipe( + Effect.map((verification) => verification.valid), + Effect.catch(() => Effect.succeed(false)), + ) + : false; const status = deriveHealthStatus(snapshot.state, binaryExists, binaryValid, serverReachable); return { status, sidecarState: snapshot.state, - binaryPath: snapshot.binaryPath, binaryExists, binaryValid, - serverUrl: snapshot.serverUrl, serverReachable, - error: snapshot.error, checkedAt: isoNow(), + ...(snapshot.binaryPath ? { binaryPath: snapshot.binaryPath } : {}), + ...(snapshot.serverUrl ? { serverUrl: snapshot.serverUrl } : {}), + ...(snapshot.error ? { error: snapshot.error } : {}), } satisfies ManagedSidecarHealthCheck; }); @@ -70,7 +74,11 @@ export const repairManagedSidecar = (input: { sidecarSnapshot: ManagedSidecarSnapshot; lifecycle: ManagedSidecarLifecycleShape; forceRedownload?: boolean; -}): Effect.Effect => { +}): Effect.Effect< + ManagedSidecarRepairResult, + ManagedSidecarError, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => { const { lifecycle, forceRedownload = false } = input; return Effect.gen(function* () { @@ -115,7 +123,7 @@ export const repairManagedSidecar = (input: { export const exportManagedSidecarDiagnostics = (input: { sidecarSnapshot: ManagedSidecarSnapshot; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const health = yield* checkManagedSidecarHealth(input); From b3b02e4106e3be4cde54d151e8b98dcbf37682e7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 17 Jun 2026 12:56:48 -0400 Subject: [PATCH 07/18] Update project files --- CONTEXT.md | 4 +- .../provider/Layers/OpenCodeAdapter.test.ts | 432 ++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 115 ++- .../src/provider/firstRunWizard.test.ts | 158 +++- apps/server/src/provider/firstRunWizard.ts | 93 +- .../src/provider/managedRuntimeHealth.test.ts | 244 ++++- .../src/provider/managedRuntimeHealth.ts | 179 +++- .../provider/managedRuntimeLifecycle.test.ts | 469 +++++++--- .../src/provider/managedRuntimeLifecycle.ts | 145 ++- .../provider/managedRuntimeProfile.test.ts | 31 + .../src/provider/managedRuntimeProfile.ts | 37 +- .../provider/openCodeRuntimeHealth.test.ts | 119 +++ .../src/provider/openCodeRuntimeHealth.ts | 8 +- .../src/provider/openCodeRuntimeProfiles.ts | 16 +- .../src/provider/opencodeRuntime.test.ts | 10 + apps/server/src/provider/opencodeRuntime.ts | 13 + .../provider/providerCredentialScan.test.ts | 6 +- .../src/provider/providerCredentialScan.ts | 12 + apps/server/src/provider/runtimeLayer.ts | 10 +- apps/server/src/wsRpc.test.ts | 408 ++++++++ apps/server/src/wsRpc.ts | 879 ++++++++++++------ .../src/components/EventRouter.browser.tsx | 100 +- .../src/components/FirstRunWizard.browser.tsx | 315 +++++++ apps/web/src/components/FirstRunWizard.tsx | 65 +- .../OpenCodeRuntimeSettingsPanel.browser.tsx | 182 ++++ .../OpenCodeRuntimeSettingsPanel.tsx | 222 ++++- apps/web/src/routes/__root.tsx | 38 +- apps/web/src/wsNativeApi.test.ts | 111 ++- apps/web/src/wsNativeApi.ts | 213 +++-- ...mote-client-runtime-ws-rpc-scope-wiring.md | 6 +- ...07-parallel-windows-wsl-backend-routing.md | 4 +- ...-scoped-remote-client-capability-tokens.md | 2 +- packages/contracts/src/ipc.ts | 20 +- packages/contracts/src/ipc.typecheck.ts | 25 + .../src/managedRuntimeHealth.test.ts | 47 + .../contracts/src/managedRuntimeHealth.ts | 15 +- packages/contracts/src/rpc.test.ts | 19 +- packages/contracts/src/rpc.ts | 60 ++ packages/contracts/src/ws.test.ts | 56 ++ packages/contracts/src/ws.ts | 7 + 40 files changed, 4260 insertions(+), 635 deletions(-) create mode 100644 apps/server/src/wsRpc.test.ts create mode 100644 apps/web/src/components/FirstRunWizard.browser.tsx create mode 100644 apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx create mode 100644 packages/contracts/src/ipc.typecheck.ts create mode 100644 packages/contracts/src/managedRuntimeHealth.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index b33f1ab63..c532427be 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,7 +30,7 @@ The first remote client runtime capability set should be observe-and-approve: vi Remote clients should authenticate through owner-issued capability tokens with explicit scopes, such as reading selected thread state, responding to approvals, or answering user-input requests. Remote client runtime work must not reuse the dev automation access grant, which is limited to trusted loopback browser automation. -ADR 0005 (Accepted) defines the scope model: four v1 capability scopes (`thread:read`, `approval:respond`, `user_input:respond`, `provider_status:read`), optional resource scoping by project or thread ID, scopes stored directly on `AuthClientSession` and `AuthPairingLink` (not a separate table or JWT claims), owner sessions implicitly hold all scopes, and scope checks use the `requireScope` guard function. The first guarded route is `/api/auth/clients` (requires `provider_status:read`). +ADR 0008 (Accepted) defines the scoped remote-client capability token model: four v1 capability scopes (`thread:read`, `approval:respond`, `user_input:respond`, `provider_status:read`), optional resource scoping by project or thread ID, scopes stored directly on `AuthClientSession` and `AuthPairingLink` (not a separate table or JWT claims), owner sessions implicitly hold all scopes, and scope checks use the `requireScope` guard function. ADR 0006 wires those scopes into WS RPC guards. The scoped remote client auth model changes the Server Auth Boundary and should be captured in an ADR before implementation begins. @@ -212,7 +212,7 @@ ADR 0007 (Proposed) defines the design-only first slice. Key decisions: - Project-to-backend routing is path-based (WSL `\\wsl$\` paths detected at project-open time) with user override in `.jcode/settings.json`. Threads inherit their project's backend. - Backend lifecycle: unknown → probing → healthy → degraded → removed, with periodic health checks for WSL backends via `wsl.exe`. - Transport abstraction: `BackendTransport` interface with `LocalTransport` (direct spawn) and `WslTransport` (spawn via `wsl.exe -d `). Path translation via `BackendPathResolver`. -- Auth bootstrap uses the existing server auth model (ADR 0005 scopes apply at server level, not per-backend). WSL requires no separate auth — `wsl.exe` inherits the Windows user. +- Auth bootstrap uses the existing server auth model (ADR 0008 capability scopes apply at server level, not per-backend). WSL requires no separate auth — `wsl.exe` inherits the Windows user. - Failure states: degraded backends trigger reconnect banners, terminated distributions show migration prompts, no global "WSL mode" toggle. ### Project Identity diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index be689fa6c..69a60adfb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,16 +1,21 @@ -import { ThreadId } from "@jcode/contracts"; +import { ThreadId, type ManagedSidecarSnapshot } from "@jcode/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { Model, OpencodeClient, Part, Provider } from "@opencode-ai/sdk/v2"; import { Effect, Fiber, Layer, Stream } from "effect"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { type OpenCodeCliModelDescriptor, OpenCodeRuntimeError, type OpenCodeInventory, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; +import { + ManagedSidecarLifecycle, + type ManagedSidecarLifecycleShape, +} from "../managedRuntimeLifecycle.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; import { flattenOpenCodeCliModels, @@ -114,8 +119,11 @@ function createMockOpenCodeRuntime(options?: { data: Array<{ info: Record; parts: Part[] }>; }>; readonly session?: Record; + readonly connectedServerExternal?: boolean; }) { const abortCalls: Array<{ sessionID: string }> = []; + const clientCalls: Array[0]> = []; + const connectCalls: Array[0]> = []; const createCalls: Array> = []; const promptCalls: Array> = []; const emptySubscription = { @@ -177,14 +185,19 @@ function createMockOpenCodeRuntime(options?: { const runtime: OpenCodeRuntimeShape = { startOpenCodeServerProcess: () => unexpectedOperation("startOpenCodeServerProcess"), - connectToOpenCodeServer: () => - Effect.succeed({ - url: "http://127.0.0.1:4099", + connectToOpenCodeServer: (input) => { + connectCalls.push(input); + return Effect.succeed({ + url: input.serverUrl ?? "http://127.0.0.1:4099", exitCode: null, - external: true, - }), + external: options?.connectedServerExternal ?? true, + }); + }, runOpenCodeCommand: () => unexpectedOperation("runOpenCodeCommand"), - createOpenCodeSdkClient: () => client as unknown as OpencodeClient, + createOpenCodeSdkClient: (input) => { + clientCalls.push(input); + return client as unknown as OpencodeClient; + }, loadOpenCodeInventory: () => Effect.succeed( options?.inventory ?? { @@ -197,7 +210,40 @@ function createMockOpenCodeRuntime(options?: { loadOpenCodeCredentialProviderIDs: () => Effect.succeed([]), }; - return { abortCalls, createCalls, promptCalls, runtime }; + return { abortCalls, clientCalls, connectCalls, createCalls, promptCalls, runtime }; +} + +const READY_MANAGED_SIDECAR: ManagedSidecarSnapshot = Object.freeze({ + state: "ready", + binaryPath: "/managed/ready/opencode", + serverUrl: "http://127.0.0.1:45454", + serverPassword: "ready-sidecar-password", +}); + +function createMockManagedSidecarLifecycle(input?: { + readonly initialSnapshot?: ManagedSidecarSnapshot; + readonly readySnapshot?: ManagedSidecarSnapshot; +}) { + let currentSnapshot: ManagedSidecarSnapshot = input?.initialSnapshot ?? READY_MANAGED_SIDECAR; + const readySnapshot = input?.readySnapshot ?? READY_MANAGED_SIDECAR; + const lifecycle = { + startManagedRuntime: vi.fn(() => { + currentSnapshot = readySnapshot; + return Effect.succeed(currentSnapshot); + }), + stopManagedRuntime: vi.fn(() => { + currentSnapshot = { state: "idle" }; + return Effect.succeed(currentSnapshot); + }), + restartManagedRuntime: vi.fn(() => { + currentSnapshot = readySnapshot; + return Effect.succeed(currentSnapshot); + }), + getManagedRuntimeStatus: vi.fn(() => + Effect.succeed(currentSnapshot), + ), + } satisfies ManagedSidecarLifecycleShape; + return lifecycle; } function createSubscribedEventQueue() { @@ -1409,6 +1455,374 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }); }); + it("starts idle managed profiles through the shared lifecycle before connecting the SDK client", async () => { + const runtime = createMockOpenCodeRuntime(); + const lifecycle = createMockManagedSidecarLifecycle({ initialSnapshot: { state: "idle" } }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-managed-lifecycle"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "managed-opencode-sidecar", + runtimeProfiles: [ + { + id: "managed-opencode-sidecar", + label: "Managed profile", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/profile/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ManagedSidecarLifecycle, lifecycle)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(lifecycle.getManagedRuntimeStatus).toHaveBeenCalledOnce(); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledOnce(); + expect(runtime.connectCalls).toHaveLength(0); + expect(runtime.clientCalls[0]).toMatchObject({ + baseUrl: READY_MANAGED_SIDECAR.serverUrl, + directory: "/managed/workspace", + serverPassword: READY_MANAGED_SIDECAR.serverPassword, + }); + }); + + it("uses ready managed lifecycle status without restarting the sidecar", async () => { + const runtime = createMockOpenCodeRuntime(); + const lifecycle = createMockManagedSidecarLifecycle({ + initialSnapshot: READY_MANAGED_SIDECAR, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-managed-lifecycle-ready"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "managed-opencode-sidecar", + runtimeProfiles: [ + { + id: "managed-opencode-sidecar", + label: "Managed profile", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/profile/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ManagedSidecarLifecycle, lifecycle)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(lifecycle.getManagedRuntimeStatus).toHaveBeenCalledOnce(); + expect(lifecycle.startManagedRuntime).not.toHaveBeenCalled(); + expect(runtime.connectCalls).toHaveLength(0); + expect(runtime.clientCalls[0]).toMatchObject({ + baseUrl: READY_MANAGED_SIDECAR.serverUrl, + directory: "/managed/workspace", + serverPassword: READY_MANAGED_SIDECAR.serverPassword, + }); + }); + + it("keeps external OpenCode profiles on their explicit server connection", async () => { + const runtime = createMockOpenCodeRuntime(); + const lifecycle = createMockManagedSidecarLifecycle({ initialSnapshot: { state: "idle" } }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-external-runtime-profile"), + runtimeMode: "full-access", + cwd: "/request/workspace", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "external-profile", + serverPassword: "external-password", + runtimeProfiles: [ + { + id: "external-profile", + label: "External profile", + provider: "opencode", + mode: "external", + configMode: "inherit", + binaryPath: "/external/bin/opencode", + serverUrl: "http://127.0.0.1:9999", + cwdDefault: "/external/workspace", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ManagedSidecarLifecycle, lifecycle)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(lifecycle.getManagedRuntimeStatus).not.toHaveBeenCalled(); + expect(lifecycle.startManagedRuntime).not.toHaveBeenCalled(); + expect(runtime.connectCalls).toHaveLength(1); + expect(runtime.connectCalls[0]).toMatchObject({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "external-password", + cwd: "/request/workspace", + }); + expect(runtime.clientCalls[0]).toMatchObject({ + baseUrl: "http://127.0.0.1:9999", + directory: "/request/workspace", + serverPassword: "external-password", + }); + expect(runtime.createCalls[0]).toMatchObject({ + title: "JCode thread-external-runtime-profile", + }); + }); + + it("uses the active OpenCode runtime profile when starting sessions", async () => { + const runtime = createMockOpenCodeRuntime(); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-active-runtime-profile"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "managed-profile", + serverPassword: "profile-password", + runtimeProfiles: [ + { + id: "external-profile", + label: "External profile", + provider: "opencode", + mode: "external", + configMode: "inherit", + binaryPath: "/external/bin/opencode", + serverUrl: "http://127.0.0.1:9999", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + { + id: "managed-profile", + label: "Managed profile", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/bin/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.connectCalls[0]).toMatchObject({ + binaryPath: "/managed/bin/opencode", + configMode: "generated", + xdgConfigHome: "/managed/config", + extraEnv: { XDG_DATA_HOME: "/managed/data" }, + cwd: "/managed/workspace", + serverPassword: "profile-password", + }); + expect(runtime.connectCalls[0]).not.toHaveProperty("serverUrl"); + expect(runtime.clientCalls[0]).toMatchObject({ + directory: "/managed/workspace", + serverPassword: "profile-password", + }); + }); + + it("passes the active managed runtime profile password to spawned SDK clients", async () => { + const runtime = createMockOpenCodeRuntime({ connectedServerExternal: false }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-managed-spawned-password"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "managed-profile", + serverPassword: "profile-password", + runtimeProfiles: [ + { + id: "managed-profile", + label: "Managed profile", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/bin/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.connectCalls[0]).toMatchObject({ + serverPassword: "profile-password", + }); + expect(runtime.clientCalls[0]).toMatchObject({ + baseUrl: "http://127.0.0.1:4099", + directory: "/managed/workspace", + serverPassword: "profile-password", + }); + }); + it("clears adapter session state when interrupting an active OpenCode turn", async () => { const runtime = createMockOpenCodeRuntime(); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index a61480f0c..6a9a66fee 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -18,7 +18,7 @@ import { TurnId, type UserInputQuestion, } from "@jcode/contracts"; -import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Layer, Option, Queue, Ref, Scope, Stream } from "effect"; import type { Agent, AssistantMessage, @@ -31,6 +31,7 @@ import type { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -43,6 +44,7 @@ import { KiloAdapter, type KiloAdapterShape } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { SkillManagementService } from "../Services/SkillManagementService.ts"; import { SkillManagementServiceLive } from "./SkillManagementService.ts"; +import { ManagedSidecarLifecycle } from "../managedRuntimeLifecycle.ts"; import { buildOpenCodePermissionRules, KILO_CLI_SPEC, @@ -64,6 +66,10 @@ import { type OpenCodeServerConnection, } from "../opencodeRuntime.ts"; import { extractProposedPlanMarkdown, withProviderPlanModePrompt } from "../planMode.ts"; +import { + resolveOpenCodeRuntimeConnectionConfig, + resolveOpenCodeRuntimeProfile, +} from "../openCodeRuntimeProfiles.ts"; type OpenCodeCompatibleProvider = Extract; @@ -1782,6 +1788,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { OpenCodeAdapter, Effect.gen(function* () { const serverConfig = yield* ServerConfig; + const serverSettings = yield* Effect.serviceOption(ServerSettingsService); const openCodeRuntime = yield* OpenCodeRuntime; const skillManagement = yield* SkillManagementService; const provider = adapterConfig.provider; @@ -3722,10 +3729,40 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { const providerOptions = input.providerOptions?.[adapterConfig.providerOptionsKey]; - const binaryPath = providerOptions?.binaryPath?.trim() || adapterConfig.defaultBinaryPath; - const serverUrl = providerOptions?.serverUrl?.trim(); - const serverPassword = providerOptions?.serverPassword?.trim(); - const directory = input.cwd ?? serverConfig.cwd; + const activeSettings = + provider === "opencode" && Option.isSome(serverSettings) + ? yield* Effect.exit(serverSettings.value.getSettings) + : undefined; + if (activeSettings !== undefined && Exit.isFailure(activeSettings)) { + return yield* toAdapterProcessError(input.threadId, Cause.squash(activeSettings.cause)); + } + const resolvedRuntimeProfile = + activeSettings !== undefined + ? resolveOpenCodeRuntimeProfile({ + settings: activeSettings.value, + defaultBinaryPath: adapterConfig.defaultBinaryPath, + }) + : undefined; + const configuredConnection = + resolvedRuntimeProfile !== undefined + ? resolveOpenCodeRuntimeConnectionConfig({ + resolved: resolvedRuntimeProfile, + cliSpec: adapterConfig.cliSpec, + defaultBinaryPath: adapterConfig.defaultBinaryPath, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }) + : undefined; + const binaryPath = + providerOptions?.binaryPath?.trim() || + configuredConnection?.binaryPath || + adapterConfig.defaultBinaryPath; + const providerServerUrl = providerOptions?.serverUrl?.trim(); + const providerServerPassword = providerOptions?.serverPassword?.trim(); + const profileServerUrl = configuredConnection?.serverUrl; + const profileServerPassword = configuredConnection?.serverPassword; + const serverUrl = providerServerUrl || profileServerUrl; + const serverPassword = providerServerPassword || profileServerPassword; + const directory = input.cwd ?? configuredConnection?.cwd ?? serverConfig.cwd; const initialParsedModel = input.modelSelection?.provider === adapterConfig.provider ? parseOpenCodeModelSlug(input.modelSelection.model) @@ -3744,22 +3781,78 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { sessions.delete(input.threadId); } + const needsManagedSidecarLifecycle = + provider === "opencode" && + resolvedRuntimeProfile?.profile.mode === "managed" && + !providerOptions?.binaryPath?.trim() && + !(providerServerUrl && providerServerPassword) && + !(profileServerUrl && profileServerPassword); + const resumedSessionId = extractResumeSessionId(input.resumeCursor); const started = yield* Effect.gen(function* () { const sessionScope = yield* Scope.make(); const startedExit = yield* Effect.exit( Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath, - cliSpec: adapterConfig.cliSpec, - ...(serverUrl ? { serverUrl } : {}), - }); + const managedSidecarLifecycle = needsManagedSidecarLifecycle + ? yield* Effect.serviceOption(ManagedSidecarLifecycle) + : Option.none(); + const managedConnection = Option.isSome(managedSidecarLifecycle) + ? yield* Effect.gen(function* () { + const lifecycle = managedSidecarLifecycle.value; + const currentSnapshot = yield* lifecycle.getManagedRuntimeStatus(); + const readySnapshot = + currentSnapshot.state === "ready" && currentSnapshot.serverUrl + ? currentSnapshot + : yield* lifecycle.startManagedRuntime(); + if (readySnapshot.state !== "ready" || !readySnapshot.serverUrl) { + return yield* new OpenCodeRuntimeError({ + operation: "managedSidecarLifecycle", + detail: "Managed OpenCode sidecar did not provide a ready server URL.", + }); + } + return { + binaryPath: readySnapshot.binaryPath ?? binaryPath, + serverUrl: readySnapshot.serverUrl, + ...(readySnapshot.serverPassword + ? { serverPassword: readySnapshot.serverPassword } + : {}), + }; + }) + : undefined; + const server: OpenCodeServerConnection = managedConnection + ? { + url: managedConnection.serverUrl, + exitCode: null, + external: true, + } + : yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath, + cliSpec: adapterConfig.cliSpec, + ...(serverUrl ? { serverUrl } : {}), + ...(configuredConnection?.configMode !== undefined + ? { configMode: configuredConnection.configMode } + : {}), + ...(configuredConnection?.homePath !== undefined + ? { homePath: configuredConnection.homePath } + : {}), + ...(configuredConnection?.xdgConfigHome !== undefined + ? { xdgConfigHome: configuredConnection.xdgConfigHome } + : {}), + ...(configuredConnection?.extraEnv !== undefined + ? { extraEnv: configuredConnection.extraEnv } + : {}), + ...(configuredConnection?.cwd !== undefined + ? { cwd: configuredConnection.cwd } + : {}), + ...(serverPassword ? { serverPassword } : {}), + }); + const effectiveServerPassword = managedConnection?.serverPassword ?? serverPassword; const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, cliSpec: adapterConfig.cliSpec, - ...(server.external && serverPassword ? { serverPassword } : {}), + ...(effectiveServerPassword ? { serverPassword: effectiveServerPassword } : {}), }); const openCodeSessionId = resumedSessionId ?? diff --git a/apps/server/src/provider/firstRunWizard.test.ts b/apps/server/src/provider/firstRunWizard.test.ts index 4c99f7954..05d517b94 100644 --- a/apps/server/src/provider/firstRunWizard.test.ts +++ b/apps/server/src/provider/firstRunWizard.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { Effect, Layer } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import { Effect, FileSystem, Layer, Path, Scope } from "effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import type { ManagedSidecarSnapshot, ProviderScanAllResult } from "@jcode/contracts"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -10,11 +12,70 @@ import { completeFirstRunWizard, skipFirstRunWizard, } from "./firstRunWizard.ts"; +import { resolveManagedRuntimeDir } from "./managedRuntimeDownload.ts"; +import type { ManagedSidecarLifecycleShape } from "./managedRuntimeLifecycle.ts"; -const TestLayers = Layer.merge(ServerSettingsService.layerTest(), NodeServices.layer); +const TestLayers = Layer.merge( + Layer.merge(ServerSettingsService.layerTest(), NodeServices.layer), + FetchHttpClient.layer, +); -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(TestLayers))); +type TestEnvironment = + | ServerSettingsService + | FileSystem.FileSystem + | Path.Path + | Scope.Scope + | HttpClient.HttpClient; + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(TestLayers))); + +const READY_MANAGED_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "ready", + binaryPath: "/managed/opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret", +}); + +function makeMockLifecycle(snapshot: ManagedSidecarSnapshot = READY_MANAGED_SNAPSHOT) { + return { + startManagedRuntime: vi.fn(() => + Effect.succeed(snapshot), + ), + stopManagedRuntime: vi.fn(() => + Effect.succeed({ state: "idle" }), + ), + restartManagedRuntime: vi.fn(() => + Effect.succeed(snapshot), + ), + getManagedRuntimeStatus: vi.fn(() => + Effect.succeed(snapshot), + ), + } satisfies ManagedSidecarLifecycleShape; +} + +const scanResultsWithOpenCode = (input: { + readonly hasCredentials: boolean; + readonly hasBinary: boolean; + readonly binaryPath?: string; +}): ProviderScanAllResult => ({ + scannedAt: "2026-06-07T00:00:00.000Z", + providers: [ + { + provider: "opencode", + status: + input.hasCredentials && input.hasBinary + ? "ready" + : input.hasBinary + ? "needs-config" + : "not-installed", + hasCredentials: input.hasCredentials, + credentials: [], + hasBinary: input.hasBinary, + ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), + }, + ], +}); describe("detectFirstRunState", () => { it("returns default incomplete state for a fresh install", async () => { @@ -39,6 +100,91 @@ describe("completeFirstRunWizard", () => { expect(state.selectedProvider).toBeUndefined(); expect(state.completedAt).toBeDefined(); }); + + it("starts the managed sidecar and persists a passwordless profile for clean OpenCode first-run completion", async () => { + const lifecycle = makeMockLifecycle(); + + const { runtimeDir, settings } = await run( + Effect.gen(function* () { + const runtimeDir = yield* resolveManagedRuntimeDir; + yield* completeFirstRunWizard( + { provider: "opencode" }, + { + managedSidecarLifecycle: lifecycle, + scanResults: scanResultsWithOpenCode({ hasCredentials: false, hasBinary: false }), + }, + ); + const serverSettings = yield* ServerSettingsService; + const settings = yield* serverSettings.getSettings; + return { runtimeDir, settings }; + }), + ); + + const profile = settings.providers.opencode.runtimeProfiles[0]; + expect(lifecycle.startManagedRuntime).toHaveBeenCalledOnce(); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + expect(settings.firstRun.completed).toBe(true); + expect(settings.firstRun.selectedProvider).toBe("opencode"); + expect(settings.providers.opencode.activeRuntimeProfileId).toBe("managed-opencode-sidecar"); + expect(settings.providers.opencode.runtimeProfiles).toHaveLength(1); + expect(profile?.mode).toBe("managed"); + expect(profile?.configMode).toBe("generated"); + expect(profile?.binaryPath).toBe(READY_MANAGED_SNAPSHOT.binaryPath); + expect(profile?.serverUrl).toBe(READY_MANAGED_SNAPSHOT.serverUrl); + expect(profile?.opencodeConfigDir).toBe(`${runtimeDir}/config`); + expect(profile?.opencodeDataDir).toBe(`${runtimeDir}/data`); + expect(JSON.stringify(profile)).not.toContain(READY_MANAGED_SNAPSHOT.serverPassword); + expect(JSON.stringify(profile)).not.toContain("serverPassword"); + expect(JSON.stringify(settings)).not.toContain(READY_MANAGED_SNAPSHOT.serverPassword); + expect(settings.providers.opencode.serverPassword).not.toBe( + READY_MANAGED_SNAPSHOT.serverPassword, + ); + }); + + it("persists an external OpenCode runtime profile when first-run finds existing config", async () => { + const lifecycle = makeMockLifecycle(); + + const settings = await run( + Effect.gen(function* () { + yield* completeFirstRunWizard( + { provider: "opencode" }, + { + managedSidecarLifecycle: lifecycle, + scanResults: scanResultsWithOpenCode({ + hasCredentials: true, + hasBinary: true, + binaryPath: "/usr/local/bin/opencode", + }), + }, + ); + const serverSettings = yield* ServerSettingsService; + return yield* serverSettings.getSettings; + }), + ); + + expect(lifecycle.startManagedRuntime).not.toHaveBeenCalled(); + expect(settings.providers.opencode.activeRuntimeProfileId).toBe("external-opencode-existing"); + expect(settings.providers.opencode.runtimeProfiles).toHaveLength(1); + expect(settings.providers.opencode.runtimeProfiles[0]?.mode).toBe("external"); + expect(settings.providers.opencode.runtimeProfiles[0]?.configMode).toBe("inherit"); + expect(settings.providers.opencode.runtimeProfiles[0]?.binaryPath).toBe( + "/usr/local/bin/opencode", + ); + }); + + it("does not create an OpenCode runtime profile for other providers", async () => { + const settings = await run( + Effect.gen(function* () { + yield* completeFirstRunWizard({ provider: "codex" }); + const serverSettings = yield* ServerSettingsService; + return yield* serverSettings.getSettings; + }), + ); + + expect(settings.firstRun.selectedProvider).toBe("codex"); + expect(settings.providers.opencode.activeRuntimeProfileId).toBe(""); + expect(settings.providers.opencode.runtimeProfiles).toHaveLength(0); + }); }); describe("skipFirstRunWizard", () => { @@ -56,6 +202,6 @@ describe("getFirstRunWizardData", () => { expect(data.state.completed).toBe(false); expect(Array.isArray(data.scanResults.providers)).toBe(true); expect(data.scanResults.scannedAt).toBeTruthy(); - expect(["scanning", "select-provider", "configure"]).toContain(data.currentStep); + expect(data.currentStep).toBe("select-provider"); }); }); diff --git a/apps/server/src/provider/firstRunWizard.ts b/apps/server/src/provider/firstRunWizard.ts index 75d8929ed..7e3de8f0e 100644 --- a/apps/server/src/provider/firstRunWizard.ts +++ b/apps/server/src/provider/firstRunWizard.ts @@ -3,12 +3,30 @@ import type { FirstRunState, FirstRunWizardData, FirstRunWizardStep, + ManagedSidecarSnapshot, + ProviderScanAllResult, + ProviderScanResult, + ServerSettingsError, } from "@jcode/contracts"; import { DEFAULT_FIRST_RUN_STATE } from "@jcode/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import { HttpClient } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; +import { + applyAutoCreatedProfile, + autoCreateManagedRuntimeProfile, +} from "./managedRuntimeProfile.ts"; +import { resolveManagedRuntimeDir } from "./managedRuntimeDownload.ts"; +import type { + ManagedSidecarError, + ManagedSidecarLifecycleShape, +} from "./managedRuntimeLifecycle.ts"; import { scanAllProviders } from "./providerCredentialScan.ts"; const resolveCurrentStep = (state: FirstRunState): FirstRunWizardStep => { @@ -21,7 +39,31 @@ const resolveCurrentStep = (state: FirstRunState): FirstRunWizardStep => { return "select-provider"; }; -export const detectFirstRunState = (): Effect.Effect => +interface CompleteFirstRunWizardOptions { + readonly managedSidecarLifecycle?: ManagedSidecarLifecycleShape; + readonly scanResults?: ProviderScanAllResult; +} + +function findOpenCodeScanResult( + scanResults: ProviderScanAllResult, +): ProviderScanResult | undefined { + return scanResults.providers.find((provider) => provider.provider === "opencode"); +} + +function sidecarSnapshotFromScanResult( + scanResult: ProviderScanResult | undefined, +): ManagedSidecarSnapshot { + return { + state: scanResult?.binaryPath ? "ready" : "idle", + ...(scanResult?.binaryPath ? { binaryPath: scanResult.binaryPath } : {}), + }; +} + +export const detectFirstRunState = (): Effect.Effect< + FirstRunState, + ServerSettingsError, + ServerSettingsService +> => Effect.gen(function* () { const settings = yield* ServerSettingsService; const serverSettings = yield* settings.getSettings; @@ -30,33 +72,70 @@ export const detectFirstRunState = (): Effect.Effect => Effect.gen(function* () { const [state, scanResults] = yield* Effect.all([detectFirstRunState(), scanAllProviders()], { concurrency: "unbounded", }); - const currentStep = state.completed || state.skipped ? resolveCurrentStep(state) : "scanning"; + const currentStep = resolveCurrentStep(state); return { state, scanResults, currentStep }; }); export const completeFirstRunWizard = ( input: CompleteFirstRunWizardInput, -): Effect.Effect => + options: CompleteFirstRunWizardOptions = {}, +): Effect.Effect< + FirstRunState, + ServerSettingsError | PlatformError.PlatformError | ManagedSidecarError, + ServerSettingsService | FileSystem.FileSystem | Path.Path | Scope.Scope | HttpClient.HttpClient +> => Effect.gen(function* () { const settings = yield* ServerSettingsService; const now = yield* DateTime.now; const next: FirstRunState = { completed: true, + skipped: false, completedAt: DateTime.formatIso(now), ...(input.provider ? { selectedProvider: input.provider } : {}), }; - yield* settings.updateSettings({ firstRun: next }); + + if (input.provider !== "opencode") { + yield* settings.updateSettings({ firstRun: next }); + return next; + } + + const currentSettings = yield* settings.getSettings; + const scanResults = options.scanResults ?? (yield* scanAllProviders()); + const openCodeScan = findOpenCodeScanResult(scanResults); + const managedRuntimeDir = yield* resolveManagedRuntimeDir; + const existingConfigDetected = openCodeScan?.hasCredentials ?? false; + const cleanManagedFirstRun = + openCodeScan?.hasCredentials === false && openCodeScan.hasBinary === false; + const sidecarSnapshot = + cleanManagedFirstRun && options.managedSidecarLifecycle + ? yield* options.managedSidecarLifecycle.startManagedRuntime({ forceDownload: true }) + : sidecarSnapshotFromScanResult(openCodeScan); + const profileResult = autoCreateManagedRuntimeProfile({ + sidecarSnapshot, + existingConfigDetected, + managedRuntimeDir, + settings: currentSettings, + }); + + yield* settings.updateSettings({ + ...applyAutoCreatedProfile(profileResult, currentSettings), + firstRun: next, + }); return next; }); -export const skipFirstRunWizard = (): Effect.Effect => +export const skipFirstRunWizard = (): Effect.Effect< + FirstRunState, + ServerSettingsError, + ServerSettingsService +> => Effect.gen(function* () { const settings = yield* ServerSettingsService; const now = yield* DateTime.now; diff --git a/apps/server/src/provider/managedRuntimeHealth.test.ts b/apps/server/src/provider/managedRuntimeHealth.test.ts index e2e5aae0c..36e026d83 100644 --- a/apps/server/src/provider/managedRuntimeHealth.test.ts +++ b/apps/server/src/provider/managedRuntimeHealth.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FetchHttpClient } from "effect/unstable/http"; import { describe, expect, it, vi } from "vitest"; -import { Effect, Scope } from "effect"; +import { Effect, Layer, Scope } from "effect"; import type { ManagedSidecarSnapshot } from "@jcode/contracts"; @@ -25,6 +26,9 @@ vi.mock("./managedRuntimeDownload.ts", () => ({ })); const mockVerify = vi.mocked(managedRuntimeDownload.verifyManagedRuntimeBinary); +const TestLayer = Layer.merge(NodeServices.layer, FetchHttpClient.layer); +const successfulServerProbe = () => Effect.succeed(true); +const failedServerProbe = () => Effect.succeed(false); const READY_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ state: "ready", @@ -104,9 +108,10 @@ describe("checkManagedSidecarHealth", () => { ); const result = await Effect.runPromise( - checkManagedSidecarHealth({ sidecarSnapshot: READY_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), - ), + checkManagedSidecarHealth({ + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: successfulServerProbe, + }).pipe(Effect.provide(TestLayer)), ); expect(result.status).toBe("healthy"); @@ -116,12 +121,52 @@ describe("checkManagedSidecarHealth", () => { expect(result.checkedAt).toBeTruthy(); }); + it("returns degraded when ready server probe fails", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: failedServerProbe, + }).pipe(Effect.provide(TestLayer)), + ); + + expect(result.status).toBe("degraded"); + expect(result.serverReachable).toBe(false); + }); + + it("authenticates the default server probe with the managed sidecar password", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true } as Response); + + const result = await Effect.runPromise( + checkManagedSidecarHealth({ sidecarSnapshot: READY_SNAPSHOT }).pipe( + Effect.provide(TestLayer), + ), + ); + + expect(result.status).toBe("healthy"); + expect(fetchSpy).toHaveBeenCalledWith( + READY_SNAPSHOT.serverUrl, + expect.objectContaining({ + headers: { + Authorization: `Basic ${Buffer.from("opencode:test-password", "utf8").toString("base64")}`, + }, + method: "GET", + }), + ); + }); + it("returns unhealthy when ready with missing binary", async () => { const snapshot: ManagedSidecarSnapshot = { state: "ready", serverUrl: "http://127.0.0.1:9999" }; const result = await Effect.runPromise( - checkManagedSidecarHealth({ sidecarSnapshot: snapshot }).pipe( - Effect.provide(NodeServices.layer), + checkManagedSidecarHealth({ sidecarSnapshot: snapshot, serverProbe: failedServerProbe }).pipe( + Effect.provide(TestLayer), ), ); @@ -133,7 +178,7 @@ describe("checkManagedSidecarHealth", () => { it("returns unhealthy with error message when in error state", async () => { const result = await Effect.runPromise( checkManagedSidecarHealth({ sidecarSnapshot: ERROR_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(TestLayer), ), ); @@ -144,9 +189,7 @@ describe("checkManagedSidecarHealth", () => { it("returns not_running when idle", async () => { const result = await Effect.runPromise( - checkManagedSidecarHealth({ sidecarSnapshot: IDLE_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), - ), + checkManagedSidecarHealth({ sidecarSnapshot: IDLE_SNAPSHOT }).pipe(Effect.provide(TestLayer)), ); expect(result.status).toBe("not_running"); @@ -156,7 +199,7 @@ describe("checkManagedSidecarHealth", () => { it("returns degraded when downloading (transient)", async () => { const result = await Effect.runPromise( checkManagedSidecarHealth({ sidecarSnapshot: DOWNLOADING_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), + Effect.provide(TestLayer), ), ); @@ -168,9 +211,7 @@ describe("checkManagedSidecarHealth", () => { const snapshot: ManagedSidecarSnapshot = { state: "stopping" }; const result = await Effect.runPromise( - checkManagedSidecarHealth({ sidecarSnapshot: snapshot }).pipe( - Effect.provide(NodeServices.layer), - ), + checkManagedSidecarHealth({ sidecarSnapshot: snapshot }).pipe(Effect.provide(TestLayer)), ); expect(result.status).toBe("not_running"); @@ -188,9 +229,12 @@ describe("repairManagedSidecar", () => { const result = await Effect.runPromise( Scope.provide( - repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }), + repairManagedSidecar({ + lifecycle, + serverProbe: successfulServerProbe, + }), scope, - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe(Effect.provide(TestLayer)), ); expect(result.success).toBe(true); @@ -198,6 +242,72 @@ describe("repairManagedSidecar", () => { expect(lifecycle.startManagedRuntime).toHaveBeenCalledOnce(); }); + it("returns failed repair when the restarted sidecar is still unreachable", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const lifecycle = makeMockLifecycle(); + const scope = await Effect.runPromise(Scope.make()); + + const result = await Effect.runPromise( + Scope.provide( + repairManagedSidecar({ + lifecycle, + serverProbe: failedServerProbe, + }), + scope, + ).pipe(Effect.provide(TestLayer)), + ); + + expect(result.success).toBe(false); + expect(result.health.status).toBe("degraded"); + expect(result.health.serverReachable).toBe(false); + }); + + it("calls startManagedRuntime with forceDownload by default during repair", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const lifecycle = makeMockLifecycle(); + const scope = await Effect.runPromise(Scope.make()); + + await Effect.runPromise( + Scope.provide( + repairManagedSidecar({ + lifecycle, + serverProbe: successfulServerProbe, + }), + scope, + ).pipe(Effect.provide(TestLayer)), + ); + + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + }); + + it("ignores explicit forceRedownload false because repair must re-download", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const lifecycle = makeMockLifecycle(); + const scope = await Effect.runPromise(Scope.make()); + + await Effect.runPromise( + Scope.provide( + repairManagedSidecar({ + lifecycle, + forceRedownload: false, + serverProbe: successfulServerProbe, + }), + scope, + ).pipe(Effect.provide(TestLayer)), + ); + + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + }); + it("calls startManagedRuntime with forceDownload when forceRedownload is true", async () => { mockVerify.mockReturnValue( Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), @@ -209,12 +319,12 @@ describe("repairManagedSidecar", () => { await Effect.runPromise( Scope.provide( repairManagedSidecar({ - sidecarSnapshot: IDLE_SNAPSHOT, lifecycle, forceRedownload: true, + serverProbe: successfulServerProbe, }), scope, - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe(Effect.provide(TestLayer)), ); expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); @@ -235,9 +345,12 @@ describe("repairManagedSidecar", () => { const result = await Effect.runPromise( Scope.provide( - repairManagedSidecar({ sidecarSnapshot: IDLE_SNAPSHOT, lifecycle }), + repairManagedSidecar({ + lifecycle, + serverProbe: failedServerProbe, + }), scope, - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe(Effect.provide(TestLayer)), ); expect(result.success).toBe(false); @@ -246,22 +359,88 @@ describe("repairManagedSidecar", () => { }); describe("exportManagedSidecarDiagnostics", () => { - it("returns full diagnostics bundle with correct shape", async () => { + interface DesiredManagedSidecarDiagnosticsInput { + sidecarSnapshot: ManagedSidecarSnapshot; + serverProbe?: typeof successfulServerProbe; + binaryVersionProbe?: () => Effect.Effect; + logCollector?: () => Effect.Effect, never, never>; + } + + it("includes collected binary version and logs", async () => { mockVerify.mockReturnValueOnce( Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), ); + const diagnosticsInput: DesiredManagedSidecarDiagnosticsInput = { + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: successfulServerProbe, + binaryVersionProbe: () => Effect.succeed("opencode 1.3.17"), + logCollector: () => Effect.succeed(["line-1", "line-2"]), + }; const result = await Effect.runPromise( - exportManagedSidecarDiagnostics({ sidecarSnapshot: READY_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), - ), + exportManagedSidecarDiagnostics(diagnosticsInput).pipe(Effect.provide(TestLayer)), ); - expect(result.generatedAt).toBeTruthy(); - expect(result.health).toBeDefined(); - expect(result.health.status).toBe("healthy"); - expect(result.platform).toBeDefined(); - expect(result.sidecarSnapshot).toBe(READY_SNAPSHOT); + expect(result.binaryVersion).toBe("opencode 1.3.17"); + expect(result.logs).toEqual(["line-1", "line-2"]); + }); + + it("limits collected logs to the diagnostics log cap", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + const collectedLogs = Array.from({ length: 20 }, (_, index) => `line-${index + 1}`); + const diagnosticsInput: DesiredManagedSidecarDiagnosticsInput = { + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: successfulServerProbe, + binaryVersionProbe: () => Effect.succeed("opencode 1.3.17"), + logCollector: () => Effect.succeed(collectedLogs), + }; + + const result = await Effect.runPromise( + exportManagedSidecarDiagnostics(diagnosticsInput).pipe(Effect.provide(TestLayer)), + ); + + expect(result.logs).toHaveLength(10); + expect(result.logs).toEqual(collectedLogs.slice(0, 10)); + }); + + it("redacts sidecar password from diagnostics", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + exportManagedSidecarDiagnostics({ + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: successfulServerProbe, + }).pipe(Effect.provide(TestLayer)), + ); + + expect(Object.hasOwn(result.sidecarSnapshot, "serverPassword")).toBe(false); + expect(JSON.stringify(result)).not.toContain("test-password"); + }); + + it("redacts sidecar password from diagnostic error and log text", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + + const result = await Effect.runPromise( + exportManagedSidecarDiagnostics({ + sidecarSnapshot: { + ...READY_SNAPSHOT, + error: "failed with test-password", + }, + serverProbe: successfulServerProbe, + binaryVersionProbe: () => Effect.succeed("opencode 1.3.17"), + logCollector: () => Effect.succeed(["stdout test-password", "safe line"]), + }).pipe(Effect.provide(TestLayer)), + ); + + expect(JSON.stringify(result)).not.toContain("test-password"); + expect(result.logs).toEqual(["stdout [redacted]", "safe line"]); + expect(result.sidecarSnapshot.error).toBe("failed with [redacted]"); }); it("populates platform info from process globals", async () => { @@ -270,9 +449,10 @@ describe("exportManagedSidecarDiagnostics", () => { ); const result = await Effect.runPromise( - exportManagedSidecarDiagnostics({ sidecarSnapshot: READY_SNAPSHOT }).pipe( - Effect.provide(NodeServices.layer), - ), + exportManagedSidecarDiagnostics({ + sidecarSnapshot: READY_SNAPSHOT, + serverProbe: successfulServerProbe, + }).pipe(Effect.provide(TestLayer)), ); expect(result.platform.os).toBe(process.platform); diff --git a/apps/server/src/provider/managedRuntimeHealth.ts b/apps/server/src/provider/managedRuntimeHealth.ts index 335a24b28..6367e2b49 100644 --- a/apps/server/src/provider/managedRuntimeHealth.ts +++ b/apps/server/src/provider/managedRuntimeHealth.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import type { @@ -10,18 +11,146 @@ import type { import { Effect, Scope } from "effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import { verifyManagedRuntimeBinary } from "./managedRuntimeDownload.ts"; import type { ManagedSidecarLifecycleShape } from "./managedRuntimeLifecycle.ts"; import { ManagedSidecarError } from "./managedRuntimeLifecycle.ts"; +import { OPENCODE_CLI_SPEC } from "./opencodeRuntime.ts"; const isoNow = (): string => new Date().toISOString(); +export const MANAGED_SIDECAR_DIAGNOSTIC_LOG_LIMIT = 10; + +export type ManagedSidecarServerProbe = ( + serverUrl: string, + serverPassword?: string, +) => Effect.Effect; + +export type ManagedSidecarBinaryVersionProbe = ( + binaryPath: string, +) => Effect.Effect; + +export type ManagedSidecarLogCollector = (input: { + sidecarSnapshot: ManagedSidecarSnapshot; + health: ManagedSidecarHealthCheck; +}) => Effect.Effect, never, never>; + +const firstOutputLine = (output: string | Buffer): string => + output + .toString() + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + +const defaultBinaryVersionProbe: ManagedSidecarBinaryVersionProbe = (binaryPath) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + execFile( + binaryPath, + ["--version"], + { env: {}, timeout: 2_000 }, + (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + + const version = firstOutputLine(stdout) || firstOutputLine(stderr); + resolve(version || "unavailable"); + }, + ); + }), + catch: () => "unavailable", + }).pipe(Effect.catch(() => Effect.succeed("unavailable"))); + +const defaultServerProbe: ManagedSidecarServerProbe = (serverUrl, serverPassword) => + Effect.tryPromise({ + try: async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const response = await fetch(serverUrl, { + method: "GET", + signal: controller.signal, + ...(serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`${OPENCODE_CLI_SPEC.serverAuthUsername}:${serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + }); + return response.ok; + } finally { + clearTimeout(timeout); + } + }, + catch: () => false, + }).pipe(Effect.catch(() => Effect.succeed(false))); + export const checkBinaryExists = (binaryPath: string | undefined): boolean => binaryPath != null && binaryPath.length > 0 && existsSync(binaryPath); -const checkServerReachable = (serverUrl: string | undefined): boolean => - serverUrl != null && serverUrl.length > 0; +const checkServerReachable = ( + serverUrl: string | undefined, + serverPassword: string | undefined, + serverProbe: ManagedSidecarServerProbe, +): Effect.Effect => + serverUrl != null && serverUrl.length > 0 + ? serverProbe(serverUrl, serverPassword) + : Effect.succeed(false); + +const redactionSecretsForSnapshot = (snapshot: ManagedSidecarSnapshot): ReadonlyArray => + snapshot.serverPassword && snapshot.serverPassword.length > 0 ? [snapshot.serverPassword] : []; + +const redactSensitiveText = (value: string, secrets: ReadonlyArray): string => + secrets.reduce((redacted, secret) => redacted.split(secret).join("[redacted]"), value); + +const redactSidecarSnapshot = (snapshot: ManagedSidecarSnapshot): ManagedSidecarSnapshot => { + const secrets = redactionSecretsForSnapshot(snapshot); + const { serverPassword: _serverPassword, ...redacted } = snapshot; + return { + ...redacted, + ...(redacted.error ? { error: redactSensitiveText(redacted.error, secrets) } : {}), + }; +}; + +const redactHealthCheck = ( + health: ManagedSidecarHealthCheck, + secrets: ReadonlyArray, +): ManagedSidecarHealthCheck => ({ + ...health, + ...(health.error ? { error: redactSensitiveText(health.error, secrets) } : {}), +}); + +const collectBinaryVersion = (input: { + sidecarSnapshot: ManagedSidecarSnapshot; + binaryVersionProbe?: ManagedSidecarBinaryVersionProbe; +}): Effect.Effect => { + const binaryPath = input.sidecarSnapshot.binaryPath; + if (binaryPath == null || binaryPath.length === 0) { + return Effect.succeed("unavailable"); + } + + return (input.binaryVersionProbe ?? defaultBinaryVersionProbe)(binaryPath); +}; + +const defaultLogCollector: ManagedSidecarLogCollector = ({ sidecarSnapshot, health }) => { + const lines = [ + `sidecarState=${sidecarSnapshot.state}`, + `healthStatus=${health.status}`, + `binaryExists=${health.binaryExists}`, + `binaryValid=${health.binaryValid}`, + `serverReachable=${health.serverReachable}`, + ...(sidecarSnapshot.binaryPath ? [`binaryPath=${sidecarSnapshot.binaryPath}`] : []), + ...(sidecarSnapshot.serverUrl ? [`serverUrl=${sidecarSnapshot.serverUrl}`] : []), + ...(sidecarSnapshot.error ? [`error=${sidecarSnapshot.error}`] : []), + ]; + + return Effect.succeed(lines); +}; export const deriveHealthStatus = ( state: ManagedSidecarSnapshot["state"], @@ -41,11 +170,16 @@ export const deriveHealthStatus = ( export const checkManagedSidecarHealth = (input: { sidecarSnapshot: ManagedSidecarSnapshot; + serverProbe?: ManagedSidecarServerProbe; }): Effect.Effect => Effect.gen(function* () { const snapshot = input.sidecarSnapshot; const binaryExists = checkBinaryExists(snapshot.binaryPath); - const serverReachable = checkServerReachable(snapshot.serverUrl); + const serverReachable = yield* checkServerReachable( + snapshot.serverUrl, + snapshot.serverPassword, + input.serverProbe ?? defaultServerProbe, + ); const binaryValid = binaryExists && snapshot.binaryPath @@ -71,24 +205,22 @@ export const checkManagedSidecarHealth = (input: { }); export const repairManagedSidecar = (input: { - sidecarSnapshot: ManagedSidecarSnapshot; lifecycle: ManagedSidecarLifecycleShape; forceRedownload?: boolean; + serverProbe?: ManagedSidecarServerProbe; }): Effect.Effect< ManagedSidecarRepairResult, ManagedSidecarError, - FileSystem.FileSystem | Path.Path | Scope.Scope + FileSystem.FileSystem | Path.Path | Scope.Scope | HttpClient.HttpClient > => { - const { lifecycle, forceRedownload = false } = input; + const { lifecycle } = input; return Effect.gen(function* () { yield* lifecycle .stopManagedRuntime() .pipe(Effect.catchTag("ManagedSidecarError", () => Effect.void)); - const startRequest = forceRedownload ? { forceDownload: true } : undefined; - - const startResult = yield* lifecycle.startManagedRuntime(startRequest).pipe( + const startResult = yield* lifecycle.startManagedRuntime({ forceDownload: true }).pipe( Effect.mapError((err) => err instanceof ManagedSidecarError ? err @@ -100,17 +232,23 @@ export const repairManagedSidecar = (input: { ), ); - const health = yield* checkManagedSidecarHealth({ sidecarSnapshot: startResult }); + const health = yield* checkManagedSidecarHealth({ + sidecarSnapshot: startResult, + ...(input.serverProbe ? { serverProbe: input.serverProbe } : {}), + }); return { - success: health.status === "healthy" || health.status === "degraded", + success: health.status === "healthy", health, } satisfies ManagedSidecarRepairResult; }).pipe( Effect.catchTag("ManagedSidecarError", (err) => Effect.gen(function* () { const latestSnapshot = yield* lifecycle.getManagedRuntimeStatus(); - const health = yield* checkManagedSidecarHealth({ sidecarSnapshot: latestSnapshot }); + const health = yield* checkManagedSidecarHealth({ + sidecarSnapshot: latestSnapshot, + ...(input.serverProbe ? { serverProbe: input.serverProbe } : {}), + }); return { success: false, health, @@ -123,18 +261,31 @@ export const repairManagedSidecar = (input: { export const exportManagedSidecarDiagnostics = (input: { sidecarSnapshot: ManagedSidecarSnapshot; + serverProbe?: ManagedSidecarServerProbe; + binaryVersionProbe?: ManagedSidecarBinaryVersionProbe; + logCollector?: ManagedSidecarLogCollector; }): Effect.Effect => Effect.gen(function* () { const health = yield* checkManagedSidecarHealth(input); + const binaryVersion = yield* collectBinaryVersion(input); + const secrets = redactionSecretsForSnapshot(input.sidecarSnapshot); + const logs = yield* (input.logCollector ?? defaultLogCollector)({ + sidecarSnapshot: input.sidecarSnapshot, + health, + }); + const redactedHealth = redactHealthCheck(health, secrets); + const redactedLogs = logs.map((line) => redactSensitiveText(line, secrets)); return { generatedAt: isoNow(), - health, + health: redactedHealth, platform: { os: process.platform, arch: process.arch, nodeVersion: process.version, }, - sidecarSnapshot: input.sidecarSnapshot, + binaryVersion, + logs: redactedLogs.slice(0, MANAGED_SIDECAR_DIAGNOSTIC_LOG_LIMIT), + sidecarSnapshot: redactSidecarSnapshot(input.sidecarSnapshot), } satisfies ManagedSidecarDiagnostics; }); diff --git a/apps/server/src/provider/managedRuntimeLifecycle.test.ts b/apps/server/src/provider/managedRuntimeLifecycle.test.ts index c3e43b053..1e42286bf 100644 --- a/apps/server/src/provider/managedRuntimeLifecycle.test.ts +++ b/apps/server/src/provider/managedRuntimeLifecycle.test.ts @@ -1,23 +1,36 @@ import { describe, expect, it, vi } from "vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Data, Effect, Layer, Ref, Exit } from "effect"; +import { Data, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref, Exit, Scope } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; -import type { ManagedSidecarSnapshot } from "@jcode/contracts"; +import type { ManagedSidecarSnapshot, ManagedSidecarStartRequest } from "@jcode/contracts"; import { generateSidecarPassword, + makeManagedSidecarLifecycle, + managedSidecarOpenCodeLaunchOptions, ManagedSidecarError, type ManagedSidecarLifecycleShape, } from "./managedRuntimeLifecycle.ts"; import { OpenCodeRuntime, type OpenCodeRuntimeShape } from "./opencodeRuntime.ts"; +vi.mock("./managedRuntimeDownload.ts", () => ({ + downloadManagedRuntime: Effect.succeed({ binaryPath: "/tmp/jcode-test-runtime/opencode" }), + resolveManagedRuntimeDir: Effect.succeed("/tmp/jcode-test-runtime"), + verifyManagedRuntimeBinary: vi.fn(() => + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ), +})); + // --------------------------------------------------------------------------- // Mock helpers // --------------------------------------------------------------------------- const FAKE_BINARY_PATH = "/tmp/jcode-test-runtime/opencode"; const FAKE_SERVER_URL = "http://127.0.0.1:9999"; +const ProductionTestLayer = Layer.merge(NodeServices.layer, FetchHttpClient.layer); const makeMockRuntime = (overrides?: Partial): OpenCodeRuntimeShape => ({ @@ -35,6 +48,17 @@ class TestOpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError")< readonly detail: string; }> {} +const runEffectTest = (effect: Effect.Effect): Promise => + Effect.runPromise(Effect.scoped(effect)); + +const runProductionLifecycleTest = ( + effect: Effect.Effect< + A, + E, + Scope.Scope | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient + >, +): Promise => Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(ProductionTestLayer))); + // --------------------------------------------------------------------------- // Test lifecycle factory // --------------------------------------------------------------------------- @@ -48,13 +72,15 @@ function makeTestLifecycle(mockRuntime: OpenCodeRuntimeShape) { const getSnapshot = () => Ref.get(stateRef); - const startManagedRuntime = (_request?: Readonly<{ readonly forceDownload?: boolean }>) => + const startManagedRuntime = (_request?: ManagedSidecarStartRequest) => Effect.gen(function* () { yield* updateState({ state: "starting" }); const password = generateSidecarPassword(); const server = yield* mockRuntime.startOpenCodeServerProcess({ binaryPath: FAKE_BINARY_PATH, configMode: "generated", + serverPassword: password, + ...managedSidecarOpenCodeLaunchOptions("/tmp/jcode-test-runtime"), }); const snapshot: ManagedSidecarSnapshot = { state: "ready", @@ -159,30 +185,36 @@ describe("ManagedSidecarError", () => { describe("lifecycle state transitions", () => { it("starts in idle state", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const status = yield* lifecycle.getManagedRuntimeStatus(); - expect(status.state).toBe("idle"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); it("transitions to ready after successful start", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const result = yield* lifecycle.startManagedRuntime(); - expect(result.state).toBe("ready"); - expect(result.serverUrl).toBe(FAKE_SERVER_URL); - expect(result.serverPassword).toBeTruthy(); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.startManagedRuntime(); + expect(result.state).toBe("ready"); + expect(result.serverUrl).toBe(FAKE_SERVER_URL); + expect(result.serverPassword).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); it("transitions back to idle after stop", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - yield* lifecycle.startManagedRuntime(); - const stopped = yield* lifecycle.stopManagedRuntime(); - expect(stopped.state).toBe("idle"); - const status = yield* lifecycle.getManagedRuntimeStatus(); - expect(status.state).toBe("idle"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + yield* lifecycle.startManagedRuntime(); + const stopped = yield* lifecycle.stopManagedRuntime(); + expect(stopped.state).toBe("idle"); + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); }); // --------------------------------------------------------------------------- @@ -191,41 +223,158 @@ describe("lifecycle state transitions", () => { describe("startManagedRuntime", () => { it("generates a fresh server password on each start", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const first = yield* lifecycle.startManagedRuntime(); - yield* lifecycle.stopManagedRuntime(); - const second = yield* lifecycle.startManagedRuntime(); - expect(first.serverPassword).toBeTruthy(); - expect(second.serverPassword).toBeTruthy(); - expect(first.serverPassword).not.toBe(second.serverPassword); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); - - it("uses configMode generated when spawning", () => - Effect.gen(function* () { - const startFn = vi.fn(() => - Effect.succeed({ - url: FAKE_SERVER_URL, - exitCode: Effect.succeed(0), - }), - ); - const mock = makeMockRuntime({ - startOpenCodeServerProcess: startFn, - }); - const lifecycle = yield* makeTestLifecycle(mock); - yield* lifecycle.startManagedRuntime(); - - expect(startFn).toHaveBeenCalledTimes(1); - const callArgs = startFn.mock.calls[0]![0] as Record; - expect(callArgs["configMode"]).toBe("generated"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const first = yield* lifecycle.startManagedRuntime(); + yield* lifecycle.stopManagedRuntime(); + const second = yield* lifecycle.startManagedRuntime(); + expect(first.serverPassword).toBeTruthy(); + expect(second.serverPassword).toBeTruthy(); + expect(first.serverPassword).not.toBe(second.serverPassword); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); + + it("passes generated config mode and password when spawning", () => + runEffectTest( + Effect.gen(function* () { + const startFn = vi.fn(() => + Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }), + ); + const mock = makeMockRuntime({ + startOpenCodeServerProcess: startFn, + }); + const lifecycle = yield* makeTestLifecycle(mock); + yield* lifecycle.startManagedRuntime(); + + expect(startFn).toHaveBeenCalledTimes(1); + const callArgs = startFn.mock.calls[0]?.[0]; + expect(callArgs).toEqual( + expect.objectContaining({ + configMode: "generated", + serverPassword: expect.any(String), + }), + ); + expect(callArgs?.serverPassword).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); + + it("builds isolated config and data directories for managed sidecar launch", () => { + expect(managedSidecarOpenCodeLaunchOptions("/tmp/jcode-test-runtime")).toEqual({ + xdgConfigHome: "/tmp/jcode-test-runtime/config", + extraEnv: { + XDG_DATA_HOME: "/tmp/jcode-test-runtime/data", + }, + }); + }); it("returns binaryPath in the snapshot", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const result = yield* lifecycle.startManagedRuntime(); - expect(result.binaryPath).toBeTruthy(); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.startManagedRuntime(); + expect(result.binaryPath).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); + + it("serializes overlapping starts so only one process spawn runs at a time", () => + runProductionLifecycleTest( + Effect.gen(function* () { + const firstStartEntered = yield* Deferred.make(); + const releaseFirstStart = yield* Deferred.make(); + let inFlightStarts = 0; + let maxInFlightStarts = 0; + let startCount = 0; + const runtime = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.gen(function* () { + startCount += 1; + const processId = `process-${startCount}`; + inFlightStarts += 1; + maxInFlightStarts = Math.max(maxInFlightStarts, inFlightStarts); + if (startCount === 1) { + yield* Deferred.succeed(firstStartEntered, undefined); + yield* Deferred.await(releaseFirstStart); + } + inFlightStarts -= 1; + return { + url: `${FAKE_SERVER_URL}/${processId}`, + exitCode: Effect.succeed(0), + }; + }), + ), + }); + const lifecycle = yield* makeManagedSidecarLifecycle.pipe( + Effect.provide(Layer.succeed(OpenCodeRuntime, runtime)), + ); + + const firstStart = yield* lifecycle.startManagedRuntime().pipe(Effect.forkChild); + yield* Deferred.await(firstStartEntered); + const secondStart = yield* lifecycle.startManagedRuntime().pipe(Effect.forkChild); + yield* Effect.sleep("25 millis"); + yield* Deferred.succeed(releaseFirstStart, undefined); + + yield* Fiber.join(firstStart); + const secondResult = yield* Fiber.join(secondStart); + const status = yield* lifecycle.getManagedRuntimeStatus(); + + expect(maxInFlightStarts).toBe(1); + expect(secondResult.state).toBe("ready"); + expect(status.state).toBe("ready"); + }), + )); + + it("does not expose stale ready credentials while a forced download start is starting", () => + runProductionLifecycleTest( + Effect.gen(function* () { + const secondStartEntered = yield* Deferred.make(); + const releaseSecondStart = yield* Deferred.make(); + let startCount = 0; + const runtime = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.gen(function* () { + startCount += 1; + const processId = `process-${startCount}`; + if (startCount === 2) { + yield* Deferred.succeed(secondStartEntered, undefined); + yield* Deferred.await(releaseSecondStart); + } + return { + url: `${FAKE_SERVER_URL}/${processId}`, + exitCode: Effect.succeed(0), + }; + }), + ), + }); + const lifecycle = yield* makeManagedSidecarLifecycle.pipe( + Effect.provide(Layer.succeed(OpenCodeRuntime, runtime)), + ); + + const first = yield* lifecycle.startManagedRuntime(); + expect(first.state).toBe("ready"); + expect(first.serverUrl).toBe(`${FAKE_SERVER_URL}/process-1`); + expect(first.serverPassword).toBeTruthy(); + + const secondStart = yield* lifecycle + .startManagedRuntime({ forceDownload: true }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStartEntered); + + const transient = yield* lifecycle.getManagedRuntimeStatus(); + expect(transient.state).toBe("starting"); + expect(transient.serverUrl).toBeUndefined(); + expect(transient.serverPassword).toBeUndefined(); + + yield* Deferred.succeed(releaseSecondStart, undefined); + const second = yield* Fiber.join(secondStart); + expect(second.state).toBe("ready"); + expect(second.serverUrl).toBe(`${FAKE_SERVER_URL}/process-2`); + expect(second.serverPassword).toBeTruthy(); + }), + )); }); // --------------------------------------------------------------------------- @@ -234,11 +383,52 @@ describe("startManagedRuntime", () => { describe("stopManagedRuntime", () => { it("returns idle when already idle", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const result = yield* lifecycle.stopManagedRuntime(); - expect(result.state).toBe("idle"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const result = yield* lifecycle.stopManagedRuntime(); + expect(result.state).toBe("idle"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); + + it("closes the running sidecar process resource", () => { + const finalizedProcesses: string[] = []; + let startCount = 0; + const runtime = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.gen(function* () { + startCount += 1; + const processId = `process-${startCount}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalizedProcesses.push(processId); + }), + ); + return { + url: `${FAKE_SERVER_URL}/${processId}`, + exitCode: Effect.succeed(0), + }; + }), + ), + }); + + return runProductionLifecycleTest( + Effect.gen(function* () { + const lifecycle = yield* makeManagedSidecarLifecycle.pipe( + Effect.provide(Layer.succeed(OpenCodeRuntime, runtime)), + ); + + yield* lifecycle.startManagedRuntime(); + expect(finalizedProcesses).toEqual([]); + + yield* lifecycle.stopManagedRuntime(); + expect(finalizedProcesses).toEqual(["process-1"]); + + yield* lifecycle.stopManagedRuntime(); + expect(finalizedProcesses).toEqual(["process-1"]); + }), + ); + }); }); // --------------------------------------------------------------------------- @@ -247,20 +437,65 @@ describe("stopManagedRuntime", () => { describe("restartManagedRuntime", () => { it("generates a new password on restart (no reuse)", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const first = yield* lifecycle.startManagedRuntime(); - const restarted = yield* lifecycle.restartManagedRuntime(); - expect(restarted.serverPassword).toBeTruthy(); - expect(restarted.serverPassword).not.toBe(first.serverPassword); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const first = yield* lifecycle.startManagedRuntime(); + const restarted = yield* lifecycle.restartManagedRuntime(); + expect(restarted.serverPassword).toBeTruthy(); + expect(restarted.serverPassword).not.toBe(first.serverPassword); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); it("returns to ready state after restart", () => - Effect.gen(function* () { - const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); - const restarted = yield* lifecycle.restartManagedRuntime(); - expect(restarted.state).toBe("ready"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + runEffectTest( + Effect.gen(function* () { + const lifecycle = yield* makeTestLifecycle(makeMockRuntime()); + const restarted = yield* lifecycle.restartManagedRuntime(); + expect(restarted.state).toBe("ready"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); + + it("closes only the old sidecar process resource before returning the replacement", () => { + const finalizedProcesses: string[] = []; + let startCount = 0; + const runtime = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.gen(function* () { + startCount += 1; + const processId = `process-${startCount}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalizedProcesses.push(processId); + }), + ); + return { + url: `${FAKE_SERVER_URL}/${processId}`, + exitCode: Effect.succeed(0), + }; + }), + ), + }); + + return runProductionLifecycleTest( + Effect.gen(function* () { + const lifecycle = yield* makeManagedSidecarLifecycle.pipe( + Effect.provide(Layer.succeed(OpenCodeRuntime, runtime)), + ); + + const first = yield* lifecycle.startManagedRuntime(); + expect(first.serverUrl).toBe(`${FAKE_SERVER_URL}/process-1`); + expect(finalizedProcesses).toEqual([]); + + const restarted = yield* lifecycle.restartManagedRuntime(); + expect(restarted.serverUrl).toBe(`${FAKE_SERVER_URL}/process-2`); + expect(finalizedProcesses).toEqual(["process-1"]); + + yield* lifecycle.stopManagedRuntime(); + expect(finalizedProcesses).toEqual(["process-1", "process-2"]); + }), + ); + }); }); // --------------------------------------------------------------------------- @@ -269,52 +504,56 @@ describe("restartManagedRuntime", () => { describe("error handling", () => { it("transitions to error state on spawn failure", () => - Effect.gen(function* () { - const failingMock = makeMockRuntime({ - startOpenCodeServerProcess: vi.fn(() => - Effect.fail( - new TestOpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: "spawn failed", - }), + runEffectTest( + Effect.gen(function* () { + const failingMock = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => + Effect.fail( + new TestOpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "spawn failed", + }), + ), ), - ), - }); - const lifecycle = yield* makeTestLifecycle(failingMock); - const exit = yield* Effect.exit(lifecycle.startManagedRuntime()); - expect(Exit.isFailure(exit)).toBe(true); + }); + const lifecycle = yield* makeTestLifecycle(failingMock); + const exit = yield* Effect.exit(lifecycle.startManagedRuntime()); + expect(Exit.isFailure(exit)).toBe(true); - const status = yield* lifecycle.getManagedRuntimeStatus(); - expect(status.state).toBe("error"); - expect(status.error).toContain("spawn failed"); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + const status = yield* lifecycle.getManagedRuntimeStatus(); + expect(status.state).toBe("error"); + expect(status.error).toContain("spawn failed"); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); it("does not reuse password after a failed start", () => - Effect.gen(function* () { - let callCount = 0; - const failThenSucceed = makeMockRuntime({ - startOpenCodeServerProcess: vi.fn(() => { - callCount++; - if (callCount === 1) { - return Effect.fail( - new TestOpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: "first attempt fails", - }), - ); - } - return Effect.succeed({ - url: FAKE_SERVER_URL, - exitCode: Effect.succeed(0), - }); - }), - }); - const lifecycle = yield* makeTestLifecycle(failThenSucceed); + runEffectTest( + Effect.gen(function* () { + let callCount = 0; + const failThenSucceed = makeMockRuntime({ + startOpenCodeServerProcess: vi.fn(() => { + callCount++; + if (callCount === 1) { + return Effect.fail( + new TestOpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "first attempt fails", + }), + ); + } + return Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }); + }), + }); + const lifecycle = yield* makeTestLifecycle(failThenSucceed); - yield* Effect.flip(lifecycle.startManagedRuntime()); + yield* Effect.flip(lifecycle.startManagedRuntime()); - const success = yield* lifecycle.startManagedRuntime(); - expect(success.state).toBe("ready"); - expect(success.serverPassword).toBeTruthy(); - }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime())))); + const success = yield* lifecycle.startManagedRuntime(); + expect(success.state).toBe("ready"); + expect(success.serverPassword).toBeTruthy(); + }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), + )); }); diff --git a/apps/server/src/provider/managedRuntimeLifecycle.ts b/apps/server/src/provider/managedRuntimeLifecycle.ts index 7abbde033..e43787068 100644 --- a/apps/server/src/provider/managedRuntimeLifecycle.ts +++ b/apps/server/src/provider/managedRuntimeLifecycle.ts @@ -4,8 +4,10 @@ // Depends on: managedRuntimeDownload, opencodeRuntime, @jcode/contracts, effect import type { ManagedSidecarSnapshot, ManagedSidecarStartRequest } from "@jcode/contracts"; -import { Data, Effect, Layer, Path, Ref, Scope, ServiceMap } from "effect"; +import { Data, Effect, Exit, FileSystem, Layer, Path, Ref, Scope, ServiceMap } from "effect"; +import * as Semaphore from "effect/Semaphore"; import * as Crypto from "node:crypto"; +import { HttpClient } from "effect/unstable/http"; import { downloadManagedRuntime, @@ -35,6 +37,19 @@ export class ManagedSidecarError extends Data.TaggedError("ManagedSidecarError") export const generateSidecarPassword = (): string => Crypto.randomBytes(24).toString("base64url"); +export function managedSidecarOpenCodeLaunchOptions(managedRuntimeDir: string): { + readonly xdgConfigHome: string; + readonly extraEnv: Readonly>; +} { + const root = managedRuntimeDir.replace(/[\\/]+$/, ""); + return { + xdgConfigHome: `${root}/config`, + extraEnv: { + XDG_DATA_HOME: `${root}/data`, + }, + }; +} + // --------------------------------------------------------------------------- // Idle snapshot // --------------------------------------------------------------------------- @@ -69,39 +84,80 @@ export interface ManagedSidecarLifecycleShape { export const makeManagedSidecarLifecycle = Effect.gen(function* () { const stateRef = yield* Ref.make(IDLE_SNAPSHOT); + const runningProcessScopeRef = yield* Ref.make(null); + const lifecycleMutex = yield* Semaphore.make(1); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; const runtime = yield* OpenCodeRuntime; + const omitReadyOnlyFieldsWhenNotReady = ( + snapshot: ManagedSidecarSnapshot, + ): ManagedSidecarSnapshot => { + if (snapshot.state === "ready") { + return snapshot; + } + + const { + serverUrl: _serverUrl, + serverPassword: _serverPassword, + ...transientSnapshot + } = snapshot; + return transientSnapshot; + }; + + const provideManagedRuntimeServices = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + const updateState = (patch: Partial) => - Ref.update(stateRef, (prev) => ({ ...prev, ...patch })); + Ref.update(stateRef, (prev) => omitReadyOnlyFieldsWhenNotReady({ ...prev, ...patch })); const getSnapshot = () => Ref.get(stateRef); + const closeRunningProcessScope = Effect.gen(function* () { + const runningProcessScope = yield* Ref.get(runningProcessScopeRef); + if (runningProcessScope === null) { + return; + } + yield* Ref.set(runningProcessScopeRef, null); + yield* Scope.close(runningProcessScope, Exit.void); + }); + + yield* Effect.addFinalizer(() => closeRunningProcessScope); + // ----------------------------------------------------------------------- // startManagedRuntime // ----------------------------------------------------------------------- - const startManagedRuntime = (request?: ManagedSidecarStartRequest) => + const startManagedRuntimeUnlocked = (request?: ManagedSidecarStartRequest) => Effect.gen(function* () { const forceDownload = request?.forceDownload ?? false; + yield* closeRunningProcessScope; yield* updateState({ state: "downloading" }); - const validation = yield* verifyManagedRuntimeBinary().pipe( - Effect.catchTag("PlatformError", (err) => - Effect.fail( + const validation = yield* provideManagedRuntimeServices(verifyManagedRuntimeBinary()).pipe( + Effect.mapError( + (err) => new ManagedSidecarError({ stage: "verify", message: `Failed to verify managed runtime binary: ${String(err)}`, cause: err, }), - ), ), ); + const path = pathService; let binaryPath: string; if (!validation.exists || !validation.valid || forceDownload) { - const downloadResult = yield* downloadManagedRuntime.pipe( + const downloadResult = yield* provideManagedRuntimeServices(downloadManagedRuntime).pipe( Effect.mapError( (err) => new ManagedSidecarError({ @@ -113,7 +169,7 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { ); binaryPath = downloadResult.binaryPath; } else { - const runtimeDir = yield* resolveManagedRuntimeDir.pipe( + const runtimeDir = yield* provideManagedRuntimeServices(resolveManagedRuntimeDir).pipe( Effect.mapError( (err) => new ManagedSidecarError({ @@ -123,7 +179,6 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { }), ), ); - const path = yield* Path.Path; const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode"; binaryPath = path.join(runtimeDir, binaryName); } @@ -131,23 +186,36 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { yield* updateState({ state: "starting", binaryPath }); const password = generateSidecarPassword(); - - const server: OpenCodeServerProcess = yield* runtime - .startOpenCodeServerProcess({ - binaryPath, - cliSpec: OPENCODE_CLI_SPEC, - configMode: "generated", - }) - .pipe( - Effect.mapError( - (err) => - new ManagedSidecarError({ - stage: "spawn", - message: `Failed to start managed sidecar: ${err.detail}`, - cause: err, - }), + const processScope = yield* Scope.make(); + + const server: OpenCodeServerProcess = yield* Scope.provide( + runtime + .startOpenCodeServerProcess({ + binaryPath, + cliSpec: OPENCODE_CLI_SPEC, + configMode: "generated", + serverPassword: password, + ...managedSidecarOpenCodeLaunchOptions(path.dirname(binaryPath)), + }) + .pipe( + Effect.mapError( + (err) => + new ManagedSidecarError({ + stage: "spawn", + message: `Failed to start managed sidecar: ${err.detail}`, + cause: err, + }), + ), ), - ); + processScope, + ).pipe( + Effect.catch((err) => + Effect.gen(function* () { + yield* Scope.close(processScope, Exit.void); + return yield* Effect.fail(err); + }), + ), + ); const snapshot: ManagedSidecarSnapshot = { state: "ready", @@ -157,6 +225,7 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { }; yield* Ref.set(stateRef, snapshot); + yield* Ref.set(runningProcessScopeRef, processScope); return snapshot; }).pipe( @@ -171,11 +240,17 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { ), ); + const startManagedRuntime = (request?: ManagedSidecarStartRequest) => + lifecycleMutex.withPermits(1)(startManagedRuntimeUnlocked(request)); + // ----------------------------------------------------------------------- // stopManagedRuntime // ----------------------------------------------------------------------- - const stopManagedRuntime = (): Effect.Effect => + const stopManagedRuntimeUnlocked = (): Effect.Effect< + ManagedSidecarSnapshot, + ManagedSidecarError + > => Effect.gen(function* () { const current = yield* getSnapshot(); @@ -184,21 +259,27 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { } yield* updateState({ state: "stopping" }); + yield* closeRunningProcessScope; yield* Ref.set(stateRef, IDLE_SNAPSHOT); return IDLE_SNAPSHOT; }); + const stopManagedRuntime = (): Effect.Effect => + lifecycleMutex.withPermits(1)(stopManagedRuntimeUnlocked()); + // ----------------------------------------------------------------------- // restartManagedRuntime // ----------------------------------------------------------------------- const restartManagedRuntime = () => - Effect.gen(function* () { - yield* stopManagedRuntime(); - return yield* startManagedRuntime(); - }); + lifecycleMutex.withPermits(1)( + Effect.gen(function* () { + yield* stopManagedRuntimeUnlocked(); + return yield* startManagedRuntimeUnlocked(); + }), + ); // ----------------------------------------------------------------------- // getManagedRuntimeStatus @@ -211,7 +292,7 @@ export const makeManagedSidecarLifecycle = Effect.gen(function* () { stopManagedRuntime, restartManagedRuntime, getManagedRuntimeStatus, - }; + } satisfies ManagedSidecarLifecycleShape; }); // --------------------------------------------------------------------------- diff --git a/apps/server/src/provider/managedRuntimeProfile.test.ts b/apps/server/src/provider/managedRuntimeProfile.test.ts index d18cd357a..511de059d 100644 --- a/apps/server/src/provider/managedRuntimeProfile.test.ts +++ b/apps/server/src/provider/managedRuntimeProfile.test.ts @@ -19,6 +19,10 @@ const READY_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ serverPassword: "s3cret", }); +const IDLE_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "idle", +}); + const EMPTY_SETTINGS: ServerSettings = DEFAULT_SERVER_SETTINGS; function settingsWithProfiles( @@ -45,6 +49,7 @@ describe("autoCreateManagedRuntimeProfile", () => { const result = autoCreateManagedRuntimeProfile({ sidecarSnapshot: READY_SNAPSHOT, existingConfigDetected: false, + managedRuntimeDir: "/var/lib/jcode/managed-opencode", settings: EMPTY_SETTINGS, }); @@ -56,6 +61,8 @@ describe("autoCreateManagedRuntimeProfile", () => { expect(result.profile.configMode).toBe("generated"); expect(result.profile.binaryPath).toBe("/opt/jcode/opencode-sidecar"); expect(result.profile.serverUrl).toBe("http://127.0.0.1:42001"); + expect(result.profile.opencodeConfigDir).toBe("/var/lib/jcode/managed-opencode/config"); + expect(result.profile.opencodeDataDir).toBe("/var/lib/jcode/managed-opencode/data"); expect(result.profile.capabilityPolicy).toBe("warn"); expect(result.activeProfileId).toBe("managed-opencode-sidecar"); }); @@ -115,12 +122,36 @@ describe("autoCreateManagedRuntimeProfile", () => { const result = autoCreateManagedRuntimeProfile({ sidecarSnapshot: READY_SNAPSHOT, existingConfigDetected: false, + managedRuntimeDir: "/var/lib/jcode/managed-opencode", settings: EMPTY_SETTINGS, }); expect(result.profile.configMode).toBe("generated"); }); + it("sets isolated OpenCode config and data directories for managed sidecar", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: READY_SNAPSHOT, + existingConfigDetected: false, + managedRuntimeDir: "/var/lib/jcode/managed-opencode", + settings: EMPTY_SETTINGS, + }); + + expect(result.profile.opencodeConfigDir).toBe("/var/lib/jcode/managed-opencode/config"); + expect(result.profile.opencodeDataDir).toBe("/var/lib/jcode/managed-opencode/data"); + }); + + it("uses the managed runtime binary path when clean discovery has no binary", () => { + const result = autoCreateManagedRuntimeProfile({ + sidecarSnapshot: IDLE_SNAPSHOT, + existingConfigDetected: false, + managedRuntimeDir: "/var/lib/jcode/managed-opencode", + settings: EMPTY_SETTINGS, + }); + + expect(result.profile.binaryPath).toBe("/var/lib/jcode/managed-opencode/opencode"); + }); + it("sets configMode to inherit for external with existing config", () => { const result = autoCreateManagedRuntimeProfile({ sidecarSnapshot: READY_SNAPSHOT, diff --git a/apps/server/src/provider/managedRuntimeProfile.ts b/apps/server/src/provider/managedRuntimeProfile.ts index cff1fa08e..cee9083b2 100644 --- a/apps/server/src/provider/managedRuntimeProfile.ts +++ b/apps/server/src/provider/managedRuntimeProfile.ts @@ -5,6 +5,7 @@ import type { ServerSettings, ServerSettingsPatch, } from "@jcode/contracts"; +import path from "node:path"; const EMPTY_CAPABILITY_ARRAYS = { skillRoots: [] as readonly string[], @@ -19,15 +20,44 @@ const EMPTY_CAPABILITY_ARRAYS = { capabilityPolicy: "warn" as const, }; -function makeManagedProfile(snapshot: ManagedSidecarSnapshot): OpenCodeRuntimeProfile { +function trimTrailingSeparators(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +export function managedOpenCodeProfileDirs(managedRuntimeDir: string): { + readonly opencodeConfigDir: string; + readonly opencodeDataDir: string; +} { + const root = trimTrailingSeparators(managedRuntimeDir.trim()); + return { + opencodeConfigDir: `${root}/config`, + opencodeDataDir: `${root}/data`, + }; +} + +export function managedOpenCodeBinaryPath(managedRuntimeDir: string): string { + const root = trimTrailingSeparators(managedRuntimeDir.trim()); + return path.join(root, process.platform === "win32" ? "opencode.exe" : "opencode"); +} + +function makeManagedProfile( + snapshot: ManagedSidecarSnapshot, + managedRuntimeDir?: string, +): OpenCodeRuntimeProfile { + const managedDirs = managedRuntimeDir?.trim() + ? managedOpenCodeProfileDirs(managedRuntimeDir) + : undefined; return { id: "managed-opencode-sidecar", label: "Managed OpenCode (sidecar)", provider: "opencode", mode: "managed", configMode: "generated", - binaryPath: snapshot.binaryPath ?? undefined, + binaryPath: + snapshot.binaryPath ?? + (managedRuntimeDir?.trim() ? managedOpenCodeBinaryPath(managedRuntimeDir) : undefined), serverUrl: snapshot.serverUrl ?? undefined, + ...(managedDirs ? managedDirs : {}), ...EMPTY_CAPABILITY_ARRAYS, }; } @@ -47,6 +77,7 @@ function makeExternalProfile(snapshot: ManagedSidecarSnapshot): OpenCodeRuntimeP export interface AutoCreateProfileInput { readonly sidecarSnapshot: ManagedSidecarSnapshot; readonly existingConfigDetected: boolean; + readonly managedRuntimeDir?: string; readonly settings: ServerSettings; } @@ -61,7 +92,7 @@ export function autoCreateManagedRuntimeProfile( ): AutoCreateProfileResult { const desiredProfile = input.existingConfigDetected ? makeExternalProfile(input.sidecarSnapshot) - : makeManagedProfile(input.sidecarSnapshot); + : makeManagedProfile(input.sidecarSnapshot, input.managedRuntimeDir); const existingProfile = input.settings.providers.opencode.runtimeProfiles.find( (p) => p.id === desiredProfile.id, diff --git a/apps/server/src/provider/openCodeRuntimeHealth.test.ts b/apps/server/src/provider/openCodeRuntimeHealth.test.ts index a61896f58..86c5fa911 100644 --- a/apps/server/src/provider/openCodeRuntimeHealth.test.ts +++ b/apps/server/src/provider/openCodeRuntimeHealth.test.ts @@ -61,6 +61,7 @@ function makeRuntime(overrides: Partial = {}): OpenCodeRun function settingsWithOpenCodeProfile( profile: ServerSettings["providers"]["opencode"]["runtimeProfiles"][number], + options?: { readonly serverPassword?: string }, ): ServerSettings { return { ...DEFAULT_SERVER_SETTINGS, @@ -70,6 +71,7 @@ function settingsWithOpenCodeProfile( ...DEFAULT_SERVER_SETTINGS.providers.opencode, activeRuntimeProfileId: profile.id, runtimeProfiles: [profile], + ...(options?.serverPassword ? { serverPassword: options.serverPassword } : {}), }, }, }; @@ -186,4 +188,121 @@ describe("checkOpenCodeRuntimeHealth", () => { expect(health.status).toBe("healthy"); expect(health.mismatches).toEqual([]); }); + + it("passes managed profile password to spawned server connection and SDK client", async () => { + const connectCalls: Array[0]> = []; + const clientCalls: Array[0]> = []; + const settings = settingsWithOpenCodeProfile( + { + id: "managed", + label: "Managed", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/bin/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + { serverPassword: "profile-password" }, + ); + const runtime = makeRuntime({ + connectToOpenCodeServer: (input) => { + connectCalls.push(input); + return Effect.succeed({ + url: "http://127.0.0.1:4096", + exitCode: null, + external: false, + }); + }, + createOpenCodeSdkClient: (input) => { + clientCalls.push(input); + return {} as never; + }, + }); + + const health = await Effect.runPromise( + checkOpenCodeRuntimeHealth({ + settings, + runtime, + cliSpec: OPENCODE_CLI_SPEC, + defaultBinaryPath: "opencode", + }), + ); + + expect(health.external).toBe(false); + expect(connectCalls[0]).toMatchObject({ + serverPassword: "profile-password", + }); + expect(clientCalls[0]).toMatchObject({ + baseUrl: "http://127.0.0.1:4096", + directory: "/managed/workspace", + serverPassword: "profile-password", + }); + }); + + it("generates a memory-only password for managed profiles without a configured password", async () => { + const connectCalls: Array[0]> = []; + const clientCalls: Array[0]> = []; + const settings = settingsWithOpenCodeProfile({ + id: "managed", + label: "Managed", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/bin/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }); + const runtime = makeRuntime({ + connectToOpenCodeServer: (input) => { + connectCalls.push(input); + return Effect.succeed({ + url: "http://127.0.0.1:4096", + exitCode: null, + external: false, + }); + }, + createOpenCodeSdkClient: (input) => { + clientCalls.push(input); + return {} as never; + }, + }); + + const health = await Effect.runPromise( + checkOpenCodeRuntimeHealth({ + settings, + runtime, + cliSpec: OPENCODE_CLI_SPEC, + defaultBinaryPath: "opencode", + }), + ); + + expect(health.external).toBe(false); + expect(connectCalls[0]?.serverPassword).toEqual(expect.any(String)); + expect(connectCalls[0]?.serverPassword).toHaveLength(32); + expect(clientCalls[0]?.serverPassword).toBe(connectCalls[0]?.serverPassword); + expect(settings.providers.opencode.serverPassword).toBe(""); + }); }); diff --git a/apps/server/src/provider/openCodeRuntimeHealth.ts b/apps/server/src/provider/openCodeRuntimeHealth.ts index be534e7ef..07bdbca23 100644 --- a/apps/server/src/provider/openCodeRuntimeHealth.ts +++ b/apps/server/src/provider/openCodeRuntimeHealth.ts @@ -294,7 +294,11 @@ export function checkOpenCodeRuntimeHealth(input: { ...(connectionConfig.xdgConfigHome ? { xdgConfigHome: connectionConfig.xdgConfigHome } : {}), + ...(connectionConfig.extraEnv ? { extraEnv: connectionConfig.extraEnv } : {}), ...(connectionConfig.cwd ? { cwd: connectionConfig.cwd } : {}), + ...(connectionConfig.serverPassword + ? { serverPassword: connectionConfig.serverPassword } + : {}), }), ); @@ -322,8 +326,8 @@ export function checkOpenCodeRuntimeHealth(input: { baseUrl: server.url, directory: input.cwd ?? profile.cwdDefault ?? process.cwd(), cliSpec: input.cliSpec, - ...(server.external && resolved.serverPassword - ? { serverPassword: resolved.serverPassword } + ...(connectionConfig.serverPassword + ? { serverPassword: connectionConfig.serverPassword } : {}), }); const inventoryExit = yield* Effect.exit(input.runtime.loadOpenCodeInventory(client)); diff --git a/apps/server/src/provider/openCodeRuntimeProfiles.ts b/apps/server/src/provider/openCodeRuntimeProfiles.ts index acc34b0f0..18b846926 100644 --- a/apps/server/src/provider/openCodeRuntimeProfiles.ts +++ b/apps/server/src/provider/openCodeRuntimeProfiles.ts @@ -1,5 +1,6 @@ import type { OpenCodeRuntimeProfile, OpenCodeRuntimeConfigMode } from "@jcode/contracts"; import type { ServerSettings } from "@jcode/contracts"; +import * as Crypto from "node:crypto"; import type { OpenCodeCompatibleCliSpec } from "./opencodeRuntime.ts"; @@ -17,6 +18,7 @@ export interface OpenCodeRuntimeConnectionConfig { readonly configMode: OpenCodeRuntimeConfigMode; readonly homePath?: string; readonly xdgConfigHome?: string; + readonly extraEnv?: Readonly>; readonly cwd?: string; } @@ -32,6 +34,10 @@ function fallbackBinaryPath( return trimToNull(settings.binaryPath) ?? defaultBinaryPath; } +function generateManagedRuntimePassword(): string { + return Crypto.randomBytes(24).toString("base64url"); +} + export function resolveOpenCodeRuntimeProfile(input: { readonly settings: ServerSettings; readonly defaultBinaryPath: string; @@ -113,6 +119,11 @@ export function resolveOpenCodeRuntimeConnectionConfig(input: { const binaryPath = trimToNull(profile.binaryPath) ?? input.defaultBinaryPath; const cwd = trimToNull(input.cwd) ?? trimToNull(profile.cwdDefault); const serverUrl = trimToNull(profile.serverUrl); + const serverPassword = + trimToNull(input.resolved.serverPassword) ?? + (profile.mode === "managed" ? generateManagedRuntimePassword() : null); + const xdgConfigHome = trimToNull(profile.opencodeConfigDir) ?? trimToNull(profile.configHome); + const xdgDataHome = trimToNull(profile.opencodeDataDir); return { binaryPath, cliSpec: input.cliSpec, @@ -121,10 +132,11 @@ export function resolveOpenCodeRuntimeConnectionConfig(input: { ? { serverUrl } : {} : {}), - ...(input.resolved.serverPassword ? { serverPassword: input.resolved.serverPassword } : {}), + ...(serverPassword ? { serverPassword } : {}), configMode: profile.configMode, ...(profile.homePath ? { homePath: profile.homePath } : {}), - ...(profile.configHome ? { xdgConfigHome: profile.configHome } : {}), + ...(xdgConfigHome ? { xdgConfigHome } : {}), + ...(xdgDataHome ? { extraEnv: { XDG_DATA_HOME: xdgDataHome } } : {}), ...(cwd ? { cwd } : {}), }; } diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index 8c2add459..11bbb29f2 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -46,6 +46,16 @@ describe("buildOpenCodeServerProcessEnv", () => { expect(env.HOME).toBe("/tmp/jcode-home"); expect(env.XDG_CONFIG_HOME).toBe("/tmp/jcode-config"); }); + + it("passes server auth credentials through environment variables", () => { + const env = buildOpenCodeServerProcessEnv({ + serverPassword: "managed-secret", + baseEnv: { PATH: "/bin" }, + }); + + expect(env.OPENCODE_SERVER_USERNAME).toBe("opencode"); + expect(env.OPENCODE_SERVER_PASSWORD).toBe("managed-secret"); + }); }); describe("parseOpenCodeCliModelsOutput", () => { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 3b01bf641..87a701c90 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -85,6 +85,7 @@ export interface OpenCodeServerLaunchConfig { readonly xdgConfigHome?: string; readonly extraEnv?: Readonly>; readonly cwd?: string; + readonly serverPassword?: string; } const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; @@ -260,9 +261,11 @@ export function buildOpenCodeServerProcessEnv(input: { readonly xdgConfigHome?: string; readonly extraEnv?: Readonly>; readonly baseEnv?: NodeJS.ProcessEnv; + readonly serverPassword?: string; }): NodeJS.ProcessEnv { const cliSpec = input.cliSpec ?? OPENCODE_CLI_SPEC; const configMode = input.configMode ?? "inherit"; + const serverPassword = input.serverPassword?.trim(); return { ...(input.baseEnv ?? process.env), ...(configMode === "inherit" @@ -275,6 +278,12 @@ export function buildOpenCodeServerProcessEnv(input: { ...(input.homePath?.trim() ? { HOME: input.homePath.trim() } : {}), ...(input.xdgConfigHome?.trim() ? { XDG_CONFIG_HOME: input.xdgConfigHome.trim() } : {}), ...input.extraEnv, + ...(serverPassword + ? { + OPENCODE_SERVER_USERNAME: cliSpec.serverAuthUsername, + OPENCODE_SERVER_PASSWORD: serverPassword, + } + : {}), }; } @@ -814,6 +823,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.homePath !== undefined ? { homePath: input.homePath } : {}), ...(input.xdgConfigHome !== undefined ? { xdgConfigHome: input.xdgConfigHome } : {}), ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + ...(input.serverPassword !== undefined + ? { serverPassword: input.serverPassword } + : {}), }), ...(input.cwd?.trim() ? { cwd: input.cwd.trim() } : {}), detached: false, @@ -956,6 +968,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.xdgConfigHome !== undefined ? { xdgConfigHome: input.xdgConfigHome } : {}), ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), }).pipe( Effect.map((server) => ({ url: server.url, diff --git a/apps/server/src/provider/providerCredentialScan.test.ts b/apps/server/src/provider/providerCredentialScan.test.ts index 8a4682d4d..3b9dfbfc5 100644 --- a/apps/server/src/provider/providerCredentialScan.test.ts +++ b/apps/server/src/provider/providerCredentialScan.test.ts @@ -124,7 +124,7 @@ describe("scanAllProviders", () => { homeDir: tempDir, }); - assert.strictEqual(result.providers.length, 7); + assert.strictEqual(result.providers.length, 9); for (const p of result.providers) { assert.strictEqual(p.hasBinary, false, `${p.provider} should have no binary`); assert.strictEqual(p.status, "not-installed", `${p.provider} should be not-installed`); @@ -264,14 +264,16 @@ describe("scanAllProviders", () => { }); describe("PROVIDER_CREDENTIAL_SPECS", () => { - it("covers all 7 providers", () => { + it("covers all provider discovery kinds", () => { const providers = PROVIDER_CREDENTIAL_SPECS.map((s) => s.provider); assert.deepStrictEqual(providers.sort(), [ "claudeAgent", "codex", "cursor", + "devin", "gemini", "kilo", + "openclaw", "opencode", "pi", ]); diff --git a/apps/server/src/provider/providerCredentialScan.ts b/apps/server/src/provider/providerCredentialScan.ts index c37be262b..8bd77736a 100644 --- a/apps/server/src/provider/providerCredentialScan.ts +++ b/apps/server/src/provider/providerCredentialScan.ts @@ -37,6 +37,12 @@ const PROVIDER_CREDENTIAL_SPECS: ReadonlyArray = [ envVars: [], configDirs: [], }, + { + provider: "devin", + binaryName: "devin", + envVars: ["DEVIN_API_KEY"], + configDirs: [], + }, { provider: "gemini", binaryName: "gemini", @@ -55,6 +61,12 @@ const PROVIDER_CREDENTIAL_SPECS: ReadonlyArray = [ envVars: [], configDirs: [".config/opencode"], }, + { + provider: "openclaw", + binaryName: "openclaw", + envVars: [], + configDirs: [], + }, { provider: "pi", binaryName: "pi", diff --git a/apps/server/src/provider/runtimeLayer.ts b/apps/server/src/provider/runtimeLayer.ts index c31839644..4ca5de0cc 100644 --- a/apps/server/src/provider/runtimeLayer.ts +++ b/apps/server/src/provider/runtimeLayer.ts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Layer, Path } from "effect"; +import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -27,14 +28,20 @@ import { ProviderDiscoveryService } from "./Services/ProviderDiscoveryService"; import { ProviderService } from "./Services/ProviderService"; import { ProviderSessionDirectory } from "./Services/ProviderSessionDirectory"; import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime"; +import { ManagedSidecarLifecycle, ManagedSidecarLifecycleLive } from "./managedRuntimeLifecycle"; export function makeServerProviderLayer(): Layer.Layer< - ProviderService | ProviderDiscoveryService | ProviderAdapterRegistry | ProviderSessionDirectory, + | ProviderService + | ProviderDiscoveryService + | ProviderAdapterRegistry + | ProviderSessionDirectory + | ManagedSidecarLifecycle, ProviderUnsupportedError | SecretStoreError, | SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | Path.Path + | HttpClient.HttpClient | AnalyticsService | ChildProcessSpawner.ChildProcessSpawner > { @@ -106,6 +113,7 @@ export function makeServerProviderLayer(): Layer.Layer< providerDiscoveryLayer, adapterRegistryLayer, providerSessionDirectoryLayer, + ManagedSidecarLifecycleLive, ); }).pipe(Layer.unwrap); } diff --git a/apps/server/src/wsRpc.test.ts b/apps/server/src/wsRpc.test.ts new file mode 100644 index 000000000..43f7ba868 --- /dev/null +++ b/apps/server/src/wsRpc.test.ts @@ -0,0 +1,408 @@ +import { readFile } from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthSessionId, + type AuthCapabilityScope, + type ManagedSidecarSnapshot, + type ManagedSidecarStartRequest, +} from "@jcode/contracts"; +import { Effect, Exit, Layer, Scope } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { describe, expect, it, vi } from "vitest"; + +import type { AuthenticatedSession } from "./auth/Services/ServerAuth.ts"; +import * as managedRuntimeDownload from "./provider/managedRuntimeDownload.ts"; +import type { ManagedSidecarLifecycleShape } from "./provider/managedRuntimeLifecycle.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { + exportManagedSidecarDiagnosticsFromLifecycle, + getManagedSidecarHealthFromLifecycle, + requireManagedSidecarHealthRpcAccess, + requireManagedSidecarDiagnosticsRpcAccess, + requireManagedSidecarRepairRpcAccess, + requireOwnerWsRpcAccess, + requireProviderStatusRpcAccess, + repairManagedSidecarFromLifecycle, + resolveLocalLegacyWsAuthSession, + skipFirstRunWizardFromRpc, +} from "./wsRpc.ts"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn((path: string) => typeof path === "string" && path.includes("opencode")), +})); + +vi.mock("./provider/managedRuntimeDownload.ts", () => ({ + verifyManagedRuntimeBinary: vi.fn(() => + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ), +})); + +const mockVerify = vi.mocked(managedRuntimeDownload.verifyManagedRuntimeBinary); +const TestLayer = Layer.merge(NodeServices.layer, FetchHttpClient.layer); +const FirstRunTestLayer = Layer.merge(ServerSettingsService.layerTest(), NodeServices.layer); +const successfulServerProbe = () => Effect.succeed(true); + +function makeAuthSession(input: { + readonly role: "owner" | "client"; + readonly scopes?: ReadonlyArray; +}): AuthenticatedSession { + return { + sessionId: AuthSessionId.makeUnsafe(`${input.role}-session`), + subject: input.role, + method: "browser-session-cookie", + role: input.role, + ...(input.scopes ? { scopes: input.scopes } : {}), + }; +} + +const READY_SNAPSHOT: ManagedSidecarSnapshot = Object.freeze({ + state: "ready", + binaryPath: "/usr/local/bin/opencode", + serverUrl: "http://127.0.0.1:9876", + serverPassword: "test-password", +}); + +async function expectWsRpcHandlerOwnerGuarded(methodExpression: string) { + const source = await readFile(new URL("./wsRpc.ts", import.meta.url), "utf8"); + const methodStart = source.indexOf(`[${methodExpression}]`); + expect(methodStart, `${methodExpression} handler exists`).toBeGreaterThanOrEqual(0); + const nextMethodStart = source.indexOf("\n [", methodStart + 1); + const handlerSource = source.slice( + methodStart, + nextMethodStart === -1 ? undefined : nextMethodStart, + ); + + expect(handlerSource).toMatch( + new RegExp(`withCurrentSession(?:Stream)?\\(\\s*requireOwnerWsRpcAccess`), + ); +} + +async function expectWsRpcHandlerScopeGuarded( + methodExpression: string, + scope: AuthCapabilityScope, +) { + const source = await readFile(new URL("./wsRpc.ts", import.meta.url), "utf8"); + const methodStart = source.indexOf(`[${methodExpression}]`); + expect(methodStart, `${methodExpression} handler exists`).toBeGreaterThanOrEqual(0); + const nextMethodStart = source.indexOf("\n [", methodStart + 1); + const handlerSource = source.slice( + methodStart, + nextMethodStart === -1 ? undefined : nextMethodStart, + ); + + expect(handlerSource).toContain(`withScope`); + expect(handlerSource).toContain(`"${scope}"`); +} + +describe("managed sidecar lifecycle layer composition", () => { + it("shares the managed sidecar lifecycle from the provider layer instead of a WS-only layer", async () => { + const wsRpcSource = await readFile(new URL("./wsRpc.ts", import.meta.url), "utf8"); + const runtimeLayerSource = await readFile( + new URL("./provider/runtimeLayer.ts", import.meta.url), + "utf8", + ); + + expect(wsRpcSource).not.toContain("ManagedSidecarLifecycleLive"); + expect(runtimeLayerSource).toContain("ManagedSidecarLifecycleLive"); + expect(runtimeLayerSource).toContain("| ManagedSidecarLifecycle"); + }); +}); + +function makeLifecycle(initialSnapshot: ManagedSidecarSnapshot = READY_SNAPSHOT) { + let currentSnapshot = initialSnapshot; + const startManagedRuntime = vi.fn( + (request?: ManagedSidecarStartRequest) => { + void request; + currentSnapshot = READY_SNAPSHOT; + return Effect.succeed(currentSnapshot); + }, + ); + const stopManagedRuntime = vi.fn(() => { + currentSnapshot = { state: "idle" }; + return Effect.succeed(currentSnapshot); + }); + const restartManagedRuntime = vi.fn(() => { + currentSnapshot = READY_SNAPSHOT; + return Effect.succeed(currentSnapshot); + }); + const getManagedRuntimeStatus = vi.fn( + () => Effect.succeed(currentSnapshot), + ); + + return { + lifecycle: { + startManagedRuntime, + stopManagedRuntime, + restartManagedRuntime, + getManagedRuntimeStatus, + } satisfies ManagedSidecarLifecycleShape, + }; +} + +describe("managed sidecar wsRpc adapters", () => { + it("does not provide a private managed sidecar lifecycle inside the websocket route", async () => { + const source = await readFile(new URL("./wsRpc.ts", import.meta.url), "utf8"); + + expect(source).not.toContain("ManagedSidecarLifecycleLive"); + expect(source).toContain("yield* ManagedSidecarLifecycle"); + }); + + it("allows owner-only WS RPC access for owners", async () => { + await expect( + Effect.runPromise(requireOwnerWsRpcAccess(makeAuthSession({ role: "owner" }))), + ).resolves.toBeUndefined(); + }); + + it("denies owner-only WS RPC access for clients without scopes", async () => { + await expect( + Effect.runPromise(requireOwnerWsRpcAccess(makeAuthSession({ role: "client" }))), + ).rejects.toThrow("requires owner role"); + }); + + it("denies owner-only WS RPC access for provider-status scoped clients", async () => { + await expect( + Effect.runPromise( + requireOwnerWsRpcAccess( + makeAuthSession({ role: "client", scopes: ["provider_status:read"] }), + ), + ), + ).rejects.toThrow("requires owner role"); + }); + + it("allows provider runtime health for provider-status scoped clients", async () => { + await expect( + Effect.runPromise( + requireProviderStatusRpcAccess( + makeAuthSession({ role: "client", scopes: ["provider_status:read"] }), + ), + ), + ).resolves.toBeUndefined(); + }); + + it("denies provider runtime health for clients without provider-status scope", async () => { + await expect( + Effect.runPromise(requireProviderStatusRpcAccess(makeAuthSession({ role: "client" }))), + ).rejects.toMatchObject({ message: "Missing required scope: provider_status:read" }); + }); + + it("allows managed sidecar health for provider-status scoped clients", async () => { + await expect( + Effect.runPromise( + requireManagedSidecarHealthRpcAccess( + makeAuthSession({ role: "client", scopes: ["provider_status:read"] }), + ), + ), + ).resolves.toBeUndefined(); + }); + + it("denies managed sidecar health for clients without provider-status scope", async () => { + await expect( + Effect.runPromise(requireManagedSidecarHealthRpcAccess(makeAuthSession({ role: "client" }))), + ).rejects.toMatchObject({ message: "Missing required scope: provider_status:read" }); + }); + + it("denies managed sidecar repair for non-owner clients even with provider-status scope", async () => { + await expect( + Effect.runPromise( + requireManagedSidecarRepairRpcAccess( + makeAuthSession({ role: "client", scopes: ["provider_status:read"] }), + ), + ), + ).rejects.toThrow("requires owner role"); + }); + + it("denies managed sidecar diagnostics for non-owner clients even with provider-status scope", async () => { + await expect( + Effect.runPromise( + requireManagedSidecarDiagnosticsRpcAccess( + makeAuthSession({ role: "client", scopes: ["provider_status:read"] }), + ), + ), + ).rejects.toThrow("requires owner role"); + }); + + it("allows managed sidecar repair and diagnostics for owners", async () => { + await expect( + Effect.runPromise(requireManagedSidecarRepairRpcAccess(makeAuthSession({ role: "owner" }))), + ).resolves.toBeUndefined(); + await expect( + Effect.runPromise( + requireManagedSidecarDiagnosticsRpcAccess(makeAuthSession({ role: "owner" })), + ), + ).resolves.toBeUndefined(); + }); + + it("maps local legacy WebSocket access to an owner-equivalent RPC session", () => { + expect( + resolveLocalLegacyWsAuthSession({ authToken: undefined, legacyToken: null }), + ).toMatchObject({ role: "owner", subject: "local-legacy-websocket" }); + expect( + resolveLocalLegacyWsAuthSession({ authToken: "local-token", legacyToken: "local-token" }), + ).toMatchObject({ role: "owner", subject: "local-legacy-websocket" }); + expect( + resolveLocalLegacyWsAuthSession({ authToken: "local-token", legacyToken: "wrong-token" }), + ).toBeNull(); + }); + + it("keeps privileged WS RPC handlers owner-only for scoped client sessions", async () => { + await expectWsRpcHandlerOwnerGuarded("ORCHESTRATION_WS_METHODS.importThread"); + await expectWsRpcHandlerOwnerGuarded("ORCHESTRATION_WS_METHODS.repairState"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsSearchLocalEntries"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsWriteFile"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.filesystemBrowse"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.shellOpenInEditor"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitPull"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRunStackedAction"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitPreparePullRequestThread"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCreateWorktree"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCreateDetachedWorktree"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRemoveWorktree"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCreateBranch"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCheckout"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStashAndCheckout"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStashDrop"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRemoveIndexLock"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitInit"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitHandoffThread"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalOpen"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalWrite"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalResize"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalClear"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalRestart"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalClose"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.subscribeTerminalEvents"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverUpdateProvider"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverTranscribeVoice"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerCompactThread"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerInstallSkill"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerUninstallSkill"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerSetSkillEnabled"); + }); + + it("keeps observable WS RPC handlers limited to explicit scopes", async () => { + await expectWsRpcHandlerScopeGuarded( + "WS_METHODS.subscribeOrchestrationDomainEvents", + "thread:read", + ); + await expectWsRpcHandlerScopeGuarded( + "WS_METHODS.serverGetProviderUsageSnapshot", + "provider_status:read", + ); + }); + + it("checks health from the lifecycle snapshot", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + const { lifecycle } = makeLifecycle(); + + const result = await Effect.runPromise( + getManagedSidecarHealthFromLifecycle({ lifecycle, serverProbe: successfulServerProbe }).pipe( + Effect.provide(TestLayer), + ), + ); + + expect(lifecycle.getManagedRuntimeStatus).toHaveBeenCalledOnce(); + expect(result.status).toBe("healthy"); + expect(result.serverReachable).toBe(true); + }); + + it("repairs through lifecycle using the force redownload request", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + const { lifecycle } = makeLifecycle({ state: "idle" }); + + const result = await Effect.runPromise( + repairManagedSidecarFromLifecycle({ + lifecycle, + request: { forceRedownload: true }, + serverProbe: successfulServerProbe, + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + expect(result.success).toBe(true); + expect(lifecycle.getManagedRuntimeStatus).not.toHaveBeenCalled(); + expect(lifecycle.stopManagedRuntime).toHaveBeenCalledOnce(); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + }); + + it("kills the old sidecar during repair and keeps the replacement attached to the caller scope", async () => { + mockVerify.mockReturnValue( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + let oldSidecarFinalized = false; + let replacementSidecarFinalized = false; + const { lifecycle } = makeLifecycle({ state: "idle" }); + const scope = await Effect.runPromise(Scope.make()); + lifecycle.stopManagedRuntime.mockImplementation(() => + Effect.sync(() => { + oldSidecarFinalized = true; + return { state: "idle" } satisfies ManagedSidecarSnapshot; + }), + ); + lifecycle.startManagedRuntime.mockImplementation((request?: ManagedSidecarStartRequest) => { + void request; + return Scope.provide( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + replacementSidecarFinalized = true; + }), + ); + return READY_SNAPSHOT; + }), + scope, + ); + }); + + const result = await Effect.runPromise( + Scope.provide( + repairManagedSidecarFromLifecycle({ + lifecycle, + request: { forceRedownload: false }, + serverProbe: successfulServerProbe, + }), + scope, + ).pipe(Effect.provide(TestLayer)), + ); + + expect(result.success).toBe(true); + expect(oldSidecarFinalized).toBe(true); + expect(replacementSidecarFinalized).toBe(false); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + expect(replacementSidecarFinalized).toBe(true); + }); + + it("exports diagnostics without leaking the sidecar password", async () => { + mockVerify.mockReturnValueOnce( + Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), + ); + const { lifecycle } = makeLifecycle(); + + const result = await Effect.runPromise( + exportManagedSidecarDiagnosticsFromLifecycle({ + lifecycle, + serverProbe: successfulServerProbe, + }).pipe(Effect.provide(TestLayer)), + ); + + expect(lifecycle.getManagedRuntimeStatus).toHaveBeenCalledOnce(); + expect(Object.hasOwn(result.sidecarSnapshot, "serverPassword")).toBe(false); + expect(JSON.stringify(result)).not.toContain("test-password"); + }); +}); + +describe("first-run wsRpc adapters", () => { + it("skips first-run through the RPC adapter", async () => { + const result = await Effect.runPromise( + skipFirstRunWizardFromRpc().pipe(Effect.provide(FirstRunTestLayer)), + ); + + expect(result.completed).toBe(true); + expect(result.skipped).toBe(true); + expect(result.completedAt).toBeDefined(); + }); +}); diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index f2a947ee5..026e8b7c3 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -5,7 +5,9 @@ import { type AuthAccessStreamEvent, type AuthCapabilityScope, type AuthClientSession, - type AuthSessionId, + AuthSessionId, + type CompleteFirstRunWizardInput, + type ManagedSidecarRepairRequest, MessageId, ORCHESTRATION_WS_METHODS, type ServerGenerateThreadRecapInput, @@ -20,6 +22,7 @@ import { type ServerConfigStreamEvent, type ServerDiagnosticsResult, type ServerLifecycleStreamEvent, + ServerProviderUpdateError, ServerVoiceTranscriptionErrorDetail, type ServerVoiceTranscriptionErrorDetail as ServerVoiceTranscriptionErrorDetailType, } from "@jcode/contracts"; @@ -68,7 +71,21 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive, } from "./provider/opencodeRuntime"; -import { makeFirstRunWizardState } from "./provider/firstRunWizard"; +import { + completeFirstRunWizard, + getFirstRunWizardData, + skipFirstRunWizard, +} from "./provider/firstRunWizard"; +import { + checkManagedSidecarHealth, + type ManagedSidecarServerProbe, + exportManagedSidecarDiagnostics, + repairManagedSidecar, +} from "./provider/managedRuntimeHealth"; +import { + ManagedSidecarLifecycle, + type ManagedSidecarLifecycleShape, +} from "./provider/managedRuntimeLifecycle"; import { checkOpenCodeRuntimeHealth } from "./provider/openCodeRuntimeHealth"; import { applyOpenClawSecretUpdate } from "./provider/openclawSecretUpdate"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; @@ -89,6 +106,22 @@ const CurrentRpcAuthSession = ServiceMap.Service( "jcode/wsRpc/CurrentRpcAuthSession", ); +export const LOCAL_LEGACY_OWNER_AUTH_SESSION: AuthenticatedSession = { + sessionId: AuthSessionId.makeUnsafe("local-legacy-websocket-owner"), + subject: "local-legacy-websocket", + method: "bearer-session-token", + role: "owner", +}; + +export function resolveLocalLegacyWsAuthSession(input: { + readonly authToken: string | undefined; + readonly legacyToken: string | null; +}): AuthenticatedSession | null { + return !input.authToken || input.legacyToken === input.authToken + ? LOCAL_LEGACY_OWNER_AUTH_SESSION + : null; +} + interface ProcessTableRow { readonly pid: number; readonly ppid: number; @@ -239,6 +272,81 @@ function withCurrentClientSession( }; } +export const getManagedSidecarHealthFromLifecycle = (input: { + readonly lifecycle: ManagedSidecarLifecycleShape; + readonly serverProbe?: ManagedSidecarServerProbe; +}) => + Effect.gen(function* () { + const sidecarSnapshot = yield* input.lifecycle.getManagedRuntimeStatus(); + return yield* checkManagedSidecarHealth({ + sidecarSnapshot, + ...(input.serverProbe ? { serverProbe: input.serverProbe } : {}), + }); + }); + +export const repairManagedSidecarFromLifecycle = (input: { + readonly lifecycle: ManagedSidecarLifecycleShape; + readonly request: ManagedSidecarRepairRequest; + readonly serverProbe?: ManagedSidecarServerProbe; +}) => + Effect.gen(function* () { + return yield* repairManagedSidecar({ + lifecycle: input.lifecycle, + forceRedownload: input.request.forceRedownload, + ...(input.serverProbe ? { serverProbe: input.serverProbe } : {}), + }); + }); + +export const exportManagedSidecarDiagnosticsFromLifecycle = (input: { + readonly lifecycle: ManagedSidecarLifecycleShape; + readonly serverProbe?: ManagedSidecarServerProbe; +}) => + Effect.gen(function* () { + const sidecarSnapshot = yield* input.lifecycle.getManagedRuntimeStatus(); + return yield* exportManagedSidecarDiagnostics({ + sidecarSnapshot, + ...(input.serverProbe ? { serverProbe: input.serverProbe } : {}), + }); + }); + +export const skipFirstRunWizardFromRpc = () => skipFirstRunWizard(); + +export const requireProviderStatusRpcAccess = ( + session: AuthenticatedSession, +): Effect.Effect => + requireScope(session, "provider_status:read").pipe( + Effect.asVoid, + Effect.mapError((err) => new WsRpcError({ message: err.message, cause: err })), + ); + +export const requireManagedSidecarHealthRpcAccess = ( + session: AuthenticatedSession, +): Effect.Effect => requireProviderStatusRpcAccess(session); + +export const requireOwnerWsRpcAccess = ( + session: AuthenticatedSession, + operation = "WS RPC operation", +): Effect.Effect => + session.role === "owner" + ? Effect.void + : Effect.fail(new WsRpcError({ message: `${operation} requires owner role` })); + +const requireManagedSidecarOwnerRpcAccess = ( + session: AuthenticatedSession, + operation: "repair" | "diagnostics", +): Effect.Effect => + session.role === "owner" + ? Effect.void + : Effect.fail(new WsRpcError({ message: `Managed sidecar ${operation} requires owner role` })); + +export const requireManagedSidecarRepairRpcAccess = ( + session: AuthenticatedSession, +): Effect.Effect => requireManagedSidecarOwnerRpcAccess(session, "repair"); + +export const requireManagedSidecarDiagnosticsRpcAccess = ( + session: AuthenticatedSession, +): Effect.Effect => requireManagedSidecarOwnerRpcAccess(session, "diagnostics"); + export const makeWsRpcLayer = () => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -260,6 +368,7 @@ export const makeWsRpcLayer = () => const providerDiscoveryService = yield* ProviderDiscoveryService; const providerHealth = yield* ProviderHealth; const providerService = yield* ProviderService; + const managedSidecarLifecycle = yield* ManagedSidecarLifecycle; const lifecycleEvents = yield* ServerLifecycleEvents; const runtimeStartup = yield* ServerRuntimeStartup; const serverEnvironment = yield* ServerEnvironment; @@ -269,7 +378,6 @@ export const makeWsRpcLayer = () => const terminalManager = yield* TerminalManager; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; - const firstRunWizard = yield* makeFirstRunWizardState; const getOpenCodeRuntimeHealth = (input: { readonly provider: "opencode"; @@ -453,6 +561,36 @@ export const makeWsRpcLayer = () => }), ); + const withCurrentSession = ( + guard: (session: AuthenticatedSession) => Effect.Effect, + effect: Effect.Effect, + ): Effect.Effect => + Effect.serviceOption(CurrentRpcAuthSession).pipe( + Effect.flatMap((sessionOpt) => { + const session = Option.getOrNull(sessionOpt); + if (!session) { + return Effect.fail(new WsRpcError({ message: "Authentication required" })); + } + return guard(session).pipe(Effect.flatMap(() => effect)); + }), + ); + + const withCurrentSessionStream = ( + guard: (session: AuthenticatedSession) => Effect.Effect, + stream: Stream.Stream, + ): Stream.Stream => + Stream.unwrap( + Effect.serviceOption(CurrentRpcAuthSession).pipe( + Effect.flatMap((sessionOpt) => { + const session = Option.getOrNull(sessionOpt); + if (!session) { + return Effect.fail(new WsRpcError({ message: "Authentication required" })); + } + return guard(session).pipe(Effect.map(() => stream)); + }), + ), + ); + /** * Wrap a Stream-returning RPC handler with a scope guard. * Validates scope before emitting any events. Owner sessions bypass all checks. @@ -531,7 +669,10 @@ export const makeWsRpcLayer = () => ), ), [ORCHESTRATION_WS_METHODS.importThread]: (input) => - rpcEffect(importThread(input), "Failed to import thread"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(importThread(input), "Failed to import thread"), + ), [ORCHESTRATION_WS_METHODS.getSnapshot]: () => withScope( "thread:read", @@ -549,7 +690,10 @@ export const makeWsRpcLayer = () => ), ), [ORCHESTRATION_WS_METHODS.repairState]: () => - rpcEffect(orchestrationEngine.repairState(), "Failed to repair orchestration state"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(orchestrationEngine.repairState(), "Failed to repair orchestration state"), + ), [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => withScope( "thread:read", @@ -629,7 +773,7 @@ export const makeWsRpcLayer = () => ), [ORCHESTRATION_WS_METHODS.unsubscribeThread]: () => withScope("thread:read", Effect.void), [WS_METHODS.subscribeOrchestrationDomainEvents]: () => - orchestrationEngine.streamDomainEvents, + withScopeStream("thread:read", orchestrationEngine.streamDomainEvents), [WS_METHODS.projectsListDirectories]: (input) => rpcEffect( @@ -639,13 +783,25 @@ export const makeWsRpcLayer = () => [WS_METHODS.projectsSearchEntries]: (input) => rpcEffect(workspaceEntries.search(input), "Failed to search workspace entries"), [WS_METHODS.projectsSearchLocalEntries]: (input) => - rpcEffect(workspaceEntries.searchLocal(input), "Failed to search local entries"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(workspaceEntries.searchLocal(input), "Failed to search local entries"), + ), [WS_METHODS.projectsWriteFile]: (input) => - rpcEffect(workspaceFileSystem.writeFile(input), "Failed to write workspace file"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(workspaceFileSystem.writeFile(input), "Failed to write workspace file"), + ), [WS_METHODS.filesystemBrowse]: (input) => - rpcEffect(workspaceEntries.browse(input), "Failed to browse filesystem"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(workspaceEntries.browse(input), "Failed to browse filesystem"), + ), [WS_METHODS.shellOpenInEditor]: (input) => - rpcEffect(open.openInEditor(input), "Failed to open editor"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(open.openInEditor(input), "Failed to open editor"), + ), [WS_METHODS.gitStatus]: (input) => rpcEffect(gitStatusBroadcaster.getStatus(input), "Failed to read git status"), @@ -654,112 +810,173 @@ export const makeWsRpcLayer = () => [WS_METHODS.gitSummarizeDiff]: (input) => rpcEffect(gitManager.summarizeDiff(input), "Failed to summarize diff"), [WS_METHODS.gitPull]: (input) => - rpcEffect( - git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to pull branch", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to pull branch", + ), ), [WS_METHODS.gitRunStackedAction]: (input) => - Stream.callback((queue) => - gitManager - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.fail(queue, toWsRpcError(cause, "Git action failed")), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), - }), - ), + withCurrentSessionStream( + requireOwnerWsRpcAccess, + Stream.callback((queue) => + gitManager + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + Effect.matchCauseEffect({ + onFailure: (cause) => + Queue.fail(queue, toWsRpcError(cause, "Git action failed")), + onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), + }), + ), + ), ), [WS_METHODS.gitResolvePullRequest]: (input) => rpcEffect(gitManager.resolvePullRequest(input), "Failed to resolve pull request"), [WS_METHODS.gitPreparePullRequestThread]: (input) => - rpcEffect( - gitManager - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to prepare pull request thread", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + gitManager + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to prepare pull request thread", + ), ), [WS_METHODS.gitListBranches]: (input) => rpcEffect(git.listBranches(input), "Failed to list branches"), [WS_METHODS.gitCreateWorktree]: (input) => - rpcEffect( - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to create worktree", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to create worktree", + ), ), [WS_METHODS.gitCreateDetachedWorktree]: (input) => - rpcEffect( - git.createDetachedWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to create detached worktree", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.createDetachedWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to create detached worktree", + ), ), [WS_METHODS.gitRemoveWorktree]: (input) => - rpcEffect( - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to remove worktree", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to remove worktree", + ), ), [WS_METHODS.gitCreateBranch]: (input) => - rpcEffect( - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to create branch", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to create branch", + ), ), [WS_METHODS.gitCheckout]: (input) => - rpcEffect( - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + "Failed to checkout branch", ), - "Failed to checkout branch", ), [WS_METHODS.gitStashAndCheckout]: (input) => - rpcEffect( - Effect.scoped(git.stashAndCheckout(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + Effect.scoped(git.stashAndCheckout(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + "Failed to stash and checkout", ), - "Failed to stash and checkout", ), [WS_METHODS.gitStashDrop]: (input) => - rpcEffect( - git.stashDrop(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to drop stash", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.stashDrop(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to drop stash", + ), ), [WS_METHODS.gitStashInfo]: (input) => rpcEffect(git.stashInfo(input), "Failed to read stash"), [WS_METHODS.gitRemoveIndexLock]: (input) => - rpcEffect(git.removeIndexLock(input), "Failed to remove Git index lock"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(git.removeIndexLock(input), "Failed to remove Git index lock"), + ), [WS_METHODS.gitInit]: (input) => - rpcEffect( - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to initialize repository", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to initialize repository", + ), ), [WS_METHODS.gitHandoffThread]: (input) => - rpcEffect( - gitManager.handoffThread(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - "Failed to hand off thread", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + gitManager.handoffThread(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + "Failed to hand off thread", + ), ), [WS_METHODS.terminalOpen]: (input) => - rpcEffect(terminalManager.open(input), "Failed to open terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.open(input), "Failed to open terminal"), + ), [WS_METHODS.terminalWrite]: (input) => - rpcEffect(terminalManager.write(input), "Failed to write terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.write(input), "Failed to write terminal"), + ), [WS_METHODS.terminalResize]: (input) => - rpcEffect(terminalManager.resize(input), "Failed to resize terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.resize(input), "Failed to resize terminal"), + ), [WS_METHODS.terminalClear]: (input) => - rpcEffect(terminalManager.clear(input), "Failed to clear terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.clear(input), "Failed to clear terminal"), + ), [WS_METHODS.terminalRestart]: (input) => - rpcEffect(terminalManager.restart(input), "Failed to restart terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.restart(input), "Failed to restart terminal"), + ), [WS_METHODS.terminalClose]: (input) => - rpcEffect(terminalManager.close(input), "Failed to close terminal"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(terminalManager.close(input), "Failed to close terminal"), + ), [WS_METHODS.subscribeTerminalEvents]: () => - Stream.callback((queue) => - Effect.gen(function* () { - const unsubscribe = yield* terminalManager.subscribe((event) => { - Effect.runFork(Queue.offer(queue, event).pipe(Effect.asVoid)); - }); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - }), + withCurrentSessionStream( + requireOwnerWsRpcAccess, + Stream.callback((queue) => + Effect.gen(function* () { + const unsubscribe = yield* terminalManager.subscribe((event) => { + Effect.runFork(Queue.offer(queue, event).pipe(Effect.asVoid)); + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + }), + ), ), [WS_METHODS.serverGetConfig]: () => @@ -770,116 +987,177 @@ export const makeWsRpcLayer = () => [WS_METHODS.serverGetEnvironment]: () => rpcEffect(serverEnvironment.getDescriptor, "Failed to load server environment"), [WS_METHODS.serverGetSettings]: () => - rpcEffect(serverSettings.getSettings, "Failed to load server settings"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(serverSettings.getSettings, "Failed to load server settings"), + ), [WS_METHODS.serverUpdateSettings]: (input) => - rpcEffect(serverSettings.updateSettings(input), "Failed to update server settings"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(serverSettings.updateSettings(input), "Failed to update server settings"), + ), [WS_METHODS.serverUpdateOpenClawSecrets]: (input) => - rpcEffect( - Effect.gen(function* () { - const metadata = yield* applyOpenClawSecretUpdate(input).pipe( - Effect.provideService(ServerSecretStore, serverSecretStore), - ); - yield* serverSettings.updateOpenClawSecretMetadata({ - hasSecret: metadata.hasToken || metadata.hasPassword, - paired: metadata.paired, - }); - return metadata; - }), - "Failed to update OpenClaw secrets", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + Effect.gen(function* () { + const metadata = yield* applyOpenClawSecretUpdate(input).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ); + yield* serverSettings.updateOpenClawSecretMetadata({ + hasSecret: metadata.hasToken || metadata.hasPassword, + paired: metadata.paired, + }); + return metadata; + }), + "Failed to update OpenClaw secrets", + ), ), [WS_METHODS.serverRefreshProviders]: () => - rpcEffect( - providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), - "Failed to refresh providers", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), + "Failed to refresh providers", + ), + ), + [WS_METHODS.serverUpdateProvider]: (input) => + withCurrentSession(requireOwnerWsRpcAccess, providerHealth.updateProvider(input)).pipe( + Effect.mapError( + (cause): ServerProviderUpdateError => + Schema.is(ServerProviderUpdateError)(cause) + ? cause + : new ServerProviderUpdateError({ + provider: input.provider, + reason: + cause instanceof Error && cause.message.length > 0 + ? cause.message + : "Provider update requires owner role", + }), + ), ), - [WS_METHODS.serverUpdateProvider]: (input) => providerHealth.updateProvider(input), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), [WS_METHODS.serverGetProviderUsageSnapshot]: (input) => - rpcEffect(getProviderUsageSnapshot(input), "Failed to load provider usage"), + withScope( + "provider_status:read", + rpcEffect(getProviderUsageSnapshot(input), "Failed to load provider usage"), + ), [WS_METHODS.serverGetDiagnostics]: () => - rpcEffect( - Effect.gen(function* () { - const [projection, fullChildProcesses] = yield* Effect.all([ - projectionReadModelQuery.getCounts(), - Effect.promise(() => readDescendantProcesses(process.pid)), - ]); - const childProcesses = fullChildProcesses.slice(0, MAX_DIAGNOSTIC_CHILD_PROCESSES); - const memory = process.memoryUsage(); - const diagnostics: ServerDiagnosticsResult = { - generatedAt: new Date().toISOString(), - process: { - pid: process.pid, - uptimeSeconds: Math.max(0, Math.round(process.uptime())), - memory: { - rssBytes: Math.max(0, Math.round(memory.rss)), - heapTotalBytes: Math.max(0, Math.round(memory.heapTotal)), - heapUsedBytes: Math.max(0, Math.round(memory.heapUsed)), - externalBytes: Math.max(0, Math.round(memory.external)), - arrayBuffersBytes: Math.max(0, Math.round(memory.arrayBuffers)), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + Effect.gen(function* () { + const [projection, fullChildProcesses] = yield* Effect.all([ + projectionReadModelQuery.getCounts(), + Effect.promise(() => readDescendantProcesses(process.pid)), + ]); + const childProcesses = fullChildProcesses.slice(0, MAX_DIAGNOSTIC_CHILD_PROCESSES); + const memory = process.memoryUsage(); + const diagnostics: ServerDiagnosticsResult = { + generatedAt: new Date().toISOString(), + process: { + pid: process.pid, + uptimeSeconds: Math.max(0, Math.round(process.uptime())), + memory: { + rssBytes: Math.max(0, Math.round(memory.rss)), + heapTotalBytes: Math.max(0, Math.round(memory.heapTotal)), + heapUsedBytes: Math.max(0, Math.round(memory.heapUsed)), + externalBytes: Math.max(0, Math.round(memory.external)), + arrayBuffersBytes: Math.max(0, Math.round(memory.arrayBuffers)), + }, }, - }, - childProcesses, - childProcessTotalCount: fullChildProcesses.length, - childProcessTotalRssBytes: fullChildProcesses.reduce( - (total, processRow) => total + processRow.rssBytes, - 0, - ), - projection, - }; - return diagnostics; - }), - "Failed to load server diagnostics", + childProcesses, + childProcessTotalCount: fullChildProcesses.length, + childProcessTotalRssBytes: fullChildProcesses.reduce( + (total, processRow) => total + processRow.rssBytes, + 0, + ), + projection, + }; + return diagnostics; + }), + "Failed to load server diagnostics", + ), ), [WS_METHODS.serverTranscribeVoice]: (input) => - providerAdapterRegistry.getByProvider(input.provider).pipe( - Effect.flatMap((adapter) => - adapter.transcribeVoice - ? adapter.transcribeVoice(input) - : Effect.fail( - new Error( - `Voice transcription is unavailable for provider '${input.provider}'.`, + withCurrentSession( + requireOwnerWsRpcAccess, + providerAdapterRegistry.getByProvider(input.provider).pipe( + Effect.flatMap((adapter) => + adapter.transcribeVoice + ? adapter.transcribeVoice(input) + : Effect.fail( + new Error( + `Voice transcription is unavailable for provider '${input.provider}'.`, + ), ), - ), - ), - Effect.mapError((cause) => - toWsRpcError( - cause, - "Voice transcription failed", - readVoiceTranscriptionErrorDetail(cause), + ), + Effect.mapError((cause) => + toWsRpcError( + cause, + "Voice transcription failed", + readVoiceTranscriptionErrorDetail(cause), + ), ), ), ), [WS_METHODS.serverUpsertKeybinding]: (input) => - rpcEffect( - keybindings - .upsertKeybindingRule(input) - .pipe( - Effect.map((keybindingsConfig) => ({ keybindings: keybindingsConfig, issues: [] })), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + keybindings.upsertKeybindingRule(input).pipe( + Effect.map((keybindingsConfig) => ({ + keybindings: keybindingsConfig, + issues: [], + })), ), - "Failed to update keybinding", + "Failed to update keybinding", + ), ), [WS_METHODS.serverResetKeybinding]: (input) => - rpcEffect( - keybindings - .resetKeybindingCommand(input.command) - .pipe( - Effect.map((keybindingsConfig) => ({ keybindings: keybindingsConfig, issues: [] })), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + keybindings.resetKeybindingCommand(input.command).pipe( + Effect.map((keybindingsConfig) => ({ + keybindings: keybindingsConfig, + issues: [], + })), ), - "Failed to reset keybinding", + "Failed to reset keybinding", + ), ), [WS_METHODS.serverResetAllKeybindings]: () => - rpcEffect( - keybindings - .resetAllKeybindings() - .pipe( - Effect.map((keybindingsConfig) => ({ keybindings: keybindingsConfig, issues: [] })), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + keybindings.resetAllKeybindings().pipe( + Effect.map((keybindingsConfig) => ({ + keybindings: keybindingsConfig, + issues: [], + })), ), - "Failed to reset keybindings", + "Failed to reset keybindings", + ), ), [WS_METHODS.serverGetFirstRunWizardData]: () => - rpcEffect(firstRunWizard.getWizardData, "Failed to get first-run wizard data"), - [WS_METHODS.serverCompleteFirstRunWizard]: (input) => - rpcEffect(firstRunWizard.completeWizard(input), "Failed to complete first-run wizard"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(getFirstRunWizardData(), "Failed to get first-run wizard data"), + ), + [WS_METHODS.serverCompleteFirstRunWizard]: (input: CompleteFirstRunWizardInput) => + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + completeFirstRunWizard(input, { managedSidecarLifecycle }), + "Failed to complete first-run wizard", + ), + ), + [WS_METHODS.serverSkipFirstRun]: () => + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(skipFirstRunWizardFromRpc(), "Failed to skip first-run wizard"), + ), [WS_METHODS.serverGenerateThreadRecap]: (input: ServerGenerateThreadRecapInput) => withScope( "thread:read", @@ -966,117 +1244,129 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerConfig]: () => - Stream.concat( - Stream.fromEffect( - loadServerConfig.pipe( - Effect.map( - (config): ServerConfigStreamEvent => ({ type: "snapshot" as const, config }), + withCurrentSessionStream( + requireOwnerWsRpcAccess, + Stream.concat( + Stream.fromEffect( + loadServerConfig.pipe( + Effect.map( + (config): ServerConfigStreamEvent => ({ type: "snapshot" as const, config }), + ), ), ), - ), - Stream.merge( - keybindings.streamChanges.pipe( - Stream.map((event) => ({ - type: "configUpdated" as const, - payload: { issues: event.issues, providers: [] }, - })), - ), Stream.merge( - providerHealth.streamChanges.pipe( - Stream.map((providers) => ({ - type: "providerStatuses" as const, - payload: { providers }, + keybindings.streamChanges.pipe( + Stream.map((event) => ({ + type: "configUpdated" as const, + payload: { issues: event.issues, providers: [] }, })), ), - serverSettings.streamChanges.pipe( - Stream.map((settings) => ({ - type: "settingsUpdated" as const, - payload: { settings }, - })), + Stream.merge( + providerHealth.streamChanges.pipe( + Stream.map((providers) => ({ + type: "providerStatuses" as const, + payload: { providers }, + })), + ), + serverSettings.streamChanges.pipe( + Stream.map((settings) => ({ + type: "settingsUpdated" as const, + payload: { settings }, + })), + ), ), ), - ), - ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), + ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), + ), [WS_METHODS.subscribeServerProviderStatuses]: () => withScopeStream( "provider_status:read", providerHealth.streamChanges.pipe(Stream.map((providers) => ({ providers }))), ), [WS_METHODS.subscribeServerSettings]: () => - serverSettings.streamChanges.pipe(Stream.map((settings) => ({ settings }))), + withCurrentSessionStream( + requireOwnerWsRpcAccess, + serverSettings.streamChanges.pipe(Stream.map((settings) => ({ settings }))), + ), [WS_METHODS.subscribeAuthAccess]: () => - Stream.unwrap( - Effect.gen(function* () { - const currentAuthSession = yield* Effect.serviceOption(CurrentRpcAuthSession); - const currentSessionId = - Option.isSome(currentAuthSession) && currentAuthSession.value !== null - ? currentAuthSession.value.sessionId - : null; - const revisionRef = yield* Ref.make(0); - const nextRevision = Ref.updateAndGet(revisionRef, (revision) => revision + 1); + withCurrentSessionStream( + requireOwnerWsRpcAccess, + Stream.unwrap( + Effect.gen(function* () { + const currentAuthSession = yield* Effect.serviceOption(CurrentRpcAuthSession); + const currentSessionId = + Option.isSome(currentAuthSession) && currentAuthSession.value !== null + ? currentAuthSession.value.sessionId + : null; + const revisionRef = yield* Ref.make(0); + const nextRevision = Ref.updateAndGet(revisionRef, (revision) => revision + 1); - const snapshotStream = Stream.fromEffect( - Effect.gen(function* () { - const revision = yield* nextRevision; - const [pairingLinks, clientSessions] = yield* Effect.all( - [bootstrapCredentials.listActive(), sessionCredentials.listActive()], - { concurrency: 2 }, - ); - return { - type: "snapshot", - revision, - access: { - pairingLinks, - clientSessions: clientSessions.map((clientSession) => - withCurrentClientSession(clientSession, currentSessionId), - ), - }, - } satisfies AuthAccessStreamEvent; - }), - ); + const snapshotStream = Stream.fromEffect( + Effect.gen(function* () { + const revision = yield* nextRevision; + const [pairingLinks, clientSessions] = yield* Effect.all( + [bootstrapCredentials.listActive(), sessionCredentials.listActive()], + { concurrency: 2 }, + ); + return { + type: "snapshot", + revision, + access: { + pairingLinks, + clientSessions: clientSessions.map((clientSession) => + withCurrentClientSession(clientSession, currentSessionId), + ), + }, + } satisfies AuthAccessStreamEvent; + }), + ); - const pairingLinkChanges = bootstrapCredentials.streamChanges.pipe( - Stream.mapEffect((event) => - Effect.map(nextRevision, (revision) => - event.type === "pairingLinkUpserted" - ? ({ - type: "pairingLinkUpserted", - revision, - pairingLink: event.pairingLink, - } satisfies AuthAccessStreamEvent) - : ({ - type: "pairingLinkRemoved", - revision, - id: event.id, - } satisfies AuthAccessStreamEvent), + const pairingLinkChanges = bootstrapCredentials.streamChanges.pipe( + Stream.mapEffect((event) => + Effect.map(nextRevision, (revision) => + event.type === "pairingLinkUpserted" + ? ({ + type: "pairingLinkUpserted", + revision, + pairingLink: event.pairingLink, + } satisfies AuthAccessStreamEvent) + : ({ + type: "pairingLinkRemoved", + revision, + id: event.id, + } satisfies AuthAccessStreamEvent), + ), ), - ), - ); + ); - const clientChanges = sessionCredentials.streamChanges.pipe( - Stream.mapEffect((event) => - Effect.map(nextRevision, (revision) => - event.type === "clientUpserted" - ? ({ - type: "clientUpserted", - revision, - clientSession: withCurrentClientSession( - event.clientSession, - currentSessionId, - ), - } satisfies AuthAccessStreamEvent) - : ({ - type: "clientRemoved", - revision, - sessionId: event.sessionId, - } satisfies AuthAccessStreamEvent), + const clientChanges = sessionCredentials.streamChanges.pipe( + Stream.mapEffect((event) => + Effect.map(nextRevision, (revision) => + event.type === "clientUpserted" + ? ({ + type: "clientUpserted", + revision, + clientSession: withCurrentClientSession( + event.clientSession, + currentSessionId, + ), + } satisfies AuthAccessStreamEvent) + : ({ + type: "clientRemoved", + revision, + sessionId: event.sessionId, + } satisfies AuthAccessStreamEvent), + ), ), - ), - ); + ); - return Stream.concat(snapshotStream, Stream.merge(pairingLinkChanges, clientChanges)); - }), - ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Auth access stream failed"))), + return Stream.concat( + snapshotStream, + Stream.merge(pairingLinkChanges, clientChanges), + ); + }), + ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Auth access stream failed"))), + ), [WS_METHODS.providerGetComposerCapabilities]: (input) => rpcEffect( @@ -1084,26 +1374,71 @@ export const makeWsRpcLayer = () => "Failed to get composer capabilities", ), [WS_METHODS.providerGetRuntimeHealth]: (input) => - rpcEffect( - getOpenCodeRuntimeHealth({ - provider: input.provider, - ...(input.profileId !== undefined ? { profileId: input.profileId } : {}), - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - }), - "Failed to get runtime health", + withCurrentSession( + requireProviderStatusRpcAccess, + rpcEffect( + getOpenCodeRuntimeHealth({ + provider: input.provider, + ...(input.profileId !== undefined ? { profileId: input.profileId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }), + "Failed to get runtime health", + ), + ), + [WS_METHODS.providerGetManagedSidecarHealth]: () => + withCurrentSession( + requireManagedSidecarHealthRpcAccess, + rpcEffect( + getManagedSidecarHealthFromLifecycle({ lifecycle: managedSidecarLifecycle }), + "Failed to get managed sidecar health", + ), + ), + [WS_METHODS.providerRepairManagedSidecar]: (input) => + withCurrentSession( + requireManagedSidecarRepairRpcAccess, + rpcEffect( + repairManagedSidecarFromLifecycle({ + lifecycle: managedSidecarLifecycle, + request: input, + }), + "Failed to repair managed sidecar", + ), + ), + [WS_METHODS.providerExportManagedSidecarDiagnostics]: () => + withCurrentSession( + requireManagedSidecarDiagnosticsRpcAccess, + rpcEffect( + exportManagedSidecarDiagnosticsFromLifecycle({ lifecycle: managedSidecarLifecycle }), + "Failed to export managed sidecar diagnostics", + ), ), [WS_METHODS.providerCompactThread]: (input) => - rpcEffect(providerService.compactThread(input), "Failed to compact thread"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerService.compactThread(input), "Failed to compact thread"), + ), [WS_METHODS.providerListCommands]: (input) => rpcEffect(providerDiscoveryService.listCommands(input), "Failed to list commands"), [WS_METHODS.providerListSkills]: (input) => rpcEffect(providerDiscoveryService.listSkills(input), "Failed to list skills"), [WS_METHODS.providerInstallSkill]: (input) => - rpcEffect(providerDiscoveryService.installSkill(input), "Failed to install skill"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.installSkill(input), "Failed to install skill"), + ), [WS_METHODS.providerUninstallSkill]: (input) => - rpcEffect(providerDiscoveryService.uninstallSkill(input), "Failed to uninstall skill"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.uninstallSkill(input), "Failed to uninstall skill"), + ), [WS_METHODS.providerSetSkillEnabled]: (input) => - rpcEffect(providerDiscoveryService.setSkillEnabled(input), "Failed to set skill enabled"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + providerDiscoveryService.setSkillEnabled(input), + "Failed to set skill enabled", + ), + ), [WS_METHODS.providerSearchSkillsCatalog]: (input) => rpcEffect( providerDiscoveryService.searchSkillsCatalog(input), @@ -1142,18 +1477,20 @@ export const websocketRpcRouteLayer = Layer.effectDiscard( const sessions = yield* SessionCredentialService; const url = HttpServerRequest.toURL(request); const legacyToken = url ? url.searchParams.get("token") : null; + const localLegacySession = resolveLocalLegacyWsAuthSession({ + authToken: config.authToken, + legacyToken, + }); const authenticatedSession = - !config.authToken || legacyToken === config.authToken - ? null + localLegacySession !== null + ? localLegacySession : yield* serverAuth.authenticateWebSocketUpgrade(makeEffectAuthRequest(request)); - const rpcWebSocketHttpEffect = !authenticatedSession - ? yield* makeRpcWebSocketHttpEffect - : yield* makeRpcWebSocketHttpEffect.pipe( - Effect.provideService(CurrentRpcAuthSession, authenticatedSession), - ); + const rpcWebSocketHttpEffect = yield* makeRpcWebSocketHttpEffect.pipe( + Effect.provideService(CurrentRpcAuthSession, authenticatedSession), + ); - if (!authenticatedSession) return yield* rpcWebSocketHttpEffect; + if (localLegacySession !== null) return yield* rpcWebSocketHttpEffect; return yield* Effect.acquireUseRelease( sessions.markConnected(authenticatedSession.sessionId), diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 9d36be11f..2f03783ac 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -1,6 +1,7 @@ import "../index.css"; import { + AuthHttpRoutes, DEFAULT_SERVER_SETTINGS, EventId, MessageId, @@ -8,6 +9,8 @@ import { ProjectId, ThreadId, TurnId, + type AuthSessionState, + type FirstRunWizardData, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationShellStreamEvent, @@ -22,6 +25,7 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http } from "msw"; import { setupWorker } from "msw/browser"; +import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -40,6 +44,8 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; + firstRunWizardData: FirstRunWizardData; + authSession: AuthSessionState; welcome: WsWelcomePayload; } @@ -73,6 +79,41 @@ function createBaseServerConfig(): ServerConfig { }; } +function createFirstRunWizardData( + overrides?: Partial>, +): FirstRunWizardData { + return { + state: { completed: false, skipped: false, ...overrides?.state }, + currentStep: overrides?.currentStep ?? "select-provider", + scanResults: { + scannedAt: NOW_ISO, + providers: [ + { + provider: "opencode", + status: "not-installed", + hasCredentials: false, + hasBinary: false, + credentials: [], + }, + ], + }, + }; +} + +function createAuthenticatedSession(): AuthSessionState { + return { + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "browser-session-cookie", + }, + role: "owner", + sessionMethod: "browser-session-cookie", + }; +} + function createSnapshot(overrides?: Partial) { return { snapshotSequence: 1, @@ -146,6 +187,11 @@ function buildFixture(): TestFixture { return { snapshot: createSnapshot(), serverConfig: createBaseServerConfig(), + firstRunWizardData: createFirstRunWizardData({ + currentStep: "complete", + state: { completed: true, skipped: false, completedAt: NOW_ISO }, + }), + authSession: createAuthenticatedSession(), welcome: { cwd: "/repo/project", projectName: "Project", @@ -236,6 +282,9 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === WS_METHODS.serverGetSettings) { return DEFAULT_SERVER_SETTINGS; } + if (tag === WS_METHODS.serverGetFirstRunWizardData) { + return fixture.firstRunWizardData; + } if (tag === WS_METHODS.serverGetEnvironment) { return { environmentId: "test-browser", @@ -306,6 +355,7 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { const worker = setupWorker( http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), + http.get(`*${AuthHttpRoutes.session.pathname}`, () => HttpResponse.json(fixture.authSession)), ); function installTransportDriver(): void { @@ -363,6 +413,7 @@ function installTransportDriver(): void { } async function mountApp(options?: { + initialPath?: string; routeThreadId?: ThreadId; waitForThreadId?: ThreadId | null; }): Promise<{ cleanup: () => Promise }> { @@ -376,7 +427,8 @@ async function mountApp(options?: { document.body.append(host); const routeThreadId = options?.routeThreadId ?? THREAD_ID; - const router = getRouter(createMemoryHistory({ initialEntries: [`/${routeThreadId}`] })); + const initialPath = options?.initialPath ?? `/${routeThreadId}`; + const router = getRouter(createMemoryHistory({ initialEntries: [initialPath] })); const screen = await render(, { container: host }); await vi.waitFor( @@ -432,8 +484,8 @@ describe("EventRouter scoped orchestration sync", () => { }); }); - afterAll(async () => { - await worker.stop(); + afterAll(() => { + worker.stop(); }); beforeEach(() => { @@ -491,6 +543,48 @@ describe("EventRouter scoped orchestration sync", () => { document.body.innerHTML = ""; }); + it("shows the first-run wizard instead of the routed workspace when authenticated setup is incomplete", async () => { + fixture.firstRunWizardData = createFirstRunWizardData(); + const mounted = await mountApp(); + + try { + await expect.element(page.getByText("Welcome to JCode")).toBeVisible(); + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + expect(document.body.textContent).not.toContain("hello"); + } finally { + await mounted.cleanup(); + } + }); + + it("renders routed children normally when authenticated setup is complete", async () => { + fixture.firstRunWizardData = createFirstRunWizardData({ + currentStep: "complete", + state: { completed: true, skipped: false, completedAt: NOW_ISO }, + }); + const mounted = await mountApp(); + + try { + await expect.element(page.getByText("hello")).toBeVisible(); + expect(document.body.textContent).not.toContain("Welcome to JCode"); + expect(document.body.textContent).not.toContain("Choose a provider"); + } finally { + await mounted.cleanup(); + } + }); + + it("does not block the pair route with the first-run wizard", async () => { + fixture.firstRunWizardData = createFirstRunWizardData(); + const mounted = await mountApp({ initialPath: "/pair", waitForThreadId: null }); + + try { + await expect.element(page.getByRole("button", { name: "Pair client" })).toBeVisible(); + expect(document.body.textContent).not.toContain("Welcome to JCode"); + expect(document.body.textContent).not.toContain("Choose a provider"); + } finally { + await mounted.cleanup(); + } + }); + it("drops duplicate thread events after the thread snapshot sequence advances", async () => { const mounted = await mountApp(); diff --git a/apps/web/src/components/FirstRunWizard.browser.tsx b/apps/web/src/components/FirstRunWizard.browser.tsx new file mode 100644 index 000000000..2bf80790b --- /dev/null +++ b/apps/web/src/components/FirstRunWizard.browser.tsx @@ -0,0 +1,315 @@ +import "../index.css"; + +import { + DEFAULT_SERVER_SETTINGS, + type FirstRunWizardData, + type NativeApi, + type OrchestrationShellSnapshot, + type ServerConfig, +} from "@jcode/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { getRouter } from "../router"; +import { FirstRunWizard } from "./FirstRunWizard"; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +const selectProviderWizardData: FirstRunWizardData = { + state: { completed: false, skipped: false }, + currentStep: "select-provider", + scanResults: { + scannedAt: "2026-06-12T12:00:00.000Z", + providers: [ + { + provider: "opencode", + status: "not-installed", + hasCredentials: false, + hasBinary: false, + credentials: [], + }, + ], + }, +}; + +const selectableWizardData: FirstRunWizardData = { + state: { completed: false, skipped: false }, + currentStep: "select-provider", + scanResults: { + scannedAt: "2026-06-12T12:01:00.000Z", + providers: [ + { + provider: "opencode", + status: "ready", + hasCredentials: true, + hasBinary: true, + binaryPath: "/tmp/jcode/opencode", + version: "1.0.0", + credentials: [{ source: "env-var", key: "OPENCODE_API_KEY", found: true }], + }, + ], + }, +}; + +const completedState = { + completed: true, + skipped: false, + completedAt: "2026-06-12T12:02:00.000Z", +}; + +const skippedState = { + completed: false, + skipped: true, +}; + +const completedWizardData: FirstRunWizardData = { + state: completedState, + currentStep: "complete", + scanResults: selectableWizardData.scanResults, +}; + +const testServerConfig: ServerConfig = { + cwd: "/repo/project", + worktreesDir: "/repo/project/.jcode/worktrees", + keybindingsConfigPath: "/repo/project/.jcode/keybindings.json", + keybindings: [], + issues: [], + providers: [], + availableEditors: [], +}; + +const emptyShellSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-12T12:03:00.000Z", +}; + +const serverApi = { + getAuthSession: vi.fn(async () => ({ authenticated: true })), + getConfig: vi.fn(async () => testServerConfig), + getEnvironment: vi.fn(async () => ({ + environmentId: "first-run-root-browser-test", + label: "First run root browser test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: false }, + })), + getSettings: vi.fn(async () => DEFAULT_SERVER_SETTINGS), + listWorktrees: vi.fn(async () => ({ worktrees: [] })), + getProviderUsageSnapshot: vi.fn(async () => null), + updateProvider: vi.fn(async () => ({ providers: testServerConfig.providers })), + getFirstRunWizardData: vi.fn(async () => selectProviderWizardData), + completeFirstRunWizard: vi.fn(async () => completedState), + skipFirstRun: vi.fn(async () => skippedState), +}; + +const orchestrationApi = { + onShellEvent: vi.fn(() => () => undefined), + onThreadEvent: vi.fn(() => () => undefined), + subscribeShell: vi.fn(async () => undefined), + unsubscribeShell: vi.fn(async () => undefined), + subscribeThread: vi.fn(async () => undefined), + unsubscribeThread: vi.fn(async () => undefined), + replayEvents: vi.fn(async () => []), + getShellSnapshot: vi.fn(async () => emptyShellSnapshot), + repairState: vi.fn(async () => emptyShellSnapshot), +}; + +const terminalApi = { + onEvent: vi.fn(() => () => undefined), +}; + +function installNativeApi(data: FirstRunWizardData) { + serverApi.getAuthSession.mockResolvedValue({ authenticated: true }); + serverApi.getFirstRunWizardData.mockResolvedValue(data); + window.nativeApi = { + server: serverApi, + orchestration: orchestrationApi, + terminal: terminalApi, + } as unknown as NativeApi; +} + +async function renderRoutedApp(path = "/") { + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter(createMemoryHistory({ initialEntries: [path] })); + const screen = await render(, { container: host }); + + return { + unmount: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +async function renderWizard() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + , + ); +} + +describe("FirstRunWizard root integration", () => { + afterEach(() => { + Reflect.deleteProperty(window, "nativeApi"); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the wizard instead of the normal outlet when authenticated first-run is incomplete", async () => { + installNativeApi(selectProviderWizardData); + const screen = await renderRoutedApp(); + + try { + await expect.element(page.getByText("Welcome to JCode")).toBeVisible(); + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + await vi.waitFor(() => { + expect(serverApi.getFirstRunWizardData).toHaveBeenCalledTimes(1); + }); + } finally { + await screen.unmount(); + } + }); + + it("renders normal route outlet content when first-run is completed", async () => { + installNativeApi(completedWizardData); + const screen = await renderRoutedApp(); + + try { + await expect.element(page.getByAltText("JCode")).toBeVisible(); + await expect.element(page.getByText("Welcome to JCode")).not.toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); +}); + +describe("FirstRunWizard", () => { + afterEach(() => { + Reflect.deleteProperty(window, "nativeApi"); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("allows selecting clean-install OpenCode and completing with that provider", async () => { + installNativeApi(selectProviderWizardData); + const screen = await renderWizard(); + + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + const openCodeButton = page.getByRole("button", { name: /OpenCode/i }); + await expect.element(openCodeButton).toBeEnabled(); + + await openCodeButton.click(); + await page.getByRole("button", { name: "Continue" }).click(); + + await vi.waitFor(() => { + expect(serverApi.completeFirstRunWizard).toHaveBeenCalledWith({ provider: "opencode" }); + }); + + await screen.unmount(); + }); + + it("exposes the selected provider card state to assistive technology", async () => { + installNativeApi(selectProviderWizardData); + const screen = await renderWizard(); + + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + const openCodeButton = page.getByRole("button", { name: /OpenCode/i }); + + await expect.element(openCodeButton).toHaveAttribute("aria-pressed", "false"); + await openCodeButton.click(); + await expect.element(openCodeButton).toHaveAttribute("aria-pressed", "true"); + + await screen.unmount(); + }); + + it("disables Continue and shows pending progress while completion is pending", async () => { + const completion = createDeferred(); + serverApi.completeFirstRunWizard.mockImplementation(() => completion.promise); + installNativeApi(selectableWizardData); + const screen = await renderWizard(); + + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + await page.getByRole("button", { name: /OpenCode/i }).click(); + + const continueButton = page.getByRole("button", { name: "Continue" }); + await continueButton.click(); + + await expect.element(page.getByRole("button", { name: "Completing..." })).toBeDisabled(); + await expect.element(page.getByRole("button", { name: "Skip for now" })).toBeDisabled(); + expect(serverApi.completeFirstRunWizard).toHaveBeenCalledTimes(1); + + completion.resolve(completedState); + await vi.waitFor(() => { + expect(serverApi.getFirstRunWizardData).toHaveBeenCalled(); + }); + + await screen.unmount(); + }); + + it("disables Continue while skip is pending", async () => { + installNativeApi(selectableWizardData); + serverApi.skipFirstRun.mockImplementationOnce(() => new Promise(() => {})); + const screen = await renderWizard(); + + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + await page.getByRole("button", { name: /OpenCode/i }).click(); + await page.getByRole("button", { name: "Skip for now" }).click(); + + await vi.waitFor(() => { + expect(serverApi.skipFirstRun).toHaveBeenCalledTimes(1); + }); + + await expect.element(page.getByRole("button", { name: "Continue" })).toBeDisabled(); + + await screen.unmount(); + }); + + it("skips first-run without completing the wizard", async () => { + installNativeApi(selectableWizardData); + const screen = await renderWizard(); + + await expect.element(page.getByText("Choose a provider")).toBeVisible(); + await page.getByRole("button", { name: "Skip for now" }).click(); + + await vi.waitFor(() => { + expect( + serverApi.skipFirstRun.mock.calls.length + + serverApi.completeFirstRunWizard.mock.calls.length, + ).toBeGreaterThan(0); + }); + expect(serverApi.completeFirstRunWizard).not.toHaveBeenCalledWith({}); + expect(serverApi.skipFirstRun).toHaveBeenCalledTimes(1); + + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/FirstRunWizard.tsx b/apps/web/src/components/FirstRunWizard.tsx index 58e87b83f..8887a47fa 100644 --- a/apps/web/src/components/FirstRunWizard.tsx +++ b/apps/web/src/components/FirstRunWizard.tsx @@ -10,15 +10,17 @@ import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "../lib/icons"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "./ui/button"; -const FIRST_RUN_QUERY_KEY = ["server", "firstRunWizard"] as const; +export const FIRST_RUN_WIZARD_QUERY_KEY = ["server", "firstRunWizard"] as const; const PROVIDER_DISPLAY_NAMES: Record = { codex: "Codex", claudeAgent: "Claude", cursor: "Cursor", + devin: "Devin", gemini: "Gemini", kilo: "Kilo", opencode: "OpenCode", + openclaw: "OpenClaw", pi: "Pi", }; @@ -48,6 +50,14 @@ function statusBadge(status: ProviderScanResult["status"]) { } } +function isProviderSelectable(result: ProviderScanResult) { + return ( + result.status === "ready" || + result.status === "needs-config" || + (result.provider === "opencode" && result.status === "not-installed") + ); +} + function ProviderCard({ result, selected, @@ -57,10 +67,11 @@ function ProviderCard({ selected: boolean; onSelect: (provider: ProviderDiscoveryKind) => void; }) { - const isSelectable = result.status === "ready" || result.status === "needs-config"; + const isSelectable = isProviderSelectable(result); return ( @@ -98,13 +107,18 @@ function ProviderSelectionStep({ providers, onComplete, onSkip, + completePending, + skipPending, }: { - providers: ProviderScanResult[]; + providers: readonly ProviderScanResult[]; onComplete: (provider: ProviderDiscoveryKind | undefined) => void; onSkip: () => void; + completePending: boolean; + skipPending: boolean; }) { const [selected, setSelected] = useState(null); const readyProviders = providers.filter((p) => p.status === "ready"); + const actionPending = completePending || skipPending; const handleSelect = useCallback( (provider: ProviderDiscoveryKind) => { @@ -140,11 +154,18 @@ function ProviderSelectionStep({
- -
@@ -167,23 +188,31 @@ export function FirstRunWizard() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: FIRST_RUN_QUERY_KEY, + queryKey: FIRST_RUN_WIZARD_QUERY_KEY, queryFn: async () => { const api = ensureNativeApi(); return api.server.getFirstRunWizardData(); }, - staleTime: 0, + staleTime: 15_000, }); const completeMutation = useMutation({ mutationFn: async (provider: ProviderDiscoveryKind | undefined) => { const api = ensureNativeApi(); - return api.server.completeFirstRunWizard( - provider ? { provider } : {}, - ); + return api.server.completeFirstRunWizard(provider ? { provider } : {}); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: FIRST_RUN_WIZARD_QUERY_KEY }); + }, + }); + + const skipMutation = useMutation({ + mutationFn: async () => { + const api = ensureNativeApi(); + return api.server.skipFirstRun(); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: FIRST_RUN_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: FIRST_RUN_WIZARD_QUERY_KEY }); }, }); @@ -207,7 +236,7 @@ export function FirstRunWizard() { variant="outline" size="sm" onClick={() => - void queryClient.refetchQueries({ queryKey: FIRST_RUN_QUERY_KEY }) + void queryClient.refetchQueries({ queryKey: FIRST_RUN_WIZARD_QUERY_KEY }) } > Retry @@ -242,7 +271,7 @@ export function FirstRunWizard() { }; const handleSkip = () => { - completeMutation.mutate(undefined); + skipMutation.mutate(); }; return ( @@ -258,6 +287,8 @@ export function FirstRunWizard() { providers={wizardData.scanResults.providers} onComplete={handleComplete} onSkip={handleSkip} + completePending={completeMutation.isPending} + skipPending={skipMutation.isPending} /> diff --git a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx new file mode 100644 index 000000000..f2617251e --- /dev/null +++ b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.browser.tsx @@ -0,0 +1,182 @@ +import "../index.css"; + +import type { + ManagedSidecarDiagnostics, + ManagedSidecarHealthCheck, + ManagedSidecarRepairResult, + NativeApi, + OpenCodeRuntimeHealth, +} 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 RUNTIME_HEALTH: OpenCodeRuntimeHealth = { + provider: "opencode", + profileId: "managed", + profileLabel: "Managed sidecar", + mode: "managed", + configMode: "inherit", + status: "healthy", + serverUrl: "http://127.0.0.1:4096", + external: false, + capabilities: { + commands: { count: 1, names: ["run"] }, + skills: { count: 0, names: [] }, + plugins: { count: 0, names: [] }, + agents: { count: 0, names: [] }, + models: { count: 1, slugs: ["openai/gpt-5.5"] }, + }, + mismatches: [], + checkedAt: "2026-06-07T12:00:00.000Z", +}; + +const SIDECAR_HEALTH: ManagedSidecarHealthCheck = { + status: "healthy", + sidecarState: "ready", + binaryPath: "/tmp/jcode/opencode", + binaryExists: true, + binaryValid: true, + serverUrl: "http://127.0.0.1:4096", + serverReachable: true, + checkedAt: "2026-06-07T12:01:00.000Z", +}; + +const SIDECAR_REPAIR: ManagedSidecarRepairResult = { + success: true, + health: SIDECAR_HEALTH, +}; + +const SIDECAR_DIAGNOSTICS: ManagedSidecarDiagnostics = { + generatedAt: "2026-06-07T12:02:00.000Z", + health: SIDECAR_HEALTH, + platform: { + os: "linux", + arch: "x64", + nodeVersion: "v26.2.0", + }, + binaryVersion: "unknown", + logs: [], + sidecarSnapshot: { + state: "ready", + binaryPath: "/tmp/jcode/opencode", + serverUrl: "http://127.0.0.1:4096", + }, +}; + +const providerApi = { + getRuntimeHealth: vi.fn(async () => RUNTIME_HEALTH), + getManagedSidecarHealth: vi.fn(async () => SIDECAR_HEALTH), + repairManagedSidecar: vi.fn(async () => SIDECAR_REPAIR), + exportManagedSidecarDiagnostics: vi.fn(async () => SIDECAR_DIAGNOSTICS), +}; + +const nativeApi = { + provider: providerApi, +} as unknown as NativeApi; + +const originalCreateObjectUrl = URL.createObjectURL; +const originalRevokeObjectUrl = URL.revokeObjectURL; + +describe("OpenCodeRuntimeSettingsPanel", () => { + beforeEach(() => { + providerApi.getRuntimeHealth.mockClear(); + providerApi.getManagedSidecarHealth.mockClear(); + providerApi.repairManagedSidecar.mockClear(); + providerApi.exportManagedSidecarDiagnostics.mockClear(); + providerApi.getRuntimeHealth.mockResolvedValue(RUNTIME_HEALTH); + providerApi.getManagedSidecarHealth.mockResolvedValue(SIDECAR_HEALTH); + providerApi.repairManagedSidecar.mockResolvedValue(SIDECAR_REPAIR); + providerApi.exportManagedSidecarDiagnostics.mockResolvedValue(SIDECAR_DIAGNOSTICS); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: vi.fn(() => "blob:jcode-sidecar-diagnostics"), + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(), + }); + window.nativeApi = nativeApi; + }); + + afterEach(() => { + Reflect.deleteProperty(window, "nativeApi"); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: originalCreateObjectUrl, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectUrl, + }); + document.body.innerHTML = ""; + }); + + it("checks managed sidecar health and shows the result", async () => { + const screen = await render(); + + await vi.waitFor(() => { + expect(providerApi.getRuntimeHealth).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Profile ID: managed"); + }); + + await page.getByRole("button", { name: "Check sidecar health" }).click(); + + await vi.waitFor(() => { + expect(providerApi.getManagedSidecarHealth).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Sidecar: healthy"); + expect(document.body.textContent).toContain("Profile ID: managed"); + expect(document.body.textContent).toContain("State: ready"); + expect(document.body.textContent).toContain("Server reachable"); + }); + + await screen.unmount(); + }); + + it("repairs the managed sidecar without forcing a redownload", async () => { + const screen = await render(); + + await page.getByRole("button", { name: "Repair sidecar" }).click(); + + await vi.waitFor(() => { + expect(providerApi.repairManagedSidecar).toHaveBeenCalledWith({ + forceRedownload: false, + }); + expect(document.body.textContent).toContain("Repair succeeded"); + expect(document.body.textContent).toContain("Sidecar: healthy"); + }); + + await screen.unmount(); + }); + + it("exports managed sidecar diagnostics as a JSON download", async () => { + const screen = await render(); + + await page.getByRole("button", { name: "Export diagnostics" }).click(); + + await vi.waitFor(() => { + expect(providerApi.exportManagedSidecarDiagnostics).toHaveBeenCalledTimes(1); + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:jcode-sidecar-diagnostics"); + expect(document.body.textContent).toContain("Diagnostics exported"); + }); + + await screen.unmount(); + }); + + it("shows an inline failure when managed sidecar health fails", async () => { + providerApi.getManagedSidecarHealth.mockRejectedValueOnce(new Error("sidecar unavailable")); + const screen = await render(); + + await page.getByRole("button", { name: "Check sidecar health" }).click(); + + await expect + .element(page.getByRole("alert")) + .toHaveTextContent("Sidecar health check failed: sidecar unavailable"); + await expect.element(page.getByRole("button", { name: "Check sidecar health" })).toBeEnabled(); + + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx index 4ef40f7d1..3fc9ef450 100644 --- a/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx +++ b/apps/web/src/components/OpenCodeRuntimeSettingsPanel.tsx @@ -1,11 +1,23 @@ -import type { OpenCodeRuntimeHealth } from "@jcode/contracts"; +import type { + ManagedSidecarDiagnostics, + ManagedSidecarHealthCheck, + OpenCodeRuntimeHealth, +} from "@jcode/contracts"; import { useCallback, useEffect, useState } from "react"; -import { Loader2Icon, RefreshCwIcon } from "../lib/icons"; +import { DownloadIcon, HammerIcon, Loader2Icon, RefreshCwIcon } from "../lib/icons"; +import { cn } from "../lib/utils"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; +type SidecarAction = "health" | "repair" | "diagnostics"; + +interface SidecarFeedback { + readonly status: "success" | "failed"; + readonly message: string; +} + function statusClassName(status: OpenCodeRuntimeHealth["status"]): string { switch (status) { case "healthy": @@ -29,9 +41,46 @@ function capabilityLine( return `${label}: ${summary.count}`; } +function sidecarStatusClassName(status: ManagedSidecarHealthCheck["status"]): string { + switch (status) { + case "healthy": + return "text-emerald-500"; + case "degraded": + case "not_running": + case "repairing": + return "text-amber-500"; + case "unhealthy": + return "text-destructive"; + } +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function downloadManagedSidecarDiagnostics(diagnostics: ManagedSidecarDiagnostics): void { + const payload = JSON.stringify(diagnostics, null, 2); + const blob = new Blob([payload], { type: "application/json" }); + const objectUrl = URL.createObjectURL(blob); + const generatedAt = diagnostics.generatedAt.replace(/[:.]/g, "-"); + + try { + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `jcode-managed-sidecar-diagnostics-${generatedAt}.json`; + link.rel = "noopener"; + link.click(); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + export function OpenCodeRuntimeSettingsPanel() { const [health, setHealth] = useState(null); + const [sidecarHealth, setSidecarHealth] = useState(null); + const [sidecarFeedback, setSidecarFeedback] = useState(null); const [isChecking, setIsChecking] = useState(false); + const [sidecarPendingAction, setSidecarPendingAction] = useState(null); const checkRuntime = useCallback(async (forceRefresh = false) => { setIsChecking(true); @@ -45,13 +94,87 @@ export function OpenCodeRuntimeSettingsPanel() { toastManager.add({ type: "error", title: "OpenCode runtime check failed", - description: (error as Error).message, + description: errorMessage(error, "The runtime health check failed."), }); } finally { setIsChecking(false); } }, []); + const checkSidecarHealth = useCallback(async () => { + setSidecarPendingAction("health"); + setSidecarFeedback(null); + try { + const result = await ensureNativeApi().provider.getManagedSidecarHealth(); + setSidecarHealth(result); + setSidecarFeedback({ status: "success", message: `Sidecar: ${result.status}` }); + } catch (error) { + const message = `Sidecar health check failed: ${errorMessage( + error, + "The managed sidecar health check failed.", + )}`; + setSidecarFeedback({ status: "failed", message }); + toastManager.add({ + type: "error", + title: "Sidecar health check failed", + description: errorMessage(error, "The managed sidecar health check failed."), + }); + } finally { + setSidecarPendingAction(null); + } + }, []); + + const repairSidecar = useCallback(async () => { + setSidecarPendingAction("repair"); + setSidecarFeedback(null); + try { + const result = await ensureNativeApi().provider.repairManagedSidecar({ + forceRedownload: false, + }); + setSidecarHealth(result.health); + setSidecarFeedback({ + status: result.success ? "success" : "failed", + message: result.success + ? "Repair succeeded" + : `Repair failed${result.error ? `: ${result.error}` : ""}`, + }); + } catch (error) { + const message = `Repair failed: ${errorMessage(error, "The managed sidecar repair failed.")}`; + setSidecarFeedback({ status: "failed", message }); + toastManager.add({ + type: "error", + title: "Sidecar repair failed", + description: errorMessage(error, "The managed sidecar repair failed."), + }); + } finally { + setSidecarPendingAction(null); + } + }, []); + + const exportSidecarDiagnostics = useCallback(async () => { + setSidecarPendingAction("diagnostics"); + setSidecarFeedback(null); + try { + const diagnostics = await ensureNativeApi().provider.exportManagedSidecarDiagnostics(); + downloadManagedSidecarDiagnostics(diagnostics); + setSidecarHealth(diagnostics.health); + setSidecarFeedback({ status: "success", message: "Diagnostics exported" }); + } catch (error) { + const message = `Diagnostics export failed: ${errorMessage( + error, + "The managed sidecar diagnostics export failed.", + )}`; + setSidecarFeedback({ status: "failed", message }); + toastManager.add({ + type: "error", + title: "Diagnostics export failed", + description: errorMessage(error, "The managed sidecar diagnostics export failed."), + }); + } finally { + setSidecarPendingAction(null); + } + }, []); + useEffect(() => { void checkRuntime(false); }, [checkRuntime]); @@ -97,6 +220,7 @@ export function OpenCodeRuntimeSettingsPanel() {
{capabilityLine("Plugins", health.capabilities.plugins)}
{capabilityLine("Agents", health.capabilities.agents)}
{capabilityLine("Models", health.capabilities.models)}
+
Profile ID: {health.profileId}
Config: {health.configMode}
) : null} @@ -123,6 +247,98 @@ export function OpenCodeRuntimeSettingsPanel() { ) : null} ) : null} + +
+
+
+
Managed sidecar
+
+ Check, repair, or export diagnostics for the managed OpenCode sidecar. +
+
+
+ + + +
+
+ + {sidecarFeedback ? ( +

+ {sidecarFeedback.message} +

+ ) : null} + + {sidecarHealth ? ( +
+
+ Sidecar:{" "} + + {sidecarHealth.status} + +
+
State: {sidecarHealth.sidecarState}
+
{sidecarHealth.serverReachable ? "Server reachable" : "Server unreachable"}
+
{sidecarHealth.binaryValid ? "Binary valid" : "Binary invalid"}
+ {sidecarHealth.serverUrl ? ( +
+ {sidecarHealth.serverUrl} +
+ ) : null} + {sidecarHealth.error ? ( +
Error: {sidecarHealth.error}
+ ) : null} +
+ ) : null} +
); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 79b0950d6..8b5e01c20 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { PROVIDER_DISPLAY_NAMES, + type FirstRunWizardData, ThreadId, type OrchestrationEvent, type OrchestrationShellSnapshot, @@ -23,6 +24,7 @@ import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; +import { FIRST_RUN_WIZARD_QUERY_KEY, FirstRunWizard } from "../components/FirstRunWizard"; import ShortcutsDialog from "../components/ShortcutsDialog"; import WhatsNewDialog from "../components/WhatsNewDialog"; import { useWhatsNew } from "../whatsNew/useWhatsNew"; @@ -143,13 +145,47 @@ function RootRouteView() { - + + + ); } +function shouldShowFirstRunWizard(data: FirstRunWizardData | undefined): boolean { + if (!data) return false; + return !data.state.completed && !data.state.skipped && data.currentStep !== "complete"; +} + +function FirstRunOutletGate({ children }: { readonly children: React.ReactNode }) { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const firstRunQuery = useQuery({ + queryKey: FIRST_RUN_WIZARD_QUERY_KEY, + enabled: pathname !== "/pair", + staleTime: 15_000, + queryFn: async () => { + const api = ensureNativeApi(); + return api.server.getFirstRunWizardData(); + }, + }); + + if (pathname === "/pair") { + return <>{children}; + } + + if (shouldShowFirstRunWizard(firstRunQuery.data)) { + return ; + } + + if (firstRunQuery.isLoading) { + return null; + } + + return <>{children}; +} + function AuthSessionGate({ children }: { readonly children: React.ReactNode }) { const pathname = useRouterState({ select: (state) => state.location.pathname }); const authSessionQuery = useQuery(serverAuthSessionQueryOptions()); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 89c9fffb5..0e03f657e 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, CommandId, type ContextMenuItem, + DEFAULT_FIRST_RUN_STATE, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -29,6 +30,7 @@ const showContextMenuFallbackMock = >(); const channelListeners = new Map void>>(); const latestPushByChannel = new Map(); +const transportInstances: Array<{ disposed: boolean; request: typeof requestMock }> = []; const subscribeMock = vi.fn< ( channel: string, @@ -54,11 +56,23 @@ const subscribeMock = vi.fn< vi.mock("./wsTransport", () => { return { WsTransport: class MockWsTransport { - request = requestMock; + disposed = false; + request = vi.fn<(...args: Array) => Promise>((...args) => + requestMock(...args), + ); subscribe = subscribeMock; + constructor() { + transportInstances.push(this); + } getLatestPush(channel: string) { return latestPushByChannel.get(channel) ?? null; } + getState() { + return this.disposed ? "disposed" : "open"; + } + dispose() { + this.disposed = true; + } }, }; }); @@ -109,6 +123,7 @@ beforeEach(() => { requestMock.mockReset(); showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); + transportInstances.length = 0; channelListeners.clear(); latestPushByChannel.clear(); nextPushSequence = 1; @@ -320,6 +335,7 @@ describe("wsNativeApi", () => { pi: { enabled: true, binaryPath: "pi", agentDir: "", customModels: [] }, devin: { enabled: true, binaryPath: "devin", customModels: [] }, }, + firstRun: DEFAULT_FIRST_RUN_STATE, }, } as const; emitPush(WS_CHANNELS.serverSettingsUpdated, payload); @@ -572,6 +588,65 @@ describe("wsNativeApi", () => { expect(result).toMatchObject({ authenticated: true, sessionMethod: "browser-session-cookie" }); }); + it("uses a fresh WebSocket transport after successful browser-session bootstrap", async () => { + requestMock.mockResolvedValue({}); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + authenticated: true, + role: "owner", + sessionMethod: "browser-session-cookie", + expiresAt: "2026-01-01T00:00:00.000Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.provider.getManagedSidecarHealth(); + const preAuthTransport = transportInstances[0]; + expect(preAuthTransport).toBeDefined(); + + await api.server.bootstrapAuth({ credential: "PAIRINGTOKEN" }); + await api.provider.getManagedSidecarHealth(); + + expect(preAuthTransport?.disposed).toBe(true); + expect(transportInstances).toHaveLength(2); + expect(transportInstances[0]?.request).toHaveBeenCalledTimes(1); + expect(transportInstances[1]?.request).toHaveBeenCalledWith( + WS_METHODS.providerGetManagedSidecarHealth, + undefined, + ); + }); + + it("keeps the existing WebSocket transport when browser-session bootstrap fails", async () => { + requestMock.mockResolvedValue({}); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid pairing token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.provider.getManagedSidecarHealth(); + const preAuthTransport = transportInstances[0]; + expect(preAuthTransport).toBeDefined(); + + await expect(api.server.bootstrapAuth({ credential: "BADTOKEN" })).rejects.toThrow( + "invalid pairing token", + ); + await api.provider.getManagedSidecarHealth(); + + expect(preAuthTransport?.disposed).toBe(false); + expect(transportInstances).toHaveLength(1); + expect(transportInstances[0]?.request).toHaveBeenCalledTimes(2); + }); + it("uses no client timeout for git.runStackedAction", async () => { requestMock.mockResolvedValue({ action: "commit", @@ -636,6 +711,40 @@ describe("wsNativeApi", () => { ); }); + it("routes managed sidecar provider methods over WebSocket transport", async () => { + requestMock.mockResolvedValue({}); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.provider.getManagedSidecarHealth(); + await api.provider.repairManagedSidecar({ forceRedownload: true }); + await api.provider.exportManagedSidecarDiagnostics(); + + expect(requestMock).toHaveBeenNthCalledWith( + 1, + WS_METHODS.providerGetManagedSidecarHealth, + undefined, + ); + expect(requestMock).toHaveBeenNthCalledWith(2, WS_METHODS.providerRepairManagedSidecar, { + forceRedownload: true, + }); + expect(requestMock).toHaveBeenNthCalledWith( + 3, + WS_METHODS.providerExportManagedSidecarDiagnostics, + undefined, + ); + }); + + it("routes first-run skip over WebSocket transport", async () => { + requestMock.mockResolvedValue({ completed: true, skipped: true }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.skipFirstRun(); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverSkipFirstRun, {}); + }); + it("forwards context menu metadata to desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); Object.defineProperty(getWindowForTest(), "desktopBridge", { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 58b48ae1a..ca3f2e9cb 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -100,6 +100,37 @@ export function __resetWsNativeApiForTests(): void { fallbackBrowserStates.clear(); } +function currentTransport(): WsTransport { + if (!instance || instance.transport.getState() === "disposed") { + createWsNativeApi(); + } + + if (!instance) { + throw new Error("WebSocket transport unavailable"); + } + + return instance.transport; +} + +function requestWs( + method: string, + params?: unknown, + options?: { readonly timeoutMs?: number | null }, +): Promise { + if (arguments.length >= 3) { + return currentTransport().request(method, params, options); + } + if (arguments.length >= 2) { + return currentTransport().request(method, params); + } + return currentTransport().request(method); +} + +function resetTransportAfterBrowserSessionAuth(): void { + instance?.transport.dispose(); + instance = null; +} + function defaultBrowserState(threadId: ThreadId): ThreadBrowserState { return { threadId, @@ -508,12 +539,12 @@ export function createWsNativeApi(): NativeApi { }, }, terminal: { - open: (input) => transport.request(WS_METHODS.terminalOpen, input), - write: (input) => transport.request(WS_METHODS.terminalWrite, input), - resize: (input) => transport.request(WS_METHODS.terminalResize, input), - clear: (input) => transport.request(WS_METHODS.terminalClear, input), - restart: (input) => transport.request(WS_METHODS.terminalRestart, input), - close: (input) => transport.request(WS_METHODS.terminalClose, input), + open: (input) => requestWs(WS_METHODS.terminalOpen, input), + write: (input) => requestWs(WS_METHODS.terminalWrite, input), + resize: (input) => requestWs(WS_METHODS.terminalResize, input), + clear: (input) => requestWs(WS_METHODS.terminalClear, input), + restart: (input) => requestWs(WS_METHODS.terminalRestart, input), + close: (input) => requestWs(WS_METHODS.terminalClose, input), onEvent: (callback) => { terminalEventListeners.add(callback); return () => { @@ -522,18 +553,16 @@ export function createWsNativeApi(): NativeApi { }, }, projects: { - listDirectories: (input) => transport.request(WS_METHODS.projectsListDirectories, input), - searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), - searchLocalEntries: (input) => - transport.request(WS_METHODS.projectsSearchLocalEntries, input), - writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + listDirectories: (input) => requestWs(WS_METHODS.projectsListDirectories, input), + searchEntries: (input) => requestWs(WS_METHODS.projectsSearchEntries, input), + searchLocalEntries: (input) => requestWs(WS_METHODS.projectsSearchLocalEntries, input), + writeFile: (input) => requestWs(WS_METHODS.projectsWriteFile, input), }, filesystem: { - browse: (input) => transport.request(WS_METHODS.filesystemBrowse, input), + browse: (input) => requestWs(WS_METHODS.filesystemBrowse, input), }, shell: { - openInEditor: (cwd, editor) => - transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), + openInEditor: (cwd, editor) => requestWs(WS_METHODS.shellOpenInEditor, { cwd, editor }), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -555,33 +584,31 @@ export function createWsNativeApi(): NativeApi { }, }, git: { - pull: (input) => transport.request(WS_METHODS.gitPull, input), - status: (input) => transport.request(WS_METHODS.gitStatus, input), - readWorkingTreeDiff: (input) => transport.request(WS_METHODS.gitReadWorkingTreeDiff, input), + pull: (input) => requestWs(WS_METHODS.gitPull, input), + status: (input) => requestWs(WS_METHODS.gitStatus, input), + readWorkingTreeDiff: (input) => requestWs(WS_METHODS.gitReadWorkingTreeDiff, input), summarizeDiff: (input) => - transport.request(WS_METHODS.gitSummarizeDiff, input, { + requestWs(WS_METHODS.gitSummarizeDiff, input, { timeoutMs: null, }), runStackedAction: (input) => - transport.request(WS_METHODS.gitRunStackedAction, input, { + requestWs(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null, }), - listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), - createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), - createDetachedWorktree: (input) => - transport.request(WS_METHODS.gitCreateDetachedWorktree, input), - removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), - createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), - checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), - stashAndCheckout: (input) => transport.request(WS_METHODS.gitStashAndCheckout, input), - stashDrop: (input) => transport.request(WS_METHODS.gitStashDrop, input), - stashInfo: (input) => transport.request(WS_METHODS.gitStashInfo, input), - removeIndexLock: (input) => transport.request(WS_METHODS.gitRemoveIndexLock, input), - init: (input) => transport.request(WS_METHODS.gitInit, input), - handoffThread: (input) => transport.request(WS_METHODS.gitHandoffThread, input), - resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), - preparePullRequestThread: (input) => - transport.request(WS_METHODS.gitPreparePullRequestThread, input), + listBranches: (input) => requestWs(WS_METHODS.gitListBranches, input), + createWorktree: (input) => requestWs(WS_METHODS.gitCreateWorktree, input), + createDetachedWorktree: (input) => requestWs(WS_METHODS.gitCreateDetachedWorktree, input), + removeWorktree: (input) => requestWs(WS_METHODS.gitRemoveWorktree, input), + createBranch: (input) => requestWs(WS_METHODS.gitCreateBranch, input), + checkout: (input) => requestWs(WS_METHODS.gitCheckout, input), + stashAndCheckout: (input) => requestWs(WS_METHODS.gitStashAndCheckout, input), + stashDrop: (input) => requestWs(WS_METHODS.gitStashDrop, input), + stashInfo: (input) => requestWs(WS_METHODS.gitStashInfo, input), + removeIndexLock: (input) => requestWs(WS_METHODS.gitRemoveIndexLock, input), + init: (input) => requestWs(WS_METHODS.gitInit, input), + handoffThread: (input) => requestWs(WS_METHODS.gitHandoffThread, input), + resolvePullRequest: (input) => requestWs(WS_METHODS.gitResolvePullRequest, input), + preparePullRequestThread: (input) => requestWs(WS_METHODS.gitPreparePullRequestThread, input), onActionProgress: (callback) => { gitActionProgressListeners.add(callback); return () => { @@ -601,21 +628,28 @@ export function createWsNativeApi(): NativeApi { }, }, server: { - getConfig: () => transport.request(WS_METHODS.serverGetConfig), - getEnvironment: () => transport.request(WS_METHODS.serverGetEnvironment), - getSettings: () => transport.request(WS_METHODS.serverGetSettings), - updateSettings: (input) => transport.request(WS_METHODS.serverUpdateSettings, input), - updateOpenClawSecrets: (input) => - transport.request(WS_METHODS.serverUpdateOpenClawSecrets, input), + getConfig: () => requestWs(WS_METHODS.serverGetConfig), + getEnvironment: () => requestWs(WS_METHODS.serverGetEnvironment), + getSettings: () => requestWs(WS_METHODS.serverGetSettings), + updateSettings: (input) => requestWs(WS_METHODS.serverUpdateSettings, input), + updateOpenClawSecrets: (input) => requestWs(WS_METHODS.serverUpdateOpenClawSecrets, input), getAuthSession: () => requestAuthJson(AuthHttpRoutes.session.pathname, { method: AuthHttpRoutes.session.method, }), - bootstrapAuth: (input: AuthBootstrapInput) => - requestAuthJson(AuthHttpRoutes.bootstrap.pathname, { - method: AuthHttpRoutes.bootstrap.method, - body: input, - }), + bootstrapAuth: async (input: AuthBootstrapInput) => { + const result = await requestAuthJson( + AuthHttpRoutes.bootstrap.pathname, + { + method: AuthHttpRoutes.bootstrap.method, + body: input, + }, + ); + if (result.sessionMethod === "browser-session-cookie") { + resetTransportAfterBrowserSessionAuth(); + } + return result; + }, bootstrapBearerAuth: (input: AuthBootstrapInput) => requestAuthJson(AuthHttpRoutes.bootstrapBearer.pathname, { method: AuthHttpRoutes.bootstrapBearer.method, @@ -653,76 +687,77 @@ export function createWsNativeApi(): NativeApi { method: AuthHttpRoutes.revokeOtherClients.method, }), onAuthAccess, - refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), - updateProvider: (input) => transport.request(WS_METHODS.serverUpdateProvider, input), - listWorktrees: () => transport.request(WS_METHODS.serverListWorktrees), + refreshProviders: () => requestWs(WS_METHODS.serverRefreshProviders), + updateProvider: (input) => requestWs(WS_METHODS.serverUpdateProvider, input), + listWorktrees: () => requestWs(WS_METHODS.serverListWorktrees), getProviderUsageSnapshot: (input) => - transport.request(WS_METHODS.serverGetProviderUsageSnapshot, input), - getDiagnostics: () => transport.request(WS_METHODS.serverGetDiagnostics), + requestWs(WS_METHODS.serverGetProviderUsageSnapshot, input), + getDiagnostics: () => requestWs(WS_METHODS.serverGetDiagnostics), transcribeVoice: (input) => { if (window.desktopBridge?.server?.transcribeVoice) { return window.desktopBridge.server.transcribeVoice(input); } - return transport.request(WS_METHODS.serverTranscribeVoice, input); - }, - upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), - resetKeybinding: (input) => transport.request(WS_METHODS.serverResetKeybinding, input), - resetAllKeybindings: () => transport.request(WS_METHODS.serverResetAllKeybindings, {}), - getFirstRunWizardData: () => transport.request(WS_METHODS.serverGetFirstRunWizardData, {}), - completeFirstRunWizard: (input) => - transport.request(WS_METHODS.serverCompleteFirstRunWizard, input), - generateThreadRecap: (input) => - transport.request(WS_METHODS.serverGenerateThreadRecap, input), + return requestWs(WS_METHODS.serverTranscribeVoice, input); + }, + upsertKeybinding: (input) => requestWs(WS_METHODS.serverUpsertKeybinding, input), + resetKeybinding: (input) => requestWs(WS_METHODS.serverResetKeybinding, input), + resetAllKeybindings: () => requestWs(WS_METHODS.serverResetAllKeybindings, {}), + getFirstRunWizardData: () => requestWs(WS_METHODS.serverGetFirstRunWizardData, {}), + completeFirstRunWizard: (input) => requestWs(WS_METHODS.serverCompleteFirstRunWizard, input), + skipFirstRun: () => requestWs(WS_METHODS.serverSkipFirstRun, {}), + generateThreadRecap: (input) => requestWs(WS_METHODS.serverGenerateThreadRecap, input), }, provider: { getComposerCapabilities: (input) => - transport.request(WS_METHODS.providerGetComposerCapabilities, input), - getRuntimeHealth: (input) => transport.request(WS_METHODS.providerGetRuntimeHealth, 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), - installSkill: (input) => transport.request(WS_METHODS.providerInstallSkill, input), - uninstallSkill: (input) => transport.request(WS_METHODS.providerUninstallSkill, input), - setSkillEnabled: (input) => transport.request(WS_METHODS.providerSetSkillEnabled, input), - searchSkillsCatalog: (input) => - transport.request(WS_METHODS.providerSearchSkillsCatalog, input), - listPlugins: (input) => transport.request(WS_METHODS.providerListPlugins, input), - readPlugin: (input) => transport.request(WS_METHODS.providerReadPlugin, input), - listModels: (input) => transport.request(WS_METHODS.providerListModels, input), - listAgents: (input) => transport.request(WS_METHODS.providerListAgents, input), + requestWs(WS_METHODS.providerGetComposerCapabilities, input), + getRuntimeHealth: (input) => requestWs(WS_METHODS.providerGetRuntimeHealth, input), + getManagedSidecarHealth: () => + requestWs(WS_METHODS.providerGetManagedSidecarHealth, undefined), + repairManagedSidecar: (input) => requestWs(WS_METHODS.providerRepairManagedSidecar, input), + exportManagedSidecarDiagnostics: () => + requestWs(WS_METHODS.providerExportManagedSidecarDiagnostics, undefined), + compactThread: (input) => requestWs(WS_METHODS.providerCompactThread, input), + listCommands: (input) => requestWs(WS_METHODS.providerListCommands, input), + listSkills: (input) => requestWs(WS_METHODS.providerListSkills, input), + installSkill: (input) => requestWs(WS_METHODS.providerInstallSkill, input), + uninstallSkill: (input) => requestWs(WS_METHODS.providerUninstallSkill, input), + setSkillEnabled: (input) => requestWs(WS_METHODS.providerSetSkillEnabled, input), + searchSkillsCatalog: (input) => requestWs(WS_METHODS.providerSearchSkillsCatalog, input), + listPlugins: (input) => requestWs(WS_METHODS.providerListPlugins, input), + readPlugin: (input) => requestWs(WS_METHODS.providerReadPlugin, input), + listModels: (input) => requestWs(WS_METHODS.providerListModels, input), + listAgents: (input) => requestWs(WS_METHODS.providerListAgents, input), }, orchestration: { getSnapshot: () => - transport.request(ORCHESTRATION_WS_METHODS.getSnapshot, undefined, { + requestWs(ORCHESTRATION_WS_METHODS.getSnapshot, undefined, { timeoutMs: null, }), getShellSnapshot: () => - transport.request(ORCHESTRATION_WS_METHODS.getShellSnapshot, undefined, { + requestWs(ORCHESTRATION_WS_METHODS.getShellSnapshot, undefined, { timeoutMs: null, }), dispatchCommand: (command) => { - return transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { + return requestWs(ORCHESTRATION_WS_METHODS.dispatchCommand, { command: omitNullUserInputAnswers(command), }); }, - importThread: (input) => transport.request(ORCHESTRATION_WS_METHODS.importThread, input), - repairState: () => transport.request(ORCHESTRATION_WS_METHODS.repairState), - getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), + importThread: (input) => requestWs(ORCHESTRATION_WS_METHODS.importThread, input), + repairState: () => requestWs(ORCHESTRATION_WS_METHODS.repairState), + getTurnDiff: (input) => requestWs(ORCHESTRATION_WS_METHODS.getTurnDiff, input), getFullThreadDiff: (input) => - transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input, { + requestWs(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input, { timeoutMs: null, }), replayEvents: (fromSequenceExclusive) => - transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { + requestWs(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive, }), - subscribeShell: () => transport.request(ORCHESTRATION_WS_METHODS.subscribeShell, {}), - unsubscribeShell: () => - transport.request(ORCHESTRATION_WS_METHODS.unsubscribeShell, {}), - subscribeThread: (input) => - transport.request(ORCHESTRATION_WS_METHODS.subscribeThread, input), + subscribeShell: () => requestWs(ORCHESTRATION_WS_METHODS.subscribeShell, {}), + unsubscribeShell: () => requestWs(ORCHESTRATION_WS_METHODS.unsubscribeShell, {}), + subscribeThread: (input) => requestWs(ORCHESTRATION_WS_METHODS.subscribeThread, input), unsubscribeThread: (input) => - transport.request(ORCHESTRATION_WS_METHODS.unsubscribeThread, input), + requestWs(ORCHESTRATION_WS_METHODS.unsubscribeThread, input), onDomainEvent: (callback) => { orchestrationDomainEventListeners.add(callback); return () => { diff --git a/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md b/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md index 468a0fc62..f71c57715 100644 --- a/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md +++ b/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md @@ -6,7 +6,7 @@ ## Context -ADR 0005 defined scoped capability tokens for remote clients and wired the first HTTP route guard. The WS RPC layer — where real-time cockpit interactions happen — has no scope checks. Any authenticated session (owner or client) can access all ~65 WS RPC methods, including thread reading, approval responding, git operations, terminal commands, and project writes. +ADR 0008 defined scoped capability tokens for remote clients and wired the first HTTP route guard. The WS RPC layer — where real-time cockpit interactions happen — has no scope checks. Any authenticated session (owner or client) can access all ~65 WS RPC methods, including thread reading, approval responding, git operations, terminal commands, and project writes. Remote clients with scoped tokens should only access methods matching their granted scopes. Owner sessions must continue to bypass all checks with zero overhead. @@ -28,7 +28,7 @@ Wire scope guards into the WS RPC layer using a hybrid approach: - `provider_status:read` → serverGetConfig, subscribeServerProviderStatuses - All other methods remain ungated (available to any authenticated session) or owner-only (enforced by `withCommandScope` rejecting non-owner clients for unrecognized command types) -4. **Owner bypass**: Owner sessions have `scopes: undefined`. The `requireScope` guard (from ADR 0005) returns immediately for undefined scopes — zero overhead on owner WebSocket connections. +4. **Owner bypass**: Owner sessions have `scopes: undefined`. The `requireScope` guard (from ADR 0008) returns immediately for undefined scopes — zero overhead on owner WebSocket connections. ## Scope Mapping Reference @@ -45,4 +45,4 @@ Wire scope guards into the WS RPC layer using a hybrid approach: - Owner sessions are completely unaffected — all guards bypass when `scopes` is undefined. - Client sessions without the required scope get `WsRpcError` with a clear message. - Unguarded methods (git, terminal, project write, etc.) remain available to any authenticated session. Remote clients are always created with explicit scopes by an owner, so unguarded methods are not exposed to remote client sessions — only to owner sessions that connect without a scoped token. -- Resource scoping (project/thread ID filtering) is deferred to a later slice; see [ADR 0005](0005-scoped-remote-client-capability-tokens.md) for the scoped token design. +- Resource scoping (project/thread ID filtering) is deferred to a later slice; see [ADR 0008](0008-scoped-remote-client-capability-tokens.md) for the scoped token design. diff --git a/docs/adr/0007-parallel-windows-wsl-backend-routing.md b/docs/adr/0007-parallel-windows-wsl-backend-routing.md index 1e4bc1339..3af795ff8 100644 --- a/docs/adr/0007-parallel-windows-wsl-backend-routing.md +++ b/docs/adr/0007-parallel-windows-wsl-backend-routing.md @@ -79,7 +79,7 @@ Backend auth follows the existing server auth model: - The JCode server process runs under the Windows user account. - WSL commands execute as the default WSL user via `wsl.exe -d `. No separate auth is required — WSL inherits the Windows user's access via the WSL bridge. - For backends with their own auth (future SSH, Docker), introduce `BackendAuthProvider` — a service that acquires credentials for a backend. `local` and `wsl` use a no-op provider. -- Capability token scopes (ADR 0005) apply at the JCode server level, not per-backend. A client with `thread:read` can observe threads on any backend the server can reach. +- Capability token scopes (ADR 0008) apply at the JCode server level, not per-backend. A client with `thread:read` can observe threads on any backend the server can reach. ### 5. Transport Layer @@ -146,7 +146,7 @@ User-visible mode transitions: - Multiple WSL distributions supported simultaneously (Ubuntu + Debian in parallel). - Per-project routing avoids the "global WSL mode" UX trap — some projects can be on the host, others in WSL. - Transport abstraction is reusable for future SSH/Docker backends. -- Backend model composes with existing auth (ADR 0005) and scope (ADR 0006) systems without changes. +- Backend model composes with existing auth (ADR 0008) and scope (ADR 0006) systems without changes. **Negative:** diff --git a/docs/adr/0008-scoped-remote-client-capability-tokens.md b/docs/adr/0008-scoped-remote-client-capability-tokens.md index 01d04edf4..7ba4030a5 100644 --- a/docs/adr/0008-scoped-remote-client-capability-tokens.md +++ b/docs/adr/0008-scoped-remote-client-capability-tokens.md @@ -7,7 +7,7 @@ | Owner | Engineering | | Audience | Maintainers, reviewers, and automation agents | | Scope | Remote Client Runtime authentication, capability scopes, pairing/session semantics, observe-and-approve clients, and the Server Auth Boundary | -| Canonical path | `docs/adr/0005-scoped-remote-client-capability-tokens.md` | +| Canonical path | `docs/adr/0008-scoped-remote-client-capability-tokens.md` | | Last reviewed | 2026-06-05 | | Review cadence | Event-driven; review when remote clients gain mutation capabilities, the Server Auth Boundary changes, or JCode becomes a hosted multi-tenant product | | Source of truth | `CONTEXT.md`, `docs/adr/0001-local-coding-agent-cockpit.md`, `docs/security/dev-automation-access.md`, `packages/contracts/src/auth.ts`, and `apps/server/src/auth` | diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a2e0a246b..c58c41d50 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -81,7 +81,11 @@ import type { ServerVoiceTranscriptionInput, ServerVoiceTranscriptionResult, } from "./server"; -import type { CompleteFirstRunWizardInput, FirstRunWizardData } from "./firstRunWizard"; +import type { + CompleteFirstRunWizardInput, + FirstRunState, + FirstRunWizardData, +} from "./firstRunWizard"; import type { TerminalClearInput, TerminalCloseInput, @@ -136,6 +140,12 @@ import type { OpenCodeRuntimeHealth, } from "./providerDiscovery"; import type { ProviderCompactThreadInput } from "./provider"; +import type { + ManagedSidecarDiagnostics, + ManagedSidecarHealthCheck, + ManagedSidecarRepairRequest, + ManagedSidecarRepairResult, +} from "./managedRuntimeHealth"; export interface ContextMenuItem { id: T; @@ -472,7 +482,8 @@ export interface NativeApi { resetKeybinding: (input: ServerResetKeybindingInput) => Promise; resetAllKeybindings: () => Promise; getFirstRunWizardData: () => Promise; - completeFirstRunWizard: (input: CompleteFirstRunWizardInput) => Promise; + completeFirstRunWizard: (input: CompleteFirstRunWizardInput) => Promise; + skipFirstRun: () => Promise; generateThreadRecap: ( input: ServerGenerateThreadRecapInput, ) => Promise; @@ -482,6 +493,11 @@ export interface NativeApi { input: ProviderGetComposerCapabilitiesInput, ) => Promise; getRuntimeHealth: (input: ProviderGetRuntimeHealthInput) => Promise; + getManagedSidecarHealth: () => Promise; + repairManagedSidecar: ( + input: ManagedSidecarRepairRequest, + ) => Promise; + exportManagedSidecarDiagnostics: () => Promise; compactThread: (input: ProviderCompactThreadInput) => Promise; listCommands: (input: ProviderListCommandsInput) => Promise; listSkills: (input: ProviderListSkillsInput) => Promise; diff --git a/packages/contracts/src/ipc.typecheck.ts b/packages/contracts/src/ipc.typecheck.ts new file mode 100644 index 000000000..35bf967c6 --- /dev/null +++ b/packages/contracts/src/ipc.typecheck.ts @@ -0,0 +1,25 @@ +import type { FirstRunState } from "./firstRunWizard"; +import type { NativeApi } from "./ipc"; + +type Assert = T; + +type IsExact = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? (() => T extends B ? 1 : 2) extends () => T extends A ? 1 : 2 + ? true + : false + : false; + +type CompleteFirstRunWizardReturn = Awaited< + ReturnType +>; + +type SkipFirstRunWizardReturn = Awaited>; + +export type _CompleteFirstRunWizardReturnsState = Assert< + IsExact +>; + +export type _SkipFirstRunWizardReturnsState = Assert< + IsExact +>; diff --git a/packages/contracts/src/managedRuntimeHealth.test.ts b/packages/contracts/src/managedRuntimeHealth.test.ts new file mode 100644 index 000000000..fb10524e2 --- /dev/null +++ b/packages/contracts/src/managedRuntimeHealth.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ManagedSidecarDiagnostics } from "./managedRuntimeHealth"; + +const BASE_DIAGNOSTICS = { + generatedAt: "2026-06-11T20:00:00.000Z", + health: { + status: "healthy", + sidecarState: "ready", + binaryExists: true, + binaryValid: true, + serverReachable: true, + checkedAt: "2026-06-11T20:00:00.000Z", + }, + platform: { + os: "linux", + arch: "x64", + nodeVersion: "v26.2.0", + }, + binaryVersion: "unknown", + logs: [] as string[], + sidecarSnapshot: { + state: "ready", + serverUrl: "http://127.0.0.1:9876", + }, +}; + +describe("ManagedSidecarDiagnostics", () => { + it("rejects diagnostics snapshots that contain serverPassword", () => { + expect(() => + Schema.decodeUnknownSync(ManagedSidecarDiagnostics)({ + ...BASE_DIAGNOSTICS, + sidecarSnapshot: { + ...BASE_DIAGNOSTICS.sidecarSnapshot, + serverPassword: "secret-password", + }, + }), + ).toThrow(); + }); + + it("requires logs and binaryVersion fields", () => { + const { binaryVersion: _binaryVersion, logs: _logs, ...missingFields } = BASE_DIAGNOSTICS; + + expect(() => Schema.decodeUnknownSync(ManagedSidecarDiagnostics)(missingFields)).toThrow(); + }); +}); diff --git a/packages/contracts/src/managedRuntimeHealth.ts b/packages/contracts/src/managedRuntimeHealth.ts index b0f60d373..a79623852 100644 --- a/packages/contracts/src/managedRuntimeHealth.ts +++ b/packages/contracts/src/managedRuntimeHealth.ts @@ -15,7 +15,7 @@ import { Schema } from "effect"; import { IsoDateTime, NonNegativeInt } from "./baseSchemas"; -import { ManagedSidecarSnapshot, ManagedSidecarState } from "./managedRuntimeLifecycle"; +import { ManagedSidecarState } from "./managedRuntimeLifecycle"; // --------------------------------------------------------------------------- // Health status @@ -68,6 +68,14 @@ export type ManagedSidecarRepairResult = typeof ManagedSidecarRepairResult.Type; // Diagnostics // --------------------------------------------------------------------------- +export const ManagedSidecarDiagnosticsSnapshot = Schema.Struct({ + state: ManagedSidecarState, + binaryPath: Schema.optional(Schema.String), + serverUrl: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type ManagedSidecarDiagnosticsSnapshot = typeof ManagedSidecarDiagnosticsSnapshot.Type; + export const ManagedSidecarDiagnostics = Schema.Struct({ generatedAt: IsoDateTime, health: ManagedSidecarHealthCheck, @@ -76,7 +84,8 @@ export const ManagedSidecarDiagnostics = Schema.Struct({ arch: Schema.String, nodeVersion: Schema.String, }), - binaryVersion: Schema.optional(Schema.String), - sidecarSnapshot: ManagedSidecarSnapshot, + binaryVersion: Schema.String, + logs: Schema.Array(Schema.String), + sidecarSnapshot: ManagedSidecarDiagnosticsSnapshot, }); export type ManagedSidecarDiagnostics = typeof ManagedSidecarDiagnostics.Type; diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 5baa940a1..ebb6f7312 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 { + WsServerSkipFirstRunRpc, + WsProviderExportManagedSidecarDiagnosticsRpc, + WsProviderGetManagedSidecarHealthRpc, + WsProviderRepairManagedSidecarRpc, + WsRpcError, + WsRpcGroup, +} from "./rpc"; describe("WS RPC contracts", () => { it("exports the additive Effect RPC group", () => { @@ -12,6 +19,16 @@ describe("WS RPC contracts", () => { expect(new WsRpcError({ message: "failed" }).message).toBe("failed"); }); + it("exports managed sidecar provider RPCs", () => { + expect(WsProviderGetManagedSidecarHealthRpc).toBeDefined(); + expect(WsProviderRepairManagedSidecarRpc).toBeDefined(); + expect(WsProviderExportManagedSidecarDiagnosticsRpc).toBeDefined(); + }); + + it("exports the first-run skip RPC", () => { + expect(WsServerSkipFirstRunRpc).toBeDefined(); + }); + 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 b08b8890f..059c2f73b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -39,6 +39,12 @@ import { GitSummarizeDiffResult, } from "./git"; import { KeybindingRule } from "./keybindings"; +import { + CompleteFirstRunWizardInput, + FirstRunWizardData, + FirstRunState, + SkipFirstRunWizardInput, +} from "./firstRunWizard"; import { ClientOrchestrationCommand, ORCHESTRATION_WS_METHODS, @@ -50,6 +56,12 @@ import { OrchestrationThreadStreamItem, } from "./orchestration"; import { ProviderCompactThreadInput } from "./provider"; +import { + ManagedSidecarDiagnostics, + ManagedSidecarHealthCheck, + ManagedSidecarRepairRequest, + ManagedSidecarRepairResult, +} from "./managedRuntimeHealth"; import { ProviderGetComposerCapabilitiesInput, ProviderComposerCapabilities, @@ -520,6 +532,24 @@ export const WsServerResetAllKeybindingsRpc = Rpc.make(WS_METHODS.serverResetAll error: WsRpcError, }); +export const WsServerGetFirstRunWizardDataRpc = Rpc.make(WS_METHODS.serverGetFirstRunWizardData, { + payload: Schema.Struct({}), + success: FirstRunWizardData, + error: WsRpcError, +}); + +export const WsServerCompleteFirstRunWizardRpc = Rpc.make(WS_METHODS.serverCompleteFirstRunWizard, { + payload: CompleteFirstRunWizardInput, + success: FirstRunState, + error: WsRpcError, +}); + +export const WsServerSkipFirstRunRpc = Rpc.make(WS_METHODS.serverSkipFirstRun, { + payload: SkipFirstRunWizardInput, + success: FirstRunState, + error: WsRpcError, +}); + export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: Schema.Struct({}), success: ServerLifecycleStreamEvent, @@ -573,6 +603,30 @@ export const WsProviderGetRuntimeHealthRpc = Rpc.make(WS_METHODS.providerGetRunt error: WsRpcError, }); +export const WsProviderGetManagedSidecarHealthRpc = Rpc.make( + WS_METHODS.providerGetManagedSidecarHealth, + { + payload: Schema.Struct({}), + success: ManagedSidecarHealthCheck, + error: WsRpcError, + }, +); + +export const WsProviderRepairManagedSidecarRpc = Rpc.make(WS_METHODS.providerRepairManagedSidecar, { + payload: ManagedSidecarRepairRequest, + success: ManagedSidecarRepairResult, + error: WsRpcError, +}); + +export const WsProviderExportManagedSidecarDiagnosticsRpc = Rpc.make( + WS_METHODS.providerExportManagedSidecarDiagnostics, + { + payload: Schema.Struct({}), + success: ManagedSidecarDiagnostics, + error: WsRpcError, + }, +); + export const WsProviderCompactThreadRpc = Rpc.make(WS_METHODS.providerCompactThread, { payload: ProviderCompactThreadInput, success: Schema.Void, @@ -700,6 +754,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpsertKeybindingRpc, WsServerResetKeybindingRpc, WsServerResetAllKeybindingsRpc, + WsServerGetFirstRunWizardDataRpc, + WsServerCompleteFirstRunWizardRpc, + WsServerSkipFirstRunRpc, WsSubscribeServerLifecycleRpc, WsSubscribeServerConfigRpc, WsSubscribeServerProviderStatusesRpc, @@ -707,6 +764,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsProviderGetComposerCapabilitiesRpc, WsProviderGetRuntimeHealthRpc, + WsProviderGetManagedSidecarHealthRpc, + WsProviderRepairManagedSidecarRpc, + WsProviderExportManagedSidecarDiagnosticsRpc, WsProviderCompactThreadRpc, WsProviderListCommandsRpc, WsProviderListSkillsRpc, diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 511ee9324..386e4c897 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -167,6 +167,62 @@ it.effect("accepts provider search skills catalog requests", () => }), ); +it.effect("accepts managed sidecar health requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-managed-health-1", + body: { + _tag: WS_METHODS.providerGetManagedSidecarHealth, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerGetManagedSidecarHealth); + }), +); + +it.effect("accepts managed sidecar repair requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-managed-repair-1", + body: { + _tag: WS_METHODS.providerRepairManagedSidecar, + forceRedownload: true, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerRepairManagedSidecar); + if (parsed.body._tag === WS_METHODS.providerRepairManagedSidecar) { + assert.strictEqual(parsed.body.forceRedownload, true); + } + }), +); + +it.effect("accepts managed sidecar diagnostics requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-managed-diagnostics-1", + body: { + _tag: WS_METHODS.providerExportManagedSidecarDiagnostics, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.providerExportManagedSidecarDiagnostics); + }), +); + +it.effect("accepts first-run wizard skip requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-first-run-skip-1", + body: { + _tag: WS_METHODS.serverSkipFirstRun, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.serverSkipFirstRun); + }), +); + 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 2ddb88a0a..6078a25e2 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -75,6 +75,7 @@ import { ServerVoiceTranscriptionInput, } from "./server"; import { CompleteFirstRunWizardInput, SkipFirstRunWizardInput } from "./firstRunWizard"; +import { ManagedSidecarRepairRequest } from "./managedRuntimeHealth"; import { ProviderListCommandsInput, ProviderGetRuntimeHealthInput, @@ -171,6 +172,9 @@ export const WS_METHODS = { // Provider discovery providerGetComposerCapabilities: "provider.getComposerCapabilities", providerGetRuntimeHealth: "provider.getRuntimeHealth", + providerGetManagedSidecarHealth: "provider.getManagedSidecarHealth", + providerRepairManagedSidecar: "provider.repairManagedSidecar", + providerExportManagedSidecarDiagnostics: "provider.exportManagedSidecarDiagnostics", providerCompactThread: "provider.compactThread", providerListCommands: "provider.listCommands", providerListSkills: "provider.listSkills", @@ -292,6 +296,9 @@ const WebSocketRequestBody = Schema.Union([ // Provider discovery tagRequestBody(WS_METHODS.providerGetComposerCapabilities, ProviderGetComposerCapabilitiesInput), tagRequestBody(WS_METHODS.providerGetRuntimeHealth, ProviderGetRuntimeHealthInput), + tagRequestBody(WS_METHODS.providerGetManagedSidecarHealth, Schema.Struct({})), + tagRequestBody(WS_METHODS.providerRepairManagedSidecar, ManagedSidecarRepairRequest), + tagRequestBody(WS_METHODS.providerExportManagedSidecarDiagnostics, Schema.Struct({})), tagRequestBody(WS_METHODS.providerCompactThread, ProviderCompactThreadInput), tagRequestBody(WS_METHODS.providerListCommands, ProviderListCommandsInput), tagRequestBody(WS_METHODS.providerListSkills, ProviderListSkillsInput), From c0d2dc724752469ad5931d7e179a80265d8d0dfb Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 18 Jun 2026 14:00:11 -0400 Subject: [PATCH 08/18] fix(server): guard non-v1 WS RPC methods --- apps/server/src/wsRpc.test.ts | 19 +++ apps/server/src/wsRpc.ts | 135 ++++++++++++------ ...mote-client-runtime-ws-rpc-scope-wiring.md | 4 +- 3 files changed, 116 insertions(+), 42 deletions(-) diff --git a/apps/server/src/wsRpc.test.ts b/apps/server/src/wsRpc.test.ts index 43f7ba868..08295b288 100644 --- a/apps/server/src/wsRpc.test.ts +++ b/apps/server/src/wsRpc.test.ts @@ -248,13 +248,20 @@ describe("managed sidecar wsRpc adapters", () => { it("keeps privileged WS RPC handlers owner-only for scoped client sessions", async () => { await expectWsRpcHandlerOwnerGuarded("ORCHESTRATION_WS_METHODS.importThread"); await expectWsRpcHandlerOwnerGuarded("ORCHESTRATION_WS_METHODS.repairState"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsListDirectories"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsSearchEntries"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsSearchLocalEntries"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.projectsWriteFile"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.filesystemBrowse"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.shellOpenInEditor"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStatus"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitReadWorkingTreeDiff"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitSummarizeDiff"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitPull"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRunStackedAction"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitResolvePullRequest"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitPreparePullRequestThread"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitListBranches"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCreateWorktree"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCreateDetachedWorktree"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRemoveWorktree"); @@ -262,6 +269,7 @@ describe("managed sidecar wsRpc adapters", () => { await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitCheckout"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStashAndCheckout"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStashDrop"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitStashInfo"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitRemoveIndexLock"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitInit"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.gitHandoffThread"); @@ -272,12 +280,23 @@ describe("managed sidecar wsRpc adapters", () => { await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalRestart"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.terminalClose"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.subscribeTerminalEvents"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverGetEnvironment"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverListWorktrees"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.subscribeServerLifecycle"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverUpdateProvider"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.serverTranscribeVoice"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerGetComposerCapabilities"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerCompactThread"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerListCommands"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerListSkills"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerInstallSkill"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerUninstallSkill"); await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerSetSkillEnabled"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerSearchSkillsCatalog"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerListPlugins"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerReadPlugin"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerListModels"); + await expectWsRpcHandlerOwnerGuarded("WS_METHODS.providerListAgents"); }); it("keeps observable WS RPC handlers limited to explicit scopes", async () => { diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 026e8b7c3..53fe89692 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -776,12 +776,18 @@ export const makeWsRpcLayer = () => withScopeStream("thread:read", orchestrationEngine.streamDomainEvents), [WS_METHODS.projectsListDirectories]: (input) => - rpcEffect( - workspaceEntries.listDirectories(input), - "Failed to list workspace directories", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + workspaceEntries.listDirectories(input), + "Failed to list workspace directories", + ), ), [WS_METHODS.projectsSearchEntries]: (input) => - rpcEffect(workspaceEntries.search(input), "Failed to search workspace entries"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(workspaceEntries.search(input), "Failed to search workspace entries"), + ), [WS_METHODS.projectsSearchLocalEntries]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -804,11 +810,20 @@ export const makeWsRpcLayer = () => ), [WS_METHODS.gitStatus]: (input) => - rpcEffect(gitStatusBroadcaster.getStatus(input), "Failed to read git status"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(gitStatusBroadcaster.getStatus(input), "Failed to read git status"), + ), [WS_METHODS.gitReadWorkingTreeDiff]: (input) => - rpcEffect(gitManager.readWorkingTreeDiff(input), "Failed to read working tree diff"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(gitManager.readWorkingTreeDiff(input), "Failed to read working tree diff"), + ), [WS_METHODS.gitSummarizeDiff]: (input) => - rpcEffect(gitManager.summarizeDiff(input), "Failed to summarize diff"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(gitManager.summarizeDiff(input), "Failed to summarize diff"), + ), [WS_METHODS.gitPull]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -839,7 +854,10 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.gitResolvePullRequest]: (input) => - rpcEffect(gitManager.resolvePullRequest(input), "Failed to resolve pull request"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(gitManager.resolvePullRequest(input), "Failed to resolve pull request"), + ), [WS_METHODS.gitPreparePullRequestThread]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -851,7 +869,10 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.gitListBranches]: (input) => - rpcEffect(git.listBranches(input), "Failed to list branches"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(git.listBranches(input), "Failed to list branches"), + ), [WS_METHODS.gitCreateWorktree]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -913,7 +934,10 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.gitStashInfo]: (input) => - rpcEffect(git.stashInfo(input), "Failed to read stash"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(git.stashInfo(input), "Failed to read stash"), + ), [WS_METHODS.gitRemoveIndexLock]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -985,7 +1009,10 @@ export const makeWsRpcLayer = () => rpcEffect(loadServerConfig, "Failed to load server config"), ), [WS_METHODS.serverGetEnvironment]: () => - rpcEffect(serverEnvironment.getDescriptor, "Failed to load server environment"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(serverEnvironment.getDescriptor, "Failed to load server environment"), + ), [WS_METHODS.serverGetSettings]: () => withCurrentSession( requireOwnerWsRpcAccess, @@ -1036,7 +1063,8 @@ export const makeWsRpcLayer = () => }), ), ), - [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), + [WS_METHODS.serverListWorktrees]: () => + withCurrentSession(requireOwnerWsRpcAccess, Effect.succeed({ worktrees: [] })), [WS_METHODS.serverGetProviderUsageSnapshot]: (input) => withScope( "provider_status:read", @@ -1222,25 +1250,28 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerLifecycle]: () => - Stream.concat( - Stream.fromEffect( - lifecycleEvents.snapshot.pipe( - Effect.map((snapshot) => - Array.from(snapshot.events).toSorted( - (left, right) => left.sequence - right.sequence, + withCurrentSessionStream( + requireOwnerWsRpcAccess, + Stream.concat( + Stream.fromEffect( + lifecycleEvents.snapshot.pipe( + Effect.map((snapshot) => + Array.from(snapshot.events).toSorted( + (left, right) => left.sequence - right.sequence, + ), ), ), + ).pipe(Stream.flatMap(Stream.fromIterable)), + lifecycleEvents.stream, + ).pipe( + Stream.map( + (event): ServerLifecycleStreamEvent => + event.type === "welcome" + ? { type: "welcome", payload: event.payload } + : event.type === "ready" + ? { type: "ready", payload: event.payload } + : { type: "maintenance", payload: event.payload }, ), - ).pipe(Stream.flatMap(Stream.fromIterable)), - lifecycleEvents.stream, - ).pipe( - Stream.map( - (event): ServerLifecycleStreamEvent => - event.type === "welcome" - ? { type: "welcome", payload: event.payload } - : event.type === "ready" - ? { type: "ready", payload: event.payload } - : { type: "maintenance", payload: event.payload }, ), ), [WS_METHODS.subscribeServerConfig]: () => @@ -1369,9 +1400,12 @@ export const makeWsRpcLayer = () => ), [WS_METHODS.providerGetComposerCapabilities]: (input) => - rpcEffect( - providerDiscoveryService.getComposerCapabilities(input), - "Failed to get composer capabilities", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + providerDiscoveryService.getComposerCapabilities(input), + "Failed to get composer capabilities", + ), ), [WS_METHODS.providerGetRuntimeHealth]: (input) => withCurrentSession( @@ -1418,9 +1452,15 @@ export const makeWsRpcLayer = () => rpcEffect(providerService.compactThread(input), "Failed to compact thread"), ), [WS_METHODS.providerListCommands]: (input) => - rpcEffect(providerDiscoveryService.listCommands(input), "Failed to list commands"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.listCommands(input), "Failed to list commands"), + ), [WS_METHODS.providerListSkills]: (input) => - rpcEffect(providerDiscoveryService.listSkills(input), "Failed to list skills"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.listSkills(input), "Failed to list skills"), + ), [WS_METHODS.providerInstallSkill]: (input) => withCurrentSession( requireOwnerWsRpcAccess, @@ -1440,18 +1480,33 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.providerSearchSkillsCatalog]: (input) => - rpcEffect( - providerDiscoveryService.searchSkillsCatalog(input), - "Failed to search skills catalog", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + providerDiscoveryService.searchSkillsCatalog(input), + "Failed to search skills catalog", + ), ), [WS_METHODS.providerListPlugins]: (input) => - rpcEffect(providerDiscoveryService.listPlugins(input), "Failed to list plugins"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.listPlugins(input), "Failed to list plugins"), + ), [WS_METHODS.providerReadPlugin]: (input) => - rpcEffect(providerDiscoveryService.readPlugin(input), "Failed to read plugin"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.readPlugin(input), "Failed to read plugin"), + ), [WS_METHODS.providerListModels]: (input) => - rpcEffect(providerDiscoveryService.listModels(input), "Failed to list models"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.listModels(input), "Failed to list models"), + ), [WS_METHODS.providerListAgents]: (input) => - rpcEffect(providerDiscoveryService.listAgents(input), "Failed to list agents"), + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect(providerDiscoveryService.listAgents(input), "Failed to list agents"), + ), }); }), ); diff --git a/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md b/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md index f71c57715..c2ac95742 100644 --- a/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md +++ b/docs/adr/0006-remote-client-runtime-ws-rpc-scope-wiring.md @@ -26,7 +26,7 @@ Wire scope guards into the WS RPC layer using a hybrid approach: - `approval:respond` → dispatchCommand with type `thread.approval.respond` - `user_input:respond` → dispatchCommand with type `thread.user-input.respond` - `provider_status:read` → serverGetConfig, subscribeServerProviderStatuses - - All other methods remain ungated (available to any authenticated session) or owner-only (enforced by `withCommandScope` rejecting non-owner clients for unrecognized command types) + - All other methods remain owner-only, either through explicit owner guards or `withCommandScope` rejecting non-owner clients for unrecognized command types 4. **Owner bypass**: Owner sessions have `scopes: undefined`. The `requireScope` guard (from ADR 0008) returns immediately for undefined scopes — zero overhead on owner WebSocket connections. @@ -44,5 +44,5 @@ Wire scope guards into the WS RPC layer using a hybrid approach: - Remote clients with appropriate scopes can observe thread state, respond to approvals/user-input, and read provider status via WebSocket — enabling mobile/remote approval companions. - Owner sessions are completely unaffected — all guards bypass when `scopes` is undefined. - Client sessions without the required scope get `WsRpcError` with a clear message. -- Unguarded methods (git, terminal, project write, etc.) remain available to any authenticated session. Remote clients are always created with explicit scopes by an owner, so unguarded methods are not exposed to remote client sessions — only to owner sessions that connect without a scoped token. +- Methods outside the explicit v1 scopes remain owner-only. Remote clients with scoped tokens can use only methods guarded by their granted scopes. - Resource scoping (project/thread ID filtering) is deferred to a later slice; see [ADR 0008](0008-scoped-remote-client-capability-tokens.md) for the scoped token design. From e466a316917446f1a0baf17d72b717176b0e9185 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 13:54:40 -0400 Subject: [PATCH 09/18] Update project files --- .github/workflows/release.yml | 2 +- .../provider/Layers/OpenCodeAdapter.test.ts | 74 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 13 ++-- apps/server/src/provider/firstRunWizard.ts | 4 +- .../src/provider/managedRuntimeDownload.ts | 23 ++++-- .../src/provider/managedRuntimeHealth.test.ts | 8 +- .../src/provider/managedRuntimeHealth.ts | 26 ++++--- .../provider/managedRuntimeLifecycle.test.ts | 36 +++++---- .../provider/providerCredentialScan.test.ts | 30 ++++++++ .../src/provider/providerCredentialScan.ts | 25 ++++++- apps/server/src/wsRpc.test.ts | 4 +- apps/server/src/wsRpc.ts | 2 +- .../src/components/FirstRunWizard.browser.tsx | 2 +- ...9-backend-owned-managed-runtime-sidecar.md | 4 +- ...0010-provider-agnostic-first-run-wizard.md | 14 ++-- docs/adr/README.md | 2 +- packages/contracts/src/firstRunWizard.ts | 6 +- packages/contracts/src/settings.ts | 6 +- packages/contracts/src/ws.test.ts | 30 ++++++++ scripts/generate-winget-manifest.test.ts | 5 ++ scripts/generate-winget-manifest.ts | 3 + scripts/update-package-manifests.test.ts | 11 ++- scripts/update-package-manifests.ts | 5 +- 23 files changed, 262 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7947e6b13..17eae60f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -402,7 +402,7 @@ jobs: finalize: name: Finalize release if: ${{ vars.JCODE_FINALIZE_RELEASE == '1' }} - needs: [preflight, release] + needs: [preflight, release, update_package_manifests] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 69a60adfb..4eadb2bc9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1757,6 +1757,80 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }); }); + it("keeps a managed runtime profile on an explicit provider server URL without starting the sidecar", async () => { + const runtime = createMockOpenCodeRuntime(); + const lifecycle = createMockManagedSidecarLifecycle({ initialSnapshot: { state: "idle" } }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-managed-provider-server-url"), + runtimeMode: "full-access", + providerOptions: { + opencode: { serverUrl: "http://127.0.0.1:7777" }, + }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + activeRuntimeProfileId: "managed-profile", + runtimeProfiles: [ + { + id: "managed-profile", + label: "Managed profile", + provider: "opencode", + mode: "managed", + configMode: "generated", + binaryPath: "/managed/bin/opencode", + cwdDefault: "/managed/workspace", + opencodeConfigDir: "/managed/config", + opencodeDataDir: "/managed/data", + skillRoots: [], + pluginRoots: [], + requiredCommands: [], + requiredSkills: [], + requiredPlugins: [], + requiredAgents: [], + requiredModels: [], + requiredEnv: [], + requirements: [], + capabilityPolicy: "warn", + }, + ], + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ManagedSidecarLifecycle, lifecycle)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(lifecycle.getManagedRuntimeStatus).not.toHaveBeenCalled(); + expect(lifecycle.startManagedRuntime).not.toHaveBeenCalled(); + expect(runtime.connectCalls[0]).toMatchObject({ + serverUrl: "http://127.0.0.1:7777", + binaryPath: "/managed/bin/opencode", + }); + expect(runtime.connectCalls[0]).not.toHaveProperty("serverPassword"); + expect(runtime.clientCalls[0]).toMatchObject({ + baseUrl: "http://127.0.0.1:7777", + directory: "/managed/workspace", + }); + expect(runtime.clientCalls[0]).not.toHaveProperty("serverPassword"); + }); + it("passes the active managed runtime profile password to spawned SDK clients", async () => { const runtime = createMockOpenCodeRuntime({ connectedServerExternal: false }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 6a9a66fee..7d627d4fa 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3758,10 +3758,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { adapterConfig.defaultBinaryPath; const providerServerUrl = providerOptions?.serverUrl?.trim(); const providerServerPassword = providerOptions?.serverPassword?.trim(); - const profileServerUrl = configuredConnection?.serverUrl; - const profileServerPassword = configuredConnection?.serverPassword; + const profileServerUrl = resolvedRuntimeProfile?.profile.serverUrl?.trim(); + const profileServerPassword = resolvedRuntimeProfile?.serverPassword?.trim(); const serverUrl = providerServerUrl || profileServerUrl; - const serverPassword = providerServerPassword || profileServerPassword; + const explicitServerPassword = providerServerPassword || profileServerPassword; + const serverPassword = + explicitServerPassword || + (serverUrl ? undefined : configuredConnection?.serverPassword); const directory = input.cwd ?? configuredConnection?.cwd ?? serverConfig.cwd; const initialParsedModel = input.modelSelection?.provider === adapterConfig.provider @@ -3785,8 +3788,8 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { provider === "opencode" && resolvedRuntimeProfile?.profile.mode === "managed" && !providerOptions?.binaryPath?.trim() && - !(providerServerUrl && providerServerPassword) && - !(profileServerUrl && profileServerPassword); + !(providerServerUrl || providerServerPassword) && + !(profileServerUrl || profileServerPassword); const resumedSessionId = extractResumeSessionId(input.resumeCursor); diff --git a/apps/server/src/provider/firstRunWizard.ts b/apps/server/src/provider/firstRunWizard.ts index 7e3de8f0e..5eb02e2a6 100644 --- a/apps/server/src/provider/firstRunWizard.ts +++ b/apps/server/src/provider/firstRunWizard.ts @@ -109,10 +109,10 @@ export const completeFirstRunWizard = ( const currentSettings = yield* settings.getSettings; const scanResults = options.scanResults ?? (yield* scanAllProviders()); const openCodeScan = findOpenCodeScanResult(scanResults); - const managedRuntimeDir = yield* resolveManagedRuntimeDir; const existingConfigDetected = openCodeScan?.hasCredentials ?? false; const cleanManagedFirstRun = openCodeScan?.hasCredentials === false && openCodeScan.hasBinary === false; + const managedRuntimeDir = existingConfigDetected ? undefined : yield* resolveManagedRuntimeDir; const sidecarSnapshot = cleanManagedFirstRun && options.managedSidecarLifecycle ? yield* options.managedSidecarLifecycle.startManagedRuntime({ forceDownload: true }) @@ -120,7 +120,7 @@ export const completeFirstRunWizard = ( const profileResult = autoCreateManagedRuntimeProfile({ sidecarSnapshot, existingConfigDetected, - managedRuntimeDir, + ...(managedRuntimeDir ? { managedRuntimeDir } : {}), settings: currentSettings, }); diff --git a/apps/server/src/provider/managedRuntimeDownload.ts b/apps/server/src/provider/managedRuntimeDownload.ts index 89082bbad..e1e6eeffb 100644 --- a/apps/server/src/provider/managedRuntimeDownload.ts +++ b/apps/server/src/provider/managedRuntimeDownload.ts @@ -111,6 +111,7 @@ const GitHubReleaseApiResponse = Schema.Struct({ name: Schema.String, browser_download_url: Schema.String, size: Schema.Number, + digest: Schema.optional(Schema.String), }), ), }); @@ -159,6 +160,7 @@ export const fetchLatestOpenCodeRelease = Effect.gen(function* () { name: asset.name, browserDownloadUrl: asset.browser_download_url, size: asset.size, + ...(asset.digest ? { digest: asset.digest } : {}), })); return { @@ -255,12 +257,16 @@ export const downloadManagedRuntime = Effect.gen(function* () { const actualHash = yield* computeFileSha256(tempPath); - if (asset.digest && asset.digest !== actualHash) { + const expectedDigest = asset.digest?.startsWith("sha256:") + ? asset.digest.slice("sha256:".length) + : asset.digest; + + if (expectedDigest && expectedDigest !== actualHash) { yield* fs.remove(tempPath).pipe(Effect.orDie); yield* Effect.fail( new ManagedRuntimeDownloadError({ stage: "verify", - message: `SHA-256 mismatch: expected ${asset.digest}, got ${actualHash}`, + message: `SHA-256 mismatch: expected ${expectedDigest}, got ${actualHash}`, }), ); return undefined as never; @@ -322,11 +328,14 @@ export const verifyManagedRuntimeBinary = (expectedSha256?: string, binaryPath?: Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const runtimeDir = yield* resolveManagedRuntimeDir; - - const platform = detectManagedRuntimePlatform(); - const binaryName = platform === "win-x64" ? "opencode.exe" : "opencode"; - const resolvedPath = binaryPath ?? path.join(runtimeDir, binaryName); + const resolvedPath = + binaryPath ?? + (yield* Effect.gen(function* () { + const runtimeDir = yield* resolveManagedRuntimeDir; + const platform = detectManagedRuntimePlatform(); + const binaryName = platform === "win-x64" ? "opencode.exe" : "opencode"; + return path.join(runtimeDir, binaryName); + })); const exists = yield* fs.exists(resolvedPath).pipe(Effect.orDie); diff --git a/apps/server/src/provider/managedRuntimeHealth.test.ts b/apps/server/src/provider/managedRuntimeHealth.test.ts index 36e026d83..7000a5e76 100644 --- a/apps/server/src/provider/managedRuntimeHealth.test.ts +++ b/apps/server/src/provider/managedRuntimeHealth.test.ts @@ -265,7 +265,7 @@ describe("repairManagedSidecar", () => { expect(result.health.serverReachable).toBe(false); }); - it("calls startManagedRuntime with forceDownload by default during repair", async () => { + it("calls startManagedRuntime without forceDownload by default during repair", async () => { mockVerify.mockReturnValue( Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), ); @@ -283,10 +283,10 @@ describe("repairManagedSidecar", () => { ).pipe(Effect.provide(TestLayer)), ); - expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: false }); }); - it("ignores explicit forceRedownload false because repair must re-download", async () => { + it("honors explicit forceRedownload false during repair", async () => { mockVerify.mockReturnValue( Effect.succeed({ exists: true, sha256: "abc", expectedSha256: null, valid: true }), ); @@ -305,7 +305,7 @@ describe("repairManagedSidecar", () => { ).pipe(Effect.provide(TestLayer)), ); - expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: true }); + expect(lifecycle.startManagedRuntime).toHaveBeenCalledWith({ forceDownload: false }); }); it("calls startManagedRuntime with forceDownload when forceRedownload is true", async () => { diff --git a/apps/server/src/provider/managedRuntimeHealth.ts b/apps/server/src/provider/managedRuntimeHealth.ts index 6367e2b49..cfc4c6a91 100644 --- a/apps/server/src/provider/managedRuntimeHealth.ts +++ b/apps/server/src/provider/managedRuntimeHealth.ts @@ -220,17 +220,21 @@ export const repairManagedSidecar = (input: { .stopManagedRuntime() .pipe(Effect.catchTag("ManagedSidecarError", () => Effect.void)); - const startResult = yield* lifecycle.startManagedRuntime({ forceDownload: true }).pipe( - Effect.mapError((err) => - err instanceof ManagedSidecarError - ? err - : new ManagedSidecarError({ - stage: "repair", - message: String(err), - cause: err, - }), - ), - ); + const startResult = yield* lifecycle + .startManagedRuntime({ + forceDownload: input.forceRedownload ?? false, + }) + .pipe( + Effect.mapError((err) => + err instanceof ManagedSidecarError + ? err + : new ManagedSidecarError({ + stage: "repair", + message: String(err), + cause: err, + }), + ), + ); const health = yield* checkManagedSidecarHealth({ sidecarSnapshot: startResult, diff --git a/apps/server/src/provider/managedRuntimeLifecycle.test.ts b/apps/server/src/provider/managedRuntimeLifecycle.test.ts index 1e42286bf..3683e47aa 100644 --- a/apps/server/src/provider/managedRuntimeLifecycle.test.ts +++ b/apps/server/src/provider/managedRuntimeLifecycle.test.ts @@ -530,30 +530,36 @@ describe("error handling", () => { runEffectTest( Effect.gen(function* () { let callCount = 0; + const startMock = vi.fn(() => { + callCount++; + if (callCount === 1) { + return Effect.fail( + new TestOpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "first attempt fails", + }), + ); + } + return Effect.succeed({ + url: FAKE_SERVER_URL, + exitCode: Effect.succeed(0), + }); + }); const failThenSucceed = makeMockRuntime({ - startOpenCodeServerProcess: vi.fn(() => { - callCount++; - if (callCount === 1) { - return Effect.fail( - new TestOpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: "first attempt fails", - }), - ); - } - return Effect.succeed({ - url: FAKE_SERVER_URL, - exitCode: Effect.succeed(0), - }); - }), + startOpenCodeServerProcess: startMock, }); const lifecycle = yield* makeTestLifecycle(failThenSucceed); yield* Effect.flip(lifecycle.startManagedRuntime()); const success = yield* lifecycle.startManagedRuntime(); + const firstPassword = startMock.mock.calls[0]?.[0].serverPassword; + const secondPassword = startMock.mock.calls[1]?.[0].serverPassword; expect(success.state).toBe("ready"); expect(success.serverPassword).toBeTruthy(); + expect(firstPassword).toBeTruthy(); + expect(secondPassword).toBeTruthy(); + expect(secondPassword).not.toBe(firstPassword); }).pipe(Effect.provide(Layer.succeed(OpenCodeRuntime, makeMockRuntime()))), )); }); diff --git a/apps/server/src/provider/providerCredentialScan.test.ts b/apps/server/src/provider/providerCredentialScan.test.ts index 3b9dfbfc5..eeb065cd1 100644 --- a/apps/server/src/provider/providerCredentialScan.test.ts +++ b/apps/server/src/provider/providerCredentialScan.test.ts @@ -108,6 +108,36 @@ describe("resolveBinaryPath", () => { assert.strictEqual(result.found, false); }).pipe(Effect.provide(NodeServices.layer))); + + it("uses Windows PATH separators and executable extensions", () => + Effect.gen(function* () { + const expectedPath = "C:\\Tools\\opencode.exe"; + const result = yield* resolveBinaryPath("opencode", { + env: { PATH: "C:\\Missing;C:\\Tools" }, + platform: "win32", + binaryExists: (path) => Effect.succeed(path === expectedPath), + }); + + assert.strictEqual(result.found, true); + if (result.found) { + assert.strictEqual(result.path, expectedPath); + } + }).pipe(Effect.provide(NodeServices.layer))); + + it("dequotes Windows PATH entries before resolving executables", () => + Effect.gen(function* () { + const expectedPath = "C:\\Program Files\\OpenCode\\opencode.cmd"; + const result = yield* resolveBinaryPath("opencode", { + env: { PATH: '"C:\\Program Files\\OpenCode";C:\\Other' }, + platform: "win32", + binaryExists: (path) => Effect.succeed(path === expectedPath), + }); + + assert.strictEqual(result.found, true); + if (result.found) { + assert.strictEqual(result.path, expectedPath); + } + }).pipe(Effect.provide(NodeServices.layer))); }); describe("scanAllProviders", () => { diff --git a/apps/server/src/provider/providerCredentialScan.ts b/apps/server/src/provider/providerCredentialScan.ts index 8bd77736a..37299c55c 100644 --- a/apps/server/src/provider/providerCredentialScan.ts +++ b/apps/server/src/provider/providerCredentialScan.ts @@ -1,4 +1,5 @@ import type { ProviderDiscoveryKind } from "@jcode/contracts"; +import nodePath from "node:path"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -77,10 +78,19 @@ const PROVIDER_CREDENTIAL_SPECS: ReadonlyArray = [ const WINDOWS_EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat"] as const; +function dequotePathEntry(entry: string): string { + const trimmed = entry.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; +} + interface ScanProviderOptions { readonly env?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; readonly homeDir?: string; + readonly binaryExists?: (path: string) => Effect.Effect; } const checkEnvVarCredentials = ( @@ -130,11 +140,18 @@ const resolveBinaryPath = ( FileSystem.FileSystem > => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; const platform = options.platform ?? process.platform; const pathEnv = (options.env?.PATH ?? process.env.PATH ?? "").trim(); const separator = platform === "win32" ? ";" : ":"; - const pathEntries = pathEnv.split(separator).filter(Boolean); + const pathEntries = pathEnv.split(separator).map(dequotePathEntry).filter(Boolean); + const joinPath = platform === "win32" ? nodePath.win32.join : nodePath.posix.join; + const binaryExists = + options.binaryExists ?? + ((fullPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.exists(fullPath).pipe(Effect.orElseSucceed(() => false)); + })); const executableCandidates = platform === "win32" ? WINDOWS_EXECUTABLE_EXTENSIONS.map((ext) => `${binaryName}${ext}`) @@ -142,8 +159,8 @@ const resolveBinaryPath = ( for (const entry of pathEntries) { for (const candidate of executableCandidates) { - const fullPath = `${entry}/${candidate}`; - const exists = yield* fileSystem.exists(fullPath).pipe(Effect.orElseSucceed(() => false)); + const fullPath = joinPath(entry, candidate); + const exists = yield* binaryExists(fullPath); if (exists) { return { found: true, path: fullPath } as const; } diff --git a/apps/server/src/wsRpc.test.ts b/apps/server/src/wsRpc.test.ts index 08295b288..2c898c8c0 100644 --- a/apps/server/src/wsRpc.test.ts +++ b/apps/server/src/wsRpc.test.ts @@ -234,9 +234,7 @@ describe("managed sidecar wsRpc adapters", () => { }); it("maps local legacy WebSocket access to an owner-equivalent RPC session", () => { - expect( - resolveLocalLegacyWsAuthSession({ authToken: undefined, legacyToken: null }), - ).toMatchObject({ role: "owner", subject: "local-legacy-websocket" }); + expect(resolveLocalLegacyWsAuthSession({ authToken: undefined, legacyToken: null })).toBeNull(); expect( resolveLocalLegacyWsAuthSession({ authToken: "local-token", legacyToken: "local-token" }), ).toMatchObject({ role: "owner", subject: "local-legacy-websocket" }); diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 53fe89692..cf4f6e07a 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -117,7 +117,7 @@ export function resolveLocalLegacyWsAuthSession(input: { readonly authToken: string | undefined; readonly legacyToken: string | null; }): AuthenticatedSession | null { - return !input.authToken || input.legacyToken === input.authToken + return input.authToken !== undefined && input.legacyToken === input.authToken ? LOCAL_LEGACY_OWNER_AUTH_SESSION : null; } diff --git a/apps/web/src/components/FirstRunWizard.browser.tsx b/apps/web/src/components/FirstRunWizard.browser.tsx index 2bf80790b..3004daa25 100644 --- a/apps/web/src/components/FirstRunWizard.browser.tsx +++ b/apps/web/src/components/FirstRunWizard.browser.tsx @@ -307,7 +307,7 @@ describe("FirstRunWizard", () => { serverApi.completeFirstRunWizard.mock.calls.length, ).toBeGreaterThan(0); }); - expect(serverApi.completeFirstRunWizard).not.toHaveBeenCalledWith({}); + expect(serverApi.completeFirstRunWizard).not.toHaveBeenCalled(); expect(serverApi.skipFirstRun).toHaveBeenCalledTimes(1); await screen.unmount(); diff --git a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md index e3878b5af..273c4c4fb 100644 --- a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md +++ b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md @@ -21,7 +21,7 @@ JCode packages a web UI, local server, and desktop shell. The Windows turnkey re The backend server owns the managed provider sidecar lifecycle. The desktop shell owns the updater and the first-run trigger UI. -This aligns with existing code. `opencodeRuntime.ts` already has `startOpenCodeServerProcess` using Effect's `ChildProcess` from `effect/unstable/process` (not raw Node `child_process`). This is architecturally significant: Effect's process spawner integrates with the Effect runtime's fiber cancellation and structured concurrency semantics. The server also has runtime profiles with `managed`, `external`, and `remote` modes, health checks in `openCodeRuntimeHealth.ts` (459 lines, exports `checkOpenCodeRuntimeHealth` with states: `unknown`, `checking`, `healthy`, `degraded`, `unreachable`, `misconfigured`), and provider session dispatch. The desktop shell's role is limited to: triggering setup on first launch, rendering UI, handling Electron lifecycle events, and managing JCode application updates. +This aligns with existing code. `opencodeRuntime.ts` already has `startOpenCodeServerProcess` using Effect's `ChildProcess` from `effect/unstable/process` (not raw Node `child_process`). This is architecturally significant: Effect's process spawner integrates with the Effect runtime's fiber cancellation and structured concurrency semantics. The server also has runtime profiles with `managed`, `external`, and `remote` modes, health checks in `openCodeRuntimeHealth.ts` with states such as `unknown`, `checking`, `healthy`, `degraded`, `unreachable`, and `misconfigured`, and provider session dispatch. The desktop shell's role is limited to: triggering setup on first launch, rendering UI, handling Electron lifecycle events, and managing JCode application updates. ### Health Gate and Version Pairing (PRD Decision 10) @@ -64,7 +64,7 @@ JCode provisions, verifies, starts, and repairs the managed runtime without aski ### configMode Default (PRD Decision 13) -`startOpenCodeServerProcess` currently defaults `configMode` to `"inherit"` (line 805 of `opencodeRuntime.ts`). For managed runtimes on clean Windows installs, the server must pass `configMode: "generated"` so the managed OpenCode instance gets an isolated configuration directory (`opencodeConfigDir`, `opencodeDataDir` from the runtime profile). The `"inherit"` default is correct for users who already have an OpenCode configuration and connect via `external` mode. The managed runtime profile creation code (Slice 6) must explicitly set `configMode: "generated"`. +`startOpenCodeServerProcess` in `opencodeRuntime.ts` defaults `configMode` to `"inherit"`. For managed runtimes on clean Windows installs, the server must pass `configMode: "generated"` so the managed OpenCode instance gets an isolated configuration directory (`opencodeConfigDir`, `opencodeDataDir` from the runtime profile). The `"inherit"` default is correct for users who already have an OpenCode configuration and connect via `external` mode. The managed runtime profile creation code (Slice 6) must explicitly set `configMode: "generated"`. ## Consequences diff --git a/docs/adr/0010-provider-agnostic-first-run-wizard.md b/docs/adr/0010-provider-agnostic-first-run-wizard.md index 16a363883..41e0479ff 100644 --- a/docs/adr/0010-provider-agnostic-first-run-wizard.md +++ b/docs/adr/0010-provider-agnostic-first-run-wizard.md @@ -11,11 +11,11 @@ | Last reviewed | 2026-06-07 | | Review cadence | Event-driven; review if JCode changes provider abstraction or adds hosted provider support | | Source of truth | `apps/web/src/routes/_chat.settings.tsx`, `packages/contracts/src/providerDiscovery.ts`, `apps/server/src/provider/providerMaintenance.ts`, `apps/server/src/provider/opencodeRuntime.ts` | -| Verification | Wizard detects all 7 ProviderDiscoveryKind providers; credential-first detection checks API keys and config dirs before PATH binaries; OpenCode is the only provider with managed download in v1 | +| Verification | Wizard detects all 9 ProviderDiscoveryKind providers; provider readiness uses `ready`, `needs-config`, and `not-installed` states; OpenCode is the only provider with managed download in v1 | ## Context -The Windows turnkey release PRD initially described a first-run wizard focused on installing and configuring OpenCode. However, JCode supports seven providers (`codex`, `claudeAgent`, `cursor`, `gemini`, `kilo`, `opencode`, `pi` — from `ProviderDiscoveryKind` in `providerDiscovery.ts`), and the codebase default provider is `codex` (`DEFAULT_PROVIDER_KIND="codex"` in `orchestration.ts` line 76), not opencode. Forcing an OpenCode install on every user, including those who already have Codex or Claude configured, creates unnecessary friction and ignores existing provider infrastructure. +The Windows turnkey release PRD initially described a first-run wizard focused on installing and configuring OpenCode. However, JCode supports nine providers (`codex`, `claudeAgent`, `cursor`, `devin`, `gemini`, `kilo`, `opencode`, `openclaw`, `pi` — from `ProviderDiscoveryKind` in `providerDiscovery.ts`), and the codebase default provider is `codex`, not opencode. Forcing an OpenCode install on every user, including those who already have Codex or Claude configured, creates unnecessary friction and ignores existing provider infrastructure. ## Decision @@ -23,16 +23,16 @@ The first-run wizard is provider-agnostic. It detects installed providers, prese ### Credential-First Detection Strategy (PRD Decision 9) -Provider detection uses a credential-first, binary-second strategy. This is architecturally important because credentials alone are sufficient for API-based providers (codex, claudeAgent, gemini, cursor), while binary-only detection misses users who have API keys configured but no local binary: +Provider detection uses a credential-first, binary-second strategy. This is architecturally important because it can explain whether a provider is missing credentials, a PATH binary, or both: 1. **Check provider credentials**: API keys in environment variables, config directories (e.g., OpenCode's `~/.config/opencode/`), and stored credentials. 2. **Check provider binaries on PATH**: Use existing `providerMaintenance.ts` binary discovery which handles Windows `.exe/.cmd/.bat` extensions. -3. **Present three states per provider**: "ready" (credentials and/or binary present), "needs credentials" (binary only), "needs setup" (neither). +3. **Present three states per provider**: `ready` when credentials and binary are both present, `needs-config` when a binary is present but credentials are missing, and `not-installed` when the binary is missing. ### Detection Source Files - `providerMaintenance.ts`: Binary detection, install source detection (`npm`, `bun`, `cargo`, `system`), version checking. -- `providerDiscovery.ts`: `ProviderDiscoveryKind` enum (7 providers), `OpenCodeRuntimeProfile` schema, `ProviderRuntimeMode` (`managed` | `external` | `remote`). +- `providerDiscovery.ts`: `ProviderDiscoveryKind` schema (9 providers), `OpenCodeRuntimeProfile` schema, `ProviderRuntimeMode` (`managed` | `external` | `remote`). - `opencodeRuntime.ts`: `startOpenCodeServerProcess` for managed runtime spawn; relevant because the wizard triggers this for OpenCode managed installs. ### Wizard Flow @@ -69,7 +69,7 @@ On a clean Windows machine where no providers are detected, the wizard must: - The settings UI should eventually offer "Install runtime" per provider, but this is post-MVP. - The wizard must handle the case where no providers are detected (clean machine) gracefully by showing all options with setup instructions. - Provider selection is stored in settings and can be changed later. -- Credential-first detection means the wizard can surface "ready" providers even when no binary is on PATH, which is the common case for API-based providers. +- Credential-first detection means the wizard can distinguish credential presence from binary presence while only surfacing `ready` when both are available. ## Implementing Issues @@ -82,4 +82,4 @@ Slice 2 builds the detection backend. Slice 3 builds the wizard UI that consumes ## Alternatives Considered -**OpenCode-only wizard (original PRD).** Would be simpler to build but ignores the multi-provider reality of JCode, creates a worse UX for non-OpenCode users (who would be forced through an unnecessary install), and would need to be redesigned when adding other providers later. The codebase already has the infrastructure to detect 7 providers; not using it would be a waste. +**OpenCode-only wizard (original PRD).** Would be simpler to build but ignores the multi-provider reality of JCode, creates a worse UX for non-OpenCode users (who would be forced through an unnecessary install), and would need to be redesigned when adding other providers later. The codebase already has the infrastructure to detect 9 providers; not using it would be a waste. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0df8ff2be..d8886088b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ | Audience | Engineers, reviewers, maintainers, and automation agents | | Scope | Durable architecture decisions for JCode boundaries, runtime posture, release strategy, and provider integration | | Canonical path | `docs/adr/README.md` | -| Last reviewed | 2026-06-05 | +| Last reviewed | 2026-06-19 | | Review cadence | Event-driven; add or update ADRs when a decision changes how future work should be done | | Source of truth | ADR files, linked architecture docs, and runtime source | | Verification | Cross-check ADR claims against current source and tests before relying on them | diff --git a/packages/contracts/src/firstRunWizard.ts b/packages/contracts/src/firstRunWizard.ts index a6c7b4fb4..2e6cc5a42 100644 --- a/packages/contracts/src/firstRunWizard.ts +++ b/packages/contracts/src/firstRunWizard.ts @@ -12,6 +12,10 @@ import { Schema } from "effect"; import { ProviderDiscoveryKind } from "./providerDiscovery"; import { ProviderScanAllResult } from "./providerScan"; +const IsoTimestampString = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/), +); + // --------------------------------------------------------------------------- // First-run state // --------------------------------------------------------------------------- @@ -19,7 +23,7 @@ import { ProviderScanAllResult } from "./providerScan"; export const FirstRunState = Schema.Struct({ completed: Schema.Boolean, selectedProvider: Schema.optional(ProviderDiscoveryKind), - completedAt: Schema.optional(Schema.String), + completedAt: Schema.optional(IsoTimestampString), skipped: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), }); export type FirstRunState = typeof FirstRunState.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fc0e05fc7..3e245856a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,7 +3,7 @@ import { TrimmedString } from "./baseSchemas"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model"; import { ModelSelection, ProviderKind, ThreadEnvironmentMode } from "./orchestration"; import { OpenCodeRuntimeProfile } from "./providerDiscovery"; -import { FirstRunState } from "./firstRunWizard"; +import { DEFAULT_FIRST_RUN_STATE, FirstRunState } from "./firstRunWizard"; const StringSetting = TrimmedString.check(Schema.isMaxLength(4096)); const CustomModels = Schema.Array(Schema.String.check(Schema.isMaxLength(256))).pipe( @@ -117,9 +117,7 @@ export const ServerSettings = Schema.Struct({ pi: PiServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), devin: DevinServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), - firstRun: FirstRunState.pipe( - Schema.withDecodingDefault(() => ({ completed: false, skipped: false })), - ), + firstRun: FirstRunState.pipe(Schema.withDecodingDefault(() => DEFAULT_FIRST_RUN_STATE)), chatFontSizePx: Schema.Number.pipe(Schema.withDecodingDefault(() => 12)), chatCodeFontFamily: Schema.String.check(Schema.isMaxLength(256)).pipe( Schema.withDecodingDefault(() => ""), diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 386e4c897..b7c7f41bb 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -223,6 +223,36 @@ it.effect("accepts first-run wizard skip requests", () => }), ); +it.effect("accepts first-run wizard data requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-get-wizard-data-1", + body: { + _tag: WS_METHODS.serverGetFirstRunWizardData, + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.serverGetFirstRunWizardData); + }), +); + +it.effect("accepts first-run wizard completion requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-complete-wizard-1", + body: { + _tag: WS_METHODS.serverCompleteFirstRunWizard, + provider: "opencode", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.serverCompleteFirstRunWizard); + if (parsed.body._tag === WS_METHODS.serverCompleteFirstRunWizard) { + assert.strictEqual(parsed.body.provider, "opencode"); + } + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decode(WsResponse, { diff --git a/scripts/generate-winget-manifest.test.ts b/scripts/generate-winget-manifest.test.ts index 7dc026e5b..f9b1f3678 100644 --- a/scripts/generate-winget-manifest.test.ts +++ b/scripts/generate-winget-manifest.test.ts @@ -4,6 +4,7 @@ import { createWingetInstallerManifest, createWingetLocaleManifest, createWingetVersionManifest, + repositoryToWingetId, } from "./generate-winget-manifest.ts"; describe("generate-winget-manifest", () => { @@ -93,4 +94,8 @@ describe("generate-winget-manifest", () => { }), ).toThrow(/Invalid GitHub repository slug/); }); + + it("rejects invalid repository slugs when deriving Winget IDs directly", () => { + expect(() => repositoryToWingetId("bad slug")).toThrow(/Invalid GitHub repository slug/); + }); }); diff --git a/scripts/generate-winget-manifest.ts b/scripts/generate-winget-manifest.ts index d17320fff..1b5443575 100644 --- a/scripts/generate-winget-manifest.ts +++ b/scripts/generate-winget-manifest.ts @@ -121,6 +121,9 @@ const WINGET_ID = "Jay1.JCode"; export function repositoryToWingetId(repository: string): string { if (repository === DEFAULT_REPOSITORY) return WINGET_ID; const [owner, repo] = repository.split("/"); + if (!owner || !repo || repository.split("/").length !== 2) { + throw new Error(`Invalid GitHub repository slug: ${repository}`); + } const pascalRepo = repo.slice(0, 1).toUpperCase() + repo.slice(1); return `${owner}.${pascalRepo}`; } diff --git a/scripts/update-package-manifests.test.ts b/scripts/update-package-manifests.test.ts index 0035019e1..53b5ec1e1 100644 --- a/scripts/update-package-manifests.test.ts +++ b/scripts/update-package-manifests.test.ts @@ -15,8 +15,9 @@ describe("update-package-manifests", () => { it("downloads the installer, computes SHA-256, and generates Scoop + Winget manifests", async () => { const fakeExe = new Uint8Array([0x4d, 0x5a, 0x90, 0x00]); - const mockFetch = async (url: string | URL | Request) => { + const mockFetch = async (url: string | URL | Request, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url.toString(); + expect(init?.signal).toBeInstanceOf(AbortSignal); if (urlStr.includes("JCode-0.0.50-x64.exe")) { return new Response(fakeExe, { status: 200 }); } @@ -62,7 +63,10 @@ describe("update-package-manifests", () => { }); it("throws on failed download", async () => { - const mockFetch = async () => new Response("not found", { status: 404 }); + const mockFetch = async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response("not found", { status: 404 }); + }; await expect( updatePackageManifests( @@ -78,8 +82,9 @@ describe("update-package-manifests", () => { it("uses a custom repository when provided", async () => { const fakeExe = new Uint8Array([0x4d, 0x5a]); - const mockFetch = async (url: string | URL | Request) => { + const mockFetch = async (url: string | URL | Request, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url.toString(); + expect(init?.signal).toBeInstanceOf(AbortSignal); if (urlStr.includes("MyOrg/my-app")) { return new Response(fakeExe, { status: 200 }); } diff --git a/scripts/update-package-manifests.ts b/scripts/update-package-manifests.ts index 505639085..d82e364a9 100644 --- a/scripts/update-package-manifests.ts +++ b/scripts/update-package-manifests.ts @@ -17,6 +17,7 @@ import { } from "./generate-winget-manifest.ts"; const DEFAULT_REPOSITORY = "Jay1/jcode"; +const INSTALLER_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; export interface UpdatePackageManifestsOptions { readonly outputDir?: string; @@ -61,7 +62,9 @@ export async function updatePackageManifests( const outputDir = options.outputDir ?? "packaging"; const installerUrl = buildWindowsInstallerUrl(repository, options.version); - const response = await fetchFn(installerUrl); + const response = await fetchFn(installerUrl, { + signal: AbortSignal.timeout(INSTALLER_DOWNLOAD_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( `Failed to download Windows installer from ${installerUrl}: ${response.status} ${response.statusText}`, From 87e3768aa0e2023264f90f1ae19465630c59a3b9 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 15:56:10 -0400 Subject: [PATCH 10/18] docs: format Windows ADR tables --- ...9-backend-owned-managed-runtime-sidecar.md | 34 +++++++++---------- ...0010-provider-agnostic-first-run-wizard.md | 30 ++++++++-------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md index 273c4c4fb..50785f890 100644 --- a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md +++ b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md @@ -1,16 +1,16 @@ # ADR 0009: Backend-Owned Managed Runtime Sidecar -| Field | Value | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Status | Accepted | -| Type | Architecture decision record | -| Owner | Engineering | -| Audience | Maintainers, reviewers, and automation agents | -| Scope | Runtime process ownership for managed provider sidecars in JCode desktop mode | -| Canonical path | `docs/adr/0009-backend-owned-managed-runtime-sidecar.md` | -| Last reviewed | 2026-06-07 | -| Review cadence | Event-driven; review if JCode adds a remote/hosted mode or changes provider runtime boundaries | -| Source of truth | `apps/server/src/provider/opencodeRuntime.ts`, `apps/server/src/provider/openCodeRuntimeHealth.ts`, `packages/contracts/src/providerDiscovery.ts` | +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Status | Accepted | +| Type | Architecture decision record | +| Owner | Engineering | +| Audience | Maintainers, reviewers, and automation agents | +| Scope | Runtime process ownership for managed provider sidecars in JCode desktop mode | +| Canonical path | `docs/adr/0009-backend-owned-managed-runtime-sidecar.md` | +| Last reviewed | 2026-06-07 | +| Review cadence | Event-driven; review if JCode adds a remote/hosted mode or changes provider runtime boundaries | +| Source of truth | `apps/server/src/provider/opencodeRuntime.ts`, `apps/server/src/provider/openCodeRuntimeHealth.ts`, `packages/contracts/src/providerDiscovery.ts` | | Verification | Managed runtime spawns via server-side Effect ChildProcess; health checks gate desktop readiness; managed profiles are OpenCode-specific in contracts schema | ## Context @@ -81,12 +81,12 @@ JCode provisions, verifies, starts, and repairs the managed runtime without aski ## Implementing Issues -| Slice | Issue | Title | Implements | -|-------|-------|-------|------------| -| 4 | #72 | Managed OpenCode download and verify | Post-Install Download Pipeline (D7) | -| 5 | #83 | Backend-owned managed OpenCode sidecar lifecycle | Core decision (D6), Fresh Password (D11), Health Gate (D10) | -| 6 | #81 | Managed runtime profile auto-creation | configMode Default (D13), Runtime Profiles Visible (D5) | -| 7 | #85 | Runtime health, repair, and diagnostics export | Health Gate repair flow (D10), Native First (D3) | +| Slice | Issue | Title | Implements | +| ------- | ------- | ------------------------------------------------ | ----------------------------------------------------------- | +| 4 | #72 | Managed OpenCode download and verify | Post-Install Download Pipeline (D7) | +| 5 | #83 | Backend-owned managed OpenCode sidecar lifecycle | Core decision (D6), Fresh Password (D11), Health Gate (D10) | +| 6 | #81 | Managed runtime profile auto-creation | configMode Default (D13), Runtime Profiles Visible (D5) | +| 7 | #85 | Runtime health, repair, and diagnostics export | Health Gate repair flow (D10), Native First (D3) | Slices 4 and 5 are the primary implementation. Slice 6 extends the profile model. Slice 7 adds the repair loop. diff --git a/docs/adr/0010-provider-agnostic-first-run-wizard.md b/docs/adr/0010-provider-agnostic-first-run-wizard.md index 41e0479ff..8cd6b1c8d 100644 --- a/docs/adr/0010-provider-agnostic-first-run-wizard.md +++ b/docs/adr/0010-provider-agnostic-first-run-wizard.md @@ -1,16 +1,16 @@ # ADR 0010: Provider-Agnostic First-Run Wizard -| Field | Value | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| Status | Accepted | -| Type | Architecture decision record | -| Owner | Engineering | -| Audience | Maintainers, reviewers, and automation agents | -| Scope | First-run setup experience for JCode desktop, covering provider detection, selection, and runtime provisioning | -| Canonical path | `docs/adr/0010-provider-agnostic-first-run-wizard.md` | -| Last reviewed | 2026-06-07 | -| Review cadence | Event-driven; review if JCode changes provider abstraction or adds hosted provider support | -| Source of truth | `apps/web/src/routes/_chat.settings.tsx`, `packages/contracts/src/providerDiscovery.ts`, `apps/server/src/provider/providerMaintenance.ts`, `apps/server/src/provider/opencodeRuntime.ts` | +| Field | Value | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Status | Accepted | +| Type | Architecture decision record | +| Owner | Engineering | +| Audience | Maintainers, reviewers, and automation agents | +| Scope | First-run setup experience for JCode desktop, covering provider detection, selection, and runtime provisioning | +| Canonical path | `docs/adr/0010-provider-agnostic-first-run-wizard.md` | +| Last reviewed | 2026-06-07 | +| Review cadence | Event-driven; review if JCode changes provider abstraction or adds hosted provider support | +| Source of truth | `apps/web/src/routes/_chat.settings.tsx`, `packages/contracts/src/providerDiscovery.ts`, `apps/server/src/provider/providerMaintenance.ts`, `apps/server/src/provider/opencodeRuntime.ts` | | Verification | Wizard detects all 9 ProviderDiscoveryKind providers; provider readiness uses `ready`, `needs-config`, and `not-installed` states; OpenCode is the only provider with managed download in v1 | ## Context @@ -73,10 +73,10 @@ On a clean Windows machine where no providers are detected, the wizard must: ## Implementing Issues -| Slice | Issue | Title | Implements | -|-------|-------|-------|------------| -| 2 | #73 | Credential-first provider scanning | Credential-First Detection (D9) | -| 3 | #84 | Provider-agnostic first-run wizard | Core decision (D8), wizard flow | +| Slice | Issue | Title | Implements | +| ------- | ------- | ---------------------------------- | ------------------------------- | +| 2 | #73 | Credential-first provider scanning | Credential-First Detection (D9) | +| 3 | #84 | Provider-agnostic first-run wizard | Core decision (D8), wizard flow | Slice 2 builds the detection backend. Slice 3 builds the wizard UI that consumes it. From 754d4b3b9e64e63c29e6656865b126d40dc2ac35 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:03:52 -0400 Subject: [PATCH 11/18] docs: apply oxfmt to Windows ADR tables --- .../0009-backend-owned-managed-runtime-sidecar.md | 12 ++++++------ docs/adr/0010-provider-agnostic-first-run-wizard.md | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md index 50785f890..e90253462 100644 --- a/docs/adr/0009-backend-owned-managed-runtime-sidecar.md +++ b/docs/adr/0009-backend-owned-managed-runtime-sidecar.md @@ -81,12 +81,12 @@ JCode provisions, verifies, starts, and repairs the managed runtime without aski ## Implementing Issues -| Slice | Issue | Title | Implements | -| ------- | ------- | ------------------------------------------------ | ----------------------------------------------------------- | -| 4 | #72 | Managed OpenCode download and verify | Post-Install Download Pipeline (D7) | -| 5 | #83 | Backend-owned managed OpenCode sidecar lifecycle | Core decision (D6), Fresh Password (D11), Health Gate (D10) | -| 6 | #81 | Managed runtime profile auto-creation | configMode Default (D13), Runtime Profiles Visible (D5) | -| 7 | #85 | Runtime health, repair, and diagnostics export | Health Gate repair flow (D10), Native First (D3) | +| Slice | Issue | Title | Implements | +| ----- | ----- | ------------------------------------------------ | ----------------------------------------------------------- | +| 4 | #72 | Managed OpenCode download and verify | Post-Install Download Pipeline (D7) | +| 5 | #83 | Backend-owned managed OpenCode sidecar lifecycle | Core decision (D6), Fresh Password (D11), Health Gate (D10) | +| 6 | #81 | Managed runtime profile auto-creation | configMode Default (D13), Runtime Profiles Visible (D5) | +| 7 | #85 | Runtime health, repair, and diagnostics export | Health Gate repair flow (D10), Native First (D3) | Slices 4 and 5 are the primary implementation. Slice 6 extends the profile model. Slice 7 adds the repair loop. diff --git a/docs/adr/0010-provider-agnostic-first-run-wizard.md b/docs/adr/0010-provider-agnostic-first-run-wizard.md index 8cd6b1c8d..066baec5d 100644 --- a/docs/adr/0010-provider-agnostic-first-run-wizard.md +++ b/docs/adr/0010-provider-agnostic-first-run-wizard.md @@ -73,10 +73,10 @@ On a clean Windows machine where no providers are detected, the wizard must: ## Implementing Issues -| Slice | Issue | Title | Implements | -| ------- | ------- | ---------------------------------- | ------------------------------- | -| 2 | #73 | Credential-first provider scanning | Credential-First Detection (D9) | -| 3 | #84 | Provider-agnostic first-run wizard | Core decision (D8), wizard flow | +| Slice | Issue | Title | Implements | +| ----- | ----- | ---------------------------------- | ------------------------------- | +| 2 | #73 | Credential-first provider scanning | Credential-First Detection (D9) | +| 3 | #84 | Provider-agnostic first-run wizard | Core decision (D8), wizard flow | Slice 2 builds the detection backend. Slice 3 builds the wizard UI that consumes it. From d0008251e7f876d54fe6bcaa6da6d1394c2d36c8 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:11:47 -0400 Subject: [PATCH 12/18] fix(web): refresh provider transport after auth bootstrap --- apps/web/src/wsNativeApi.ts | 40 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 49a20a1ce..5fdb10358 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -709,30 +709,28 @@ export function createWsNativeApi(): NativeApi { }, provider: { getComposerCapabilities: (input) => - transport.request(WS_METHODS.providerGetComposerCapabilities, input), - getRuntimeHealth: (input) => transport.request(WS_METHODS.providerGetRuntimeHealth, input), + requestWs(WS_METHODS.providerGetComposerCapabilities, input), + getRuntimeHealth: (input) => requestWs(WS_METHODS.providerGetRuntimeHealth, input), getManagedSidecarHealth: () => - transport.request(WS_METHODS.providerGetManagedSidecarHealth, undefined), - repairManagedSidecar: (input) => - transport.request(WS_METHODS.providerRepairManagedSidecar, input), + requestWs(WS_METHODS.providerGetManagedSidecarHealth, undefined), + repairManagedSidecar: (input) => requestWs(WS_METHODS.providerRepairManagedSidecar, input), exportManagedSidecarDiagnostics: () => - transport.request(WS_METHODS.providerExportManagedSidecarDiagnostics, undefined), + requestWs(WS_METHODS.providerExportManagedSidecarDiagnostics, undefined), 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), - installSkill: (input) => transport.request(WS_METHODS.providerInstallSkill, input), - uninstallSkill: (input) => transport.request(WS_METHODS.providerUninstallSkill, input), - setSkillEnabled: (input) => transport.request(WS_METHODS.providerSetSkillEnabled, input), - searchSkillsCatalog: (input) => - transport.request(WS_METHODS.providerSearchSkillsCatalog, input), - listPlugins: (input) => transport.request(WS_METHODS.providerListPlugins, input), - readPlugin: (input) => transport.request(WS_METHODS.providerReadPlugin, input), - listModels: (input) => transport.request(WS_METHODS.providerListModels, input), - listAgents: (input) => transport.request(WS_METHODS.providerListAgents, input), + requestWs(WS_METHODS.providerGetRuntimeBootstrapStatus, input), + bootstrapRuntime: (input) => requestWs(WS_METHODS.providerBootstrapRuntime, input), + repairRuntime: (input) => requestWs(WS_METHODS.providerRepairRuntime, input), + compactThread: (input) => requestWs(WS_METHODS.providerCompactThread, input), + listCommands: (input) => requestWs(WS_METHODS.providerListCommands, input), + listSkills: (input) => requestWs(WS_METHODS.providerListSkills, input), + installSkill: (input) => requestWs(WS_METHODS.providerInstallSkill, input), + uninstallSkill: (input) => requestWs(WS_METHODS.providerUninstallSkill, input), + setSkillEnabled: (input) => requestWs(WS_METHODS.providerSetSkillEnabled, input), + searchSkillsCatalog: (input) => requestWs(WS_METHODS.providerSearchSkillsCatalog, input), + listPlugins: (input) => requestWs(WS_METHODS.providerListPlugins, input), + readPlugin: (input) => requestWs(WS_METHODS.providerReadPlugin, input), + listModels: (input) => requestWs(WS_METHODS.providerListModels, input), + listAgents: (input) => requestWs(WS_METHODS.providerListAgents, input), }, orchestration: { getSnapshot: () => From ca32d3b01e3a80b5f6901282c705c86080039f9a Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:32:38 -0400 Subject: [PATCH 13/18] test(web): provide first-run data in ChatView browser fixture --- apps/web/src/components/ChatView.browser.tsx | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ba3f93407..140e119b8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -7,6 +7,7 @@ import { MessageId, ORCHESTRATION_WS_METHODS, type GitListBranchesResult, + type FirstRunWizardData, type OrchestrationReadModel, type OrchestrationShellSnapshot, type OrchestrationThread, @@ -72,6 +73,7 @@ interface WsRequestEnvelope { interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; + firstRunWizardData: FirstRunWizardData; gitListBranchesResult?: GitListBranchesResult; welcome: WsWelcomePayload; } @@ -161,6 +163,30 @@ function createBaseServerConfig(): ServerConfig { }; } +function createCompletedFirstRunWizardData(): FirstRunWizardData { + return { + state: { + completed: true, + completedAt: NOW_ISO, + selectedProvider: "codex", + skipped: false, + }, + currentStep: "complete", + scanResults: { + scannedAt: NOW_ISO, + providers: [ + { + provider: "codex", + status: "ready", + hasCredentials: true, + hasBinary: true, + credentials: [], + }, + ], + }, + }; +} + function createUserMessage(options: { id: MessageId; text: string; @@ -481,6 +507,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, serverConfig: createBaseServerConfig(), + firstRunWizardData: createCompletedFirstRunWizardData(), welcome: { cwd: "/repo/project", projectName: "Project", @@ -894,6 +921,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetSettings) { return DEFAULT_SERVER_SETTINGS; } + if (tag === WS_METHODS.serverGetFirstRunWizardData) { + return fixture.firstRunWizardData; + } if (tag === WS_METHODS.serverGetEnvironment) { return { environmentId: "test-browser", From 391f9745de5914c82eb4bc3b29b768386aec7ed7 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:47:25 -0400 Subject: [PATCH 14/18] fix(server): guard runtime bootstrap status rpc --- apps/server/src/wsRpc.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 681577b61..c8fc8a773 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -1711,9 +1711,12 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.providerGetRuntimeBootstrapStatus]: (input) => - rpcEffect( - getOpenCodeRuntimeBootstrapStatus(input), - "Failed to get runtime bootstrap status", + withCurrentSession( + requireOwnerWsRpcAccess, + rpcEffect( + getOpenCodeRuntimeBootstrapStatus(input), + "Failed to get runtime bootstrap status", + ), ), [WS_METHODS.providerBootstrapRuntime]: (input) => rpcEffect(ownerOnly(bootstrapOpenCodeRuntime(input)), "Failed to bootstrap runtime"), From 438aba014c0eb591d228c7c3bf3e351a3a3a7b70 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:47:25 -0400 Subject: [PATCH 15/18] test(web): complete first-run browser fixtures --- apps/web/src/components/ChatView.browser.tsx | 32 ++++++++++++------- .../components/KeybindingsToast.browser.tsx | 30 +++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 140e119b8..f21c0f1de 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -577,6 +577,7 @@ function getThreadDetailFromFixtureSnapshot(threadId: ThreadId): OrchestrationTh function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, + overrides?: Partial, ): OrchestrationReadModel { return { ...snapshot, @@ -593,9 +594,9 @@ function addThreadToSnapshot( }, interactionMode: "default", runtimeMode: "full-access", - envMode: "local", - branch: "main", - worktreePath: null, + envMode: overrides?.envMode ?? "local", + branch: overrides?.branch ?? "main", + worktreePath: overrides?.worktreePath ?? null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -2385,15 +2386,22 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "test", - name: "Test", - command: "bun run test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]), + snapshot: withProjectScripts( + addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID, { + branch: "feature/draft", + envMode: "worktree", + worktreePath: "/repo/worktrees/feature-draft", + }), + [ + { + id: "test", + name: "Test", + command: "bun run test", + icon: "test", + runOnWorktreeCreate: false, + }, + ], + ), }); try { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 292c8b1b2..c10f06260 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { DEFAULT_SERVER_SETTINGS, + type FirstRunWizardData, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -33,6 +34,7 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; + firstRunWizardData: FirstRunWizardData; welcome: WsWelcomePayload; } @@ -59,6 +61,30 @@ function createBaseServerConfig(): ServerConfig { }; } +function createCompletedFirstRunWizardData(): FirstRunWizardData { + return { + state: { + completed: true, + completedAt: NOW_ISO, + selectedProvider: "codex", + skipped: false, + }, + currentStep: "complete", + scanResults: { + scannedAt: NOW_ISO, + providers: [ + { + provider: "codex", + status: "ready", + hasCredentials: true, + hasBinary: true, + credentials: [], + }, + ], + }, + }; +} + function createMinimalSnapshot(): OrchestrationReadModel { return { snapshotSequence: 1, @@ -131,6 +157,7 @@ function buildFixture(): TestFixture { return { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), + firstRunWizardData: createCompletedFirstRunWizardData(), welcome: { cwd: "/repo/project", projectName: "Project", @@ -210,6 +237,9 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetSettings) { return DEFAULT_SERVER_SETTINGS; } + if (tag === WS_METHODS.serverGetFirstRunWizardData) { + return fixture.firstRunWizardData; + } if (tag === WS_METHODS.serverGetEnvironment) { return { environmentId: "test-browser", From 1d4304865ece4d4c07be2ee951f4f31e6917968d Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 16:59:16 -0400 Subject: [PATCH 16/18] fix(web): run draft scripts from worktree cwd --- apps/web/src/components/ChatView.browser.tsx | 32 ++++++++------------ apps/web/src/components/ChatView.tsx | 5 ++- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f21c0f1de..140e119b8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -577,7 +577,6 @@ function getThreadDetailFromFixtureSnapshot(threadId: ThreadId): OrchestrationTh function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, - overrides?: Partial, ): OrchestrationReadModel { return { ...snapshot, @@ -594,9 +593,9 @@ function addThreadToSnapshot( }, interactionMode: "default", runtimeMode: "full-access", - envMode: overrides?.envMode ?? "local", - branch: overrides?.branch ?? "main", - worktreePath: overrides?.worktreePath ?? null, + envMode: "local", + branch: "main", + worktreePath: null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -2386,22 +2385,15 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts( - addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID, { - branch: "feature/draft", - envMode: "worktree", - worktreePath: "/repo/worktrees/feature-draft", - }), - [ - { - id: "test", - name: "Test", - command: "bun run test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - ), + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "test", + name: "Test", + command: "bun run test", + icon: "test", + runOnWorktreeCreate: false, + }, + ]), }); try { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1ddb0e5fe..aaa1002a0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2869,7 +2869,6 @@ export default function ChatView({ voiceProviderStatus?.voiceTranscriptionAvailable !== false; const showVoiceNotesControl = canRenderVoiceNotes || isVoiceRecording || isVoiceTranscribing; const activeProjectCwd = activeProject?.cwd ?? null; - const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const hasNativeUserMessages = useMemo( () => activeThread?.messages.some( @@ -2883,9 +2882,9 @@ export default function ChatView({ project: { cwd: activeProjectCwd, }, - worktreePath: activeThreadWorktreePath, + worktreePath: resolvedThreadWorktreePath, }); - }, [activeProjectCwd, activeThreadWorktreePath]); + }, [activeProjectCwd, resolvedThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const terminalToggleShortcutLabel = useMemo( From 639b26b93330bdc7f97c444d8ce3124bf69ed01a Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 17:08:19 -0400 Subject: [PATCH 17/18] fix(web): use draft worktree for script terminal --- apps/web/src/components/ChatView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aaa1002a0..97136779a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3558,7 +3558,7 @@ export default function ChatView({ }, ) => { const api = readNativeApi(); - if (!api || !activeThreadId || !activeProject || !activeThread) return; + if (!api || !activeThreadId || !activeProject) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -3589,7 +3589,7 @@ export default function ChatView({ project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: options?.worktreePath ?? resolvedThreadWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal @@ -3631,9 +3631,9 @@ export default function ChatView({ }, [ activeProject, - activeThread, activeThreadId, gitCwd, + resolvedThreadWorktreePath, setTerminalOpen, setThreadError, storeNewTerminal, From 601638b44f3a37d9b03735b429652cc3a5b452aa Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 25 Jun 2026 17:15:23 -0400 Subject: [PATCH 18/18] test(web): ignore mount terminal opens in script tests --- apps/web/src/components/ChatView.browser.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 140e119b8..118cebec8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2326,13 +2326,14 @@ describe("ChatView timeline estimator parity (full app)", () => { ) as HTMLButtonElement | null, "Unable to find Run Lint button.", ); + const requestStartIndex = wsRequests.length; runButton.click(); await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); + const openRequest = wsRequests + .slice(requestStartIndex) + .find((request) => request._tag === WS_METHODS.terminalOpen); expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, @@ -2348,9 +2349,9 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const writeRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalWrite, - ); + const writeRequest = wsRequests + .slice(requestStartIndex) + .find((request) => request._tag === WS_METHODS.terminalWrite); expect(writeRequest).toMatchObject({ _tag: WS_METHODS.terminalWrite, threadId: THREAD_ID, @@ -2404,13 +2405,14 @@ describe("ChatView timeline estimator parity (full app)", () => { ) as HTMLButtonElement | null, "Unable to find Run Test button.", ); + const requestStartIndex = wsRequests.length; runButton.click(); await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); + const openRequest = wsRequests + .slice(requestStartIndex) + .find((request) => request._tag === WS_METHODS.terminalOpen); expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID,