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,
+ });
+ });
+});