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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
c0405d3fa4
commit
c88dd16d2a
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user