fix: prevent bundled OpenCode self-upgrades (#2525)

* fix: prevent bundled OpenCode self-upgrades

* feat(vscode): support OpenCode upgrades

* fix: refresh OpenCode update status on runtime switch

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-07-29 19:59:41 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent c0405d3fa4
commit c88dd16d2a
21 changed files with 620 additions and 84 deletions
+3
View File
@@ -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) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
</TooltipProvider>
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
</div>
+1 -1
View File
@@ -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:
@@ -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<unknown>).detail);
showUpdateAvailableToast(version);
};
let cancelled = false;
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
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<unknown>).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]);
@@ -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({
@@ -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();
+1 -36
View File
@@ -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<boolean> } | 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)
+4
View File
@@ -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.
@@ -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() };
}
@@ -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<typeof fetch>[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<typeof fetch>[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<string, string>).Authorization, 'Basic test');
});
test('serializes concurrent managed upgrades', async () => {
const { manager } = createManager();
let release: (response: Response) => void = () => {};
globalThis.fetch = (() => new Promise<Response>((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);
});
});
@@ -0,0 +1,129 @@
type UpgradeCapability = {
supported: boolean;
manager: 'opencode' | 'external' | null;
reason: 'external' | 'unavailable' | null;
};
export type OpenCodeUpgradeManager = {
getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
getDebugInfo(): { mode: 'managed' | 'external' };
restart(): Promise<void>;
};
type UpgradeResult = { status: number; body: Record<string, unknown> };
let openCodeUpgradePromise: Promise<UpgradeResult> | 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<string> => {
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<Record<string, unknown>> => {
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<UpgradeResult> => {
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<UpgradeResult> => {
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;
}
};
+11
View File
@@ -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');
+15
View File
@@ -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,
@@ -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`
@@ -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,
@@ -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');
@@ -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,
@@ -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,
@@ -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);
});
});
+69 -36
View File
@@ -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({
@@ -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,
};
};
@@ -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,
});
});
});