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)