diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 579dcda1..47b8086c 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -7,6 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useRouter } from '@/hooks/useRouter'; @@ -107,6 +108,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
diff --git a/packages/ui/src/components/ui/sonner.tsx b/packages/ui/src/components/ui/sonner.tsx index f2b508a8..fd12cfdb 100644 --- a/packages/ui/src/components/ui/sonner.tsx +++ b/packages/ui/src/components/ui/sonner.tsx @@ -89,7 +89,7 @@ const Toaster = ({ ...props }: ToasterProps) => { toastOptions={{ classNames: { toast: - "group/toast toast !rounded-[var(--radius-xl)] !border-0 !px-3.5 !py-3 !gap-2.5 !text-foreground", + "group/toast toast !rounded-[var(--radius-xl)] !border-0 !px-3.5 !py-3 !gap-2.5 !text-foreground [&_[data-cancel]+[data-button]]:!ml-2", title: "typography-ui-label !font-medium !text-foreground", description: "typography-meta !text-muted-foreground !mt-0.5", actionButton: diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 8d1a3732..96ecf7df 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -5,6 +5,7 @@ import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { @@ -127,39 +128,50 @@ export const OpenCodeUpdateToast: React.FC = () => { }); }; - const onUpdateAvailable = (event: Event) => { - const version = resolveOpenCodeUpdateVersion((event as CustomEvent).detail); - showUpdateAvailableToast(version); - }; - let cancelled = false; const timeoutIds: Array> = []; - const checkForUpdate = async (attempt: number) => { + const checkForUpdate = async (attempt: number, runtimeKey = getRuntimeKey()) => { try { const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed'); const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null; const version = resolveOpenCodeUpgradeStatusVersion(status); - if (!cancelled && version) { + if (!cancelled && runtimeKey === getRuntimeKey() && version) { showUpdateAvailableToast(version); } } catch { const delay = CHECK_RETRY_DELAYS_MS[attempt]; - if (!cancelled && delay !== undefined) { - timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1); }, delay)); + if (!cancelled && runtimeKey === getRuntimeKey() && delay !== undefined) { + timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1, runtimeKey); }, delay)); } } }; + const onUpdateAvailable = (event: Event) => { + const version = resolveOpenCodeUpdateVersion((event as CustomEvent).detail); + if (version) { + void checkForUpdate(0); + } + }; + if (showOpenCodeUpdateNotifications) { timeoutIds.push(setTimeout(() => { void checkForUpdate(0); }, INITIAL_CHECK_DELAY_MS)); } + const unsubscribeRuntime = subscribeRuntimeEndpointChanged(({ runtimeKey }) => { + seenVersionsRef.current.clear(); + toast.dismiss(UPDATE_TOAST_ID); + if (useUIStore.getState().showOpenCodeUpdateNotifications) { + void checkForUpdate(0, runtimeKey); + } + }); + window.addEventListener('openchamber:opencode-update-available', onUpdateAvailable); return () => { cancelled = true; for (const timeoutId of timeoutIds) clearTimeout(timeoutId); + unsubscribeRuntime(); window.removeEventListener('openchamber:opencode-update-available', onUpdateAvailable); }; }, [runUpgrade, showOpenCodeUpdateNotifications, t]); diff --git a/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts b/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts index 64d8bf42..b670871f 100644 --- a/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts +++ b/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts @@ -181,6 +181,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => { resolveOpenCodeUpgradeStatusVersion({ available: true, latestVersion: '1.16.0', + upgrade: { supported: true }, }), ).toBe('1.16.0'); }); @@ -190,6 +191,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => { resolveOpenCodeUpgradeStatusVersion({ available: true, latestVersion: ' 1.16.0 ', + upgrade: { supported: true }, }), ).toBe('1.16.0'); }); @@ -211,6 +213,22 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => { ).toBe(''); }); + test('fails closed when the server does not explicitly support upgrades', () => { + expect( + resolveOpenCodeUpgradeStatusVersion({ + available: true, + latestVersion: '1.16.0', + }), + ).toBe(''); + expect( + resolveOpenCodeUpgradeStatusVersion({ + available: true, + latestVersion: '1.16.0', + upgrade: { supported: false }, + }), + ).toBe(''); + }); + test('returns empty string when available is missing or null', () => { expect( resolveOpenCodeUpgradeStatusVersion({ diff --git a/packages/ui/src/components/update/openCodeUpdateDedup.ts b/packages/ui/src/components/update/openCodeUpdateDedup.ts index be2cd54e..374c025c 100644 --- a/packages/ui/src/components/update/openCodeUpdateDedup.ts +++ b/packages/ui/src/components/update/openCodeUpdateDedup.ts @@ -79,6 +79,9 @@ export const resolveOpenCodeUpdateVersion = (detail: unknown): string => { export interface OpenCodeUpgradeStatusLike { readonly available?: boolean | null; readonly latestVersion?: string | null; + readonly upgrade?: { + readonly supported?: boolean | null; + } | null; } /** @@ -90,6 +93,7 @@ export const resolveOpenCodeUpgradeStatusVersion = ( status: OpenCodeUpgradeStatusLike | null | undefined, ): string => { if (!status) return ''; + if (status.upgrade?.supported !== true) return ''; if (status.available !== true) return ''; if (typeof status.latestVersion !== 'string') return ''; return status.latestVersion.trim(); diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 8d3f72d9..69152a46 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -64,7 +64,6 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { listGlobalSessionPages } from "@/stores/globalSessions" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" -import { runtimeFetch } from "@/lib/runtime-fetch" import { EMPTY_SESSION_MESSAGE_LOAD_STATE, SessionMessageLoader, @@ -1741,40 +1740,6 @@ const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) } -let bundledOpenCodeRuntimeCache: { runtimeKey: string; promise: Promise } | null = null - -const isBundledOpenCodeRuntime = async () => { - const runtimeKey = getRuntimeKey() - if (!bundledOpenCodeRuntimeCache || bundledOpenCodeRuntimeCache.runtimeKey !== runtimeKey) { - bundledOpenCodeRuntimeCache = { - runtimeKey, - promise: runtimeFetch("/api/config/opencode-resolution", { signal: AbortSignal.timeout(4000) }) - .then(async (response) => { - if (response.ok) { - const resolution = await response.json() as { source?: unknown; detectedSourceNow?: unknown } - return resolution.source === "bundled" || resolution.detectedSourceNow === "bundled" - } - - const healthResponse = await runtimeFetch("/health", { signal: AbortSignal.timeout(4000) }) - if (!healthResponse.ok) return false - const health = await healthResponse.json() as { opencodeBinarySource?: unknown } - return health.opencodeBinarySource === "bundled" - }) - .catch(() => false), - } - } - return bundledOpenCodeRuntimeCache.promise -} - -const dispatchOpenCodeUpdateAvailableUnlessBundled = (payload: { version: string }) => { - if (typeof window === "undefined") return - void isBundledOpenCodeRuntime().then((isBundled) => { - if (!isBundled) { - dispatchOpenCodeUpdateAvailable(payload) - } - }) -} - export function SyncProvider(props: { sdk: OpencodeClient directory: string @@ -2017,7 +1982,7 @@ export function SyncProvider(props: { ? (payload.properties as { version: string }).version : "" if (version) { - dispatchOpenCodeUpdateAvailableUnlessBundled({ version }) + dispatchOpenCodeUpdateAvailable({ version }) } } handleEvent(directory, payload, childStores, routingIndex, runtimeKey, false, currentDirectoryRef.current, batch) diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index e8ff0ca2..0060c110 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -59,6 +59,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - System/editor/provider/quota/notification/update-check message handlers. - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). + - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. + +- `opencode-upgrade-runtime.ts` + - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. - `bridge-permission-auto-accept-runtime.ts` - Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract. diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index d5829de3..eacbb3aa 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -9,6 +9,7 @@ import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProv import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials'; import { getSessionActivitySnapshot } from './sessionActivityWatcher'; +import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime'; import type { BridgeContext, BridgeResponse } from './bridge'; type BridgeMessageInput = { @@ -269,6 +270,15 @@ export async function handleSystemBridgeMessage( } } + case 'api:opencode/upgrade-status': { + return { id, type, success: true, data: await getOpenCodeUpgradeStatus(ctx?.manager) }; + } + + case 'api:opencode/upgrade': { + const target = (payload as { target?: unknown } | undefined)?.target; + return { id, type, success: true, data: await upgradeManagedOpenCode(ctx?.manager, target) }; + } + case 'api:session-activity:get': { return { id, type, success: true, data: getSessionActivitySnapshot() }; } diff --git a/packages/vscode/src/opencode-upgrade-runtime.test.ts b/packages/vscode/src/opencode-upgrade-runtime.test.ts new file mode 100644 index 00000000..6d872f52 --- /dev/null +++ b/packages/vscode/src/opencode-upgrade-runtime.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode, type OpenCodeUpgradeManager } from './opencode-upgrade-runtime'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const createManager = (mode: 'managed' | 'external' = 'managed') => { + let restartCount = 0; + const manager: OpenCodeUpgradeManager = { + getApiUrl: () => 'http://127.0.0.1:4096', + getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }), + getDebugInfo: () => ({ mode }), + restart: async () => { restartCount += 1; }, + }; + return { manager, getRestartCount: () => restartCount }; +}; + +describe('VS Code OpenCode upgrades', () => { + test('reports an available update for a managed OpenCode process', async () => { + const { manager } = createManager(); + globalThis.fetch = (async (input: Parameters[0]) => { + const url = String(input); + if (url.endsWith('/global/health')) return new Response(JSON.stringify({ version: '1.18.8' })); + if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.9' })); + return new Response(JSON.stringify({ tag_name: 'v1.18.9' })); + }) as typeof fetch; + + assert.deepEqual(await getOpenCodeUpgradeStatus(manager), { + available: true, + currentVersion: '1.18.8', + latestVersion: '1.18.9', + upgrade: { supported: true, manager: 'opencode', reason: null }, + }); + }); + + test('fails closed for externally managed OpenCode without contacting the updater', async () => { + const { manager } = createManager('external'); + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response('{}'); + }) as typeof fetch; + + assert.deepEqual(await upgradeManagedOpenCode(manager), { + status: 409, + body: { + success: false, + code: 'OPENCODE_UPGRADE_UNSUPPORTED', + error: 'This OpenCode runtime cannot be upgraded by OpenChamber.', + }, + }); + assert.equal(fetchCount, 0); + }); + + test('upgrades then restarts the extension-owned OpenCode process', async () => { + const { manager, getRestartCount } = createManager(); + let request: RequestInit | undefined; + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + assert.equal(String(input), 'http://127.0.0.1:4096/global/upgrade'); + request = init; + return new Response(JSON.stringify({ success: true, version: '1.18.9' })); + }) as typeof fetch; + + assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), { + status: 200, + body: { success: true, version: '1.18.9', restarted: true }, + }); + assert.equal(getRestartCount(), 1); + assert.equal(request?.method, 'POST'); + assert.deepEqual(JSON.parse(String(request?.body)), { target: '1.18.9' }); + assert.equal((request?.headers as Record).Authorization, 'Basic test'); + }); + + test('serializes concurrent managed upgrades', async () => { + const { manager } = createManager(); + let release: (response: Response) => void = () => {}; + globalThis.fetch = (() => new Promise((resolve) => { release = resolve; })) as typeof fetch; + + const first = upgradeManagedOpenCode(manager); + const second = await upgradeManagedOpenCode(manager); + assert.equal(second.status, 409); + assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS'); + + release(new Response(JSON.stringify({ success: true }))); + assert.equal((await first).status, 200); + }); +}); diff --git a/packages/vscode/src/opencode-upgrade-runtime.ts b/packages/vscode/src/opencode-upgrade-runtime.ts new file mode 100644 index 00000000..30f60857 --- /dev/null +++ b/packages/vscode/src/opencode-upgrade-runtime.ts @@ -0,0 +1,129 @@ +type UpgradeCapability = { + supported: boolean; + manager: 'opencode' | 'external' | null; + reason: 'external' | 'unavailable' | null; +}; + +export type OpenCodeUpgradeManager = { + getApiUrl(): string | null; + getOpenCodeAuthHeaders(): Record; + getDebugInfo(): { mode: 'managed' | 'external' }; + restart(): Promise; +}; + +type UpgradeResult = { status: number; body: Record }; + +let openCodeUpgradePromise: Promise | null = null; + +const parseVersion = (value: unknown): { parts: number[]; prerelease: boolean } => { + const normalized = String(value || '').replace(/^v/, '').split('+')[0]; + const prereleaseIndex = normalized.indexOf('-'); + const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized; + return { + parts: core.split('.').map((part) => { + const parsed = Number.parseInt(part || '0', 10); + return Number.isFinite(parsed) ? parsed : 0; + }), + prerelease: prereleaseIndex >= 0, + }; +}; + +const compareVersions = (left: unknown, right: unknown): number => { + const a = parseVersion(left); + const b = parseVersion(right); + for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) { + const difference = (a.parts[index] || 0) - (b.parts[index] || 0); + if (difference !== 0) return difference; + } + return a.prerelease === b.prerelease ? 0 : (a.prerelease ? -1 : 1); +}; + +const getCapability = (manager?: OpenCodeUpgradeManager): UpgradeCapability => { + if (!manager) return { supported: false, manager: null, reason: 'unavailable' }; + if (manager.getDebugInfo().mode !== 'managed') return { supported: false, manager: 'external', reason: 'external' }; + if (!manager.getApiUrl()) return { supported: false, manager: null, reason: 'unavailable' }; + return { supported: true, manager: 'opencode', reason: null }; +}; + +const getApiUrl = (manager?: OpenCodeUpgradeManager): string | null => { + const apiUrl = manager?.getApiUrl(); + return apiUrl ? `${apiUrl.replace(/\/+$/, '')}/` : null; +}; + +const fetchLatestVersion = async (): Promise => { + const results = await Promise.allSettled([ + fetch('https://registry.npmjs.org/opencode-ai/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) }) + .then(async (response) => { + if (!response.ok) throw new Error(`OpenCode npm registry responded with ${response.status}`); + const payload = await response.json() as { version?: unknown }; + return typeof payload.version === 'string' ? payload.version.trim().replace(/^v/, '') : ''; + }), + fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) }) + .then(async (response) => { + if (!response.ok) throw new Error(`OpenCode releases responded with ${response.status}`); + const payload = await response.json() as { tag_name?: unknown }; + return typeof payload.tag_name === 'string' ? payload.tag_name.trim().replace(/^v/, '') : ''; + }), + ]); + const versions = results.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []); + if (versions.length === 0) throw new Error('Failed to resolve latest OpenCode version'); + return versions.sort((left, right) => compareVersions(right, left))[0]; +}; + +export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise> => { + const upgrade = getCapability(manager); + const apiUrl = getApiUrl(manager); + if (!upgrade.supported || !apiUrl || !manager) return { available: false, currentVersion: null, latestVersion: null, upgrade }; + try { + const [healthResponse, latestVersion] = await Promise.all([ + fetch(new URL('global/health', apiUrl).toString(), { method: 'GET', headers: { Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() } }), + fetchLatestVersion(), + ]); + const health = await healthResponse.json().catch(() => null) as { version?: unknown; error?: unknown } | null; + if (!healthResponse.ok) { + const error = typeof health?.error === 'string' ? health.error : healthResponse.statusText || 'Failed to read OpenCode version'; + return { available: null, error, upgrade }; + } + const currentVersion = typeof health?.version === 'string' && health.version.trim() ? health.version.trim().replace(/^v/, '') : null; + return { available: currentVersion ? compareVersions(latestVersion, currentVersion) > 0 : null, currentVersion, latestVersion, upgrade }; + } catch (error) { + return { available: null, error: error instanceof Error ? error.message : String(error), upgrade }; + } +}; + +export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | undefined, target?: unknown): Promise => { + const upgrade = getCapability(manager); + const apiUrl = getApiUrl(manager); + if (!upgrade.supported || !apiUrl || !manager) { + return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_UNSUPPORTED', error: 'This OpenCode runtime cannot be upgraded by OpenChamber.' } }; + } + if (openCodeUpgradePromise) { + return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } }; + } + const targetVersion = typeof target === 'string' ? target.trim() : ''; + const operation = (async (): Promise => { + try { + const response = await fetch(new URL('global/upgrade', apiUrl).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() }, + body: JSON.stringify(targetVersion ? { target: targetVersion } : {}), + }); + const payload = await response.json().catch(() => null) as { error?: unknown } | null; + if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } }; + try { + await manager.restart(); + } catch (error) { + return { status: 500, body: { success: false, upgraded: true, error: error instanceof Error ? `OpenCode upgraded, but restart failed: ${error.message}` : 'OpenCode upgraded, but restart failed' } }; + } + return { status: 200, body: { ...(payload && typeof payload === 'object' ? payload : { success: true }), restarted: true } }; + } catch (error) { + return { status: 500, body: { success: false, error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode' } }; + } + })(); + openCodeUpgradePromise = operation; + try { + return await operation; + } finally { + if (openCodeUpgradePromise === operation) openCodeUpgradePromise = null; + } +}; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 1a1d1ad2..4c6085ec 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -993,6 +993,17 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R }); } + if (pathname === '/api/opencode/upgrade-status' && method === 'GET') { + const data = await sendBridgeMessage('api:opencode/upgrade-status'); + return jsonResponse(data); + } + + if (pathname === '/api/opencode/upgrade' && method === 'POST') { + const body = await extractJsonBody(input, init, method); + const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body); + return jsonResponse(result.body, result.status); + } + if (pathname === '/api/zen/models' && method === 'GET') { try { const data = await sendBridgeMessage('api:zen:models'); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c0b7f8eb..30c87a40 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -69,6 +69,7 @@ import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js'; import { createSettingsRuntime } from './lib/opencode/settings-runtime.js'; import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js'; +import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js'; import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; @@ -666,6 +667,7 @@ const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEn const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args); const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args); const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args); +const isBundledOpenCodeCliPath = (...args) => openCodeEnvRuntime.isBundledOpenCodeCliPath(...args); const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args); const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args); const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args); @@ -1070,6 +1072,18 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ }, }); +const getOpenCodeUpgradeCapability = () => { + const activeBinary = lastOpenCodeLaunchDiagnostics?.sourceBinary + || lastOpenCodeLaunchDiagnostics?.binary + || resolvedOpencodeBinary; + return resolveOpenCodeUpgradeCapability({ + isExternal: isExternalOpenCode, + hasManagedProcess: Boolean(openCodeProcess), + activeBinary, + isBundledBinary: isBundledOpenCodeCliPath, + }); +}; + const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args); const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args); @@ -1600,6 +1614,7 @@ async function main(options = {}) { readCustomThemesFromDisk, refreshOpenCodeAfterConfigChange, getOpenCodeResolutionSnapshot, + getOpenCodeUpgradeCapability, formatSettingsResponse, readSettingsFromDisk, readSettingsFromDiskMigrated, diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index e5041171..8773e001 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -26,6 +26,7 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring. - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration. - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics. +- `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode. - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup. - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow. - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch. @@ -76,8 +77,8 @@ This module provides OpenCode server integration utilities for the web server ru - `GET /api/config/settings` - `PUT /api/config/settings` - `GET /api/config/opencode-resolution` - - `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active) - - `GET /api/opencode/upgrade-status` + - `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active) + - `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability) - `POST /api/opencode/directory` - `GET /api/provider/:providerId/source` - `DELETE /api/provider/:providerId/auth` diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 15f6bb15..2382c706 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -301,6 +301,23 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; }; + const canonicalExecutablePath = (candidate) => { + if (typeof candidate !== 'string' || !candidate.trim()) return null; + try { + return fs.realpathSync.native(candidate.trim()); + } catch { + return path.resolve(candidate.trim()); + } + }; + + const isBundledOpenCodeCliPath = (candidate) => { + const canonicalCandidate = canonicalExecutablePath(candidate); + if (!canonicalCandidate) return false; + return bundledOpenCodeCliCandidates().some((bundledCandidate) => ( + canonicalExecutablePath(bundledCandidate) === canonicalCandidate + )); + }; + const bundledOpenCodeCliFallback = () => { const bundled = resolveBundledOpenCodeCliPath(); if (!bundled) return null; @@ -1164,6 +1181,7 @@ export const createOpenCodeEnvRuntime = (deps) => { applyOpencodeBinaryFromSettings, getLoginShellEnvSnapshot, resolveOpencodeCliPath, + isBundledOpenCodeCliPath, resolveManagedOpenCodeLaunchSpec, isExecutable, searchPathFor, diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index f4b52068..52ce6908 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -183,6 +183,18 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('bundled'); }); + it('recognizes the bundled CLI by canonical path', () => { + const bundledDir = createTempDir('openchamber-bundled-opencode-'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') fs.chmodSync(bundledBinary, 0o755); + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir; + const { runtime } = createRuntime({}); + + expect(runtime.isBundledOpenCodeCliPath(bundledBinary)).toBe(true); + expect(runtime.isBundledOpenCodeCliPath(path.join(bundledDir, 'other'))).toBe(false); + }); + it('keeps explicit OpenCode binary ahead of bundled CLI', () => { const bundledDir = createTempDir('openchamber-bundled-opencode-'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 4f0a391f..c0b55e99 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -86,6 +86,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { readCustomThemesFromDisk, refreshOpenCodeAfterConfigChange, getOpenCodeResolutionSnapshot, + getOpenCodeUpgradeCapability, formatSettingsResponse, readSettingsFromDisk, readSettingsFromDiskMigrated, @@ -121,6 +122,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { crypto, clientReloadDelayMs, getOpenCodeResolutionSnapshot, + getOpenCodeUpgradeCapability, formatSettingsResponse, readSettingsFromDisk, readSettingsFromDiskMigrated, diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 7a4f5cba..48d11e0f 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -241,6 +241,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + const sourceBinary = binary; let args = ['serve', '--hostname', hostname, '--port', String(port)]; let launchWrapperType = null; @@ -264,6 +265,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0; state.lastOpenCodeLaunchDiagnostics = { launchedAt: new Date().toISOString(), + sourceBinary, binary, args, cwd, diff --git a/packages/web/server/lib/opencode/routes-upgrade.test.js b/packages/web/server/lib/opencode/routes-upgrade.test.js new file mode 100644 index 00000000..cb18b833 --- /dev/null +++ b/packages/web/server/lib/opencode/routes-upgrade.test.js @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { registerOpenCodeRoutes } from './routes.js'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const createApp = (overrides = {}) => { + const app = express(); + app.use(express.json()); + const dependencies = { + getOpenCodeUpgradeCapability: () => ({ + supported: false, + manager: 'openchamber', + reason: 'bundled', + }), + buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`, + getOpenCodeAuthHeaders: () => ({}), + refreshOpenCodeAfterConfigChange: vi.fn(async () => {}), + ...overrides, + }; + registerOpenCodeRoutes(app, dependencies); + return { app, dependencies }; +}; + +describe('OpenCode upgrade routes', () => { + it('fails closed without contacting the bundled OpenCode updater', async () => { + globalThis.fetch = vi.fn(); + const { app } = createApp(); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(409, { + success: false, + code: 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER', + error: 'OpenCode is bundled with OpenChamber Desktop and updates with the app.', + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('reports bundled update ownership through the capability contract', async () => { + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.18.8' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + const { app } = createApp(); + + const response = await request(app) + .get('/api/opencode/upgrade-status') + .expect(200); + + expect(response.body).toEqual({ + available: false, + currentVersion: '1.18.8', + latestVersion: null, + upgrade: { + supported: false, + manager: 'openchamber', + reason: 'bundled', + }, + }); + }); + + it('serializes supported upgrades and preserves the in-flight lock', async () => { + let releaseUpgrade; + const upstreamResponse = new Promise((resolve) => { + releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + }); + globalThis.fetch = vi.fn(() => upstreamResponse); + const { app, dependencies } = createApp({ + getOpenCodeUpgradeCapability: () => ({ + supported: true, + manager: 'opencode', + reason: null, + }), + }); + + const first = request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(200, { + success: true, + version: '1.18.9', + restarted: true, + }) + .then((response) => response); + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(409, { + success: false, + code: 'OPENCODE_UPGRADE_IN_PROGRESS', + error: 'An OpenCode upgrade is already in progress.', + }); + + releaseUpgrade(); + await first; + expect(dependencies.refreshOpenCodeAfterConfigChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 08cdb0ac..b78b550c 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -8,6 +8,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => { crypto, clientReloadDelayMs, getOpenCodeResolutionSnapshot, + getOpenCodeUpgradeCapability, formatSettingsResponse, readSettingsFromDisk, readSettingsFromDiskMigrated, @@ -41,12 +42,6 @@ export const registerOpenCodeRoutes = (app, dependencies) => { return trimmed || null; }; - const isBundledOpenCodeBinaryActive = async () => { - const settings = await readSettingsFromDiskMigrated(); - const resolution = await getOpenCodeResolutionSnapshot(settings); - return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled'; - }; - const readOpenCodeCurrentVersion = async () => { const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), { method: 'GET', @@ -153,48 +148,84 @@ export const registerOpenCodeRoutes = (app, dependencies) => { } }); + let openCodeUpgradePromise = null; + app.post('/api/opencode/upgrade', async (req, res) => { try { - if (await isBundledOpenCodeBinaryActive()) { + const capability = getOpenCodeUpgradeCapability(); + if (!capability.supported) { return res.status(409).json({ success: false, - error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.', + code: capability.reason === 'bundled' + ? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER' + : 'OPENCODE_UPGRADE_UNSUPPORTED', + error: capability.reason === 'bundled' + ? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.' + : 'This OpenCode runtime cannot be upgraded by OpenChamber.', + }); + } + if (openCodeUpgradePromise) { + return res.status(409).json({ + success: false, + code: 'OPENCODE_UPGRADE_IN_PROGRESS', + error: 'An OpenCode upgrade is already in progress.', }); } const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0 ? req.body.target.trim() : undefined; - const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - body: JSON.stringify(target ? { target } : {}), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) { - return res.status(response.status).json({ - success: false, - error: payload?.error || response.statusText || 'Failed to upgrade OpenCode', + const upgradeOperation = (async () => { + const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + body: JSON.stringify(target ? { target } : {}), }); - } + const payload = await response.json().catch(() => null); + if (!response.ok) { + return { + status: response.status, + body: { + success: false, + error: payload?.error || response.statusText || 'Failed to upgrade OpenCode', + }, + }; + } + + try { + await refreshOpenCodeAfterConfigChange('OpenCode upgrade'); + } catch (restartError) { + return { + status: 500, + body: { + success: false, + upgraded: true, + error: restartError instanceof Error + ? `OpenCode upgraded, but restart failed: ${restartError.message}` + : 'OpenCode upgraded, but restart failed', + }, + }; + } + + return { + status: 200, + body: { ...(payload ?? { success: true }), restarted: true }, + }; + })(); + openCodeUpgradePromise = upgradeOperation; try { - await refreshOpenCodeAfterConfigChange('OpenCode upgrade'); - } catch (restartError) { - return res.status(500).json({ - success: false, - upgraded: true, - error: restartError instanceof Error - ? `OpenCode upgraded, but restart failed: ${restartError.message}` - : 'OpenCode upgraded, but restart failed', - }); + const result = await upgradeOperation; + return res.status(result.status).json(result.body); + } finally { + if (openCodeUpgradePromise === upgradeOperation) { + openCodeUpgradePromise = null; + } } - - return res.json({ ...(payload ?? { success: true }), restarted: true }); } catch (error) { console.error('Failed to upgrade OpenCode:', error); return res.status(500).json({ @@ -206,13 +237,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => { app.get('/api/opencode/upgrade-status', async (_req, res) => { try { - if (await isBundledOpenCodeBinaryActive()) { + const capability = getOpenCodeUpgradeCapability(); + if (!capability.supported) { const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null })); return res.json({ available: false, currentVersion: current.ok ? current.currentVersion : null, latestVersion: null, - source: 'bundled', + upgrade: capability, }); } @@ -239,6 +271,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => { available, currentVersion, latestVersion, + upgrade: capability, }); } catch (error) { return res.status(500).json({ diff --git a/packages/web/server/lib/opencode/upgrade-capability.js b/packages/web/server/lib/opencode/upgrade-capability.js new file mode 100644 index 00000000..80606dee --- /dev/null +++ b/packages/web/server/lib/opencode/upgrade-capability.js @@ -0,0 +1,36 @@ +export const resolveOpenCodeUpgradeCapability = ({ + isExternal, + hasManagedProcess, + activeBinary, + isBundledBinary, +}) => { + if (isExternal) { + return { + supported: false, + manager: 'external', + reason: 'external', + }; + } + + if (!hasManagedProcess || !activeBinary) { + return { + supported: false, + manager: null, + reason: 'unavailable', + }; + } + + if (isBundledBinary(activeBinary)) { + return { + supported: false, + manager: 'openchamber', + reason: 'bundled', + }; + } + + return { + supported: true, + manager: 'opencode', + reason: null, + }; +}; diff --git a/packages/web/server/lib/opencode/upgrade-capability.test.js b/packages/web/server/lib/opencode/upgrade-capability.test.js new file mode 100644 index 00000000..cde708f9 --- /dev/null +++ b/packages/web/server/lib/opencode/upgrade-capability.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveOpenCodeUpgradeCapability } from './upgrade-capability.js'; + +describe('OpenCode upgrade capability', () => { + it('assigns bundled binaries to the OpenChamber updater', () => { + const isBundledBinary = vi.fn(() => true); + + expect(resolveOpenCodeUpgradeCapability({ + isExternal: false, + hasManagedProcess: true, + activeBinary: '/Applications/OpenChamber.app/Contents/Resources/opencode-cli/opencode', + isBundledBinary, + })).toEqual({ + supported: false, + manager: 'openchamber', + reason: 'bundled', + }); + }); + + it('never upgrades external or unresolved runtimes', () => { + const isBundledBinary = vi.fn(() => false); + + expect(resolveOpenCodeUpgradeCapability({ + isExternal: true, + hasManagedProcess: false, + activeBinary: null, + isBundledBinary, + })).toEqual({ + supported: false, + manager: 'external', + reason: 'external', + }); + expect(resolveOpenCodeUpgradeCapability({ + isExternal: false, + hasManagedProcess: false, + activeBinary: '/usr/local/bin/opencode', + isBundledBinary, + })).toEqual({ + supported: false, + manager: null, + reason: 'unavailable', + }); + }); + + it('allows OpenCode to upgrade a managed non-bundled binary', () => { + expect(resolveOpenCodeUpgradeCapability({ + isExternal: false, + hasManagedProcess: true, + activeBinary: '/Users/alice/.opencode/bin/opencode', + isBundledBinary: () => false, + })).toEqual({ + supported: true, + manager: 'opencode', + reason: null, + }); + }); +});