fix: cross-verify update API claims against npm registry (#1082)

* fix: cross-verify update API claims against npm registry

* Update packages/web/server/lib/package-manager.js

Signed-off-by: Islam Nofl <islamnofl.official@gmail.com>

* fix: show live server version in AboutDialog instead of stale build-time constant

* fix: add comment to empty catch block to satisfy lint no-empty rule

* fix: preserve live about dialog version in electron

* fix: scope update checks by runtime

---------

Signed-off-by: Islam Nofl <islamnofl.official@gmail.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Islam Nofl
2026-05-01 12:23:58 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent c924e85a29
commit a23f5e7545
11 changed files with 340 additions and 23 deletions
@@ -24,6 +24,7 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { UsageWindow } from '@/types';
@@ -58,6 +59,41 @@ type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
const { t } = useI18n();
const runtimeApis = useRuntimeAPIs();
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
React.useEffect(() => {
const initialDelayMs = 3000;
const defaultIntervalMs = 60 * 60 * 1000;
const minIntervalMs = 5 * 60 * 1000;
const maxIntervalMs = 24 * 60 * 60 * 1000;
let disposed = false;
let timer: number | null = null;
const clampIntervalMs = (seconds: number): number => {
const ms = Math.round(seconds * 1000);
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
};
const scheduleNext = (delayMs: number) => {
if (disposed) return;
timer = window.setTimeout(async () => {
const suggestedSec = await checkForUpdates();
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
? clampIntervalMs(suggestedSec)
: defaultIntervalMs;
scheduleNext(nextDelay);
}, delayMs);
};
scheduleNext(initialDelayMs);
return () => {
disposed = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [checkForUpdates]);
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
const configured =
+18 -17
View File
@@ -9,8 +9,7 @@ import { debugUtils } from '@/lib/debug';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
declare const __APP_VERSION__: string | undefined;
import { getDesktopAppVersion } from '@/lib/desktopNative';
interface AboutDialogProps {
open: boolean;
@@ -60,22 +59,24 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
React.useEffect(() => {
if (!open) return;
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
if (isDesktop) {
const fetchVersion = async () => {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const v = await getVersion();
setVersion(v);
} catch {
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
const fetchVersion = async () => {
try {
const response = await fetch('/api/system/info');
if (response.ok) {
const data = await response.json();
if (typeof data.openchamberVersion === 'string' && data.openchamberVersion.trim()) {
setVersion(data.openchamberVersion);
return;
}
}
};
fetchVersion();
} else {
setVersion(typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null);
}
} catch {
// Fall back to the native shell version when the web server is unavailable.
}
setVersion(await getDesktopAppVersion());
};
void fetchVersion();
}, [open]);
React.useEffect(() => {
+12 -1
View File
@@ -7,6 +7,7 @@ import {
downloadDesktopUpdate,
restartToApplyUpdate,
isDesktopLocalOriginActive,
isElectronShell,
isTauriShell,
isVSCodeRuntime,
isWebRuntime,
@@ -46,6 +47,12 @@ function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
}
function detectArch(): 'arm64' | 'x64' | 'unknown' {
const vscodeArch = typeof window !== 'undefined'
? (window as { __VSCODE_CONFIG__?: { arch?: string } }).__VSCODE_CONFIG__?.arch?.toLowerCase?.()
: undefined;
if (vscodeArch === 'arm64' || vscodeArch === 'aarch64') return 'arm64';
if (vscodeArch === 'x64' || vscodeArch === 'amd64' || vscodeArch === 'x86_64') return 'x64';
const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData : undefined;
const fromUAData = nav?.architecture?.toLowerCase?.();
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
@@ -75,7 +82,7 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
params.set('arch', detectArch());
params.set('platform', detectPlatform());
if (runtime === 'desktop') {
params.set('appType', 'desktop-tauri');
params.set('appType', isElectronShell() ? 'desktop-electron' : 'desktop-tauri');
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
return params;
}
@@ -94,7 +101,11 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
try {
const params = mapRuntimeParams(runtime);
const vscodeVersion = typeof window !== 'undefined'
? (window as { __VSCODE_CONFIG__?: { extensionVersion?: string } }).__VSCODE_CONFIG__?.extensionVersion
: undefined;
if (currentVersion) params.set('currentVersion', currentVersion);
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
@@ -234,6 +234,7 @@ export class AgentManagerPanelProvider {
initialStatus: this._cachedStatus,
cliAvailable,
panelType: 'agentManager',
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
devServerUrl: this._webviewDevServerUrl,
});
}
+1
View File
@@ -406,6 +406,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
workspaceFolder,
initialStatus,
cliAvailable,
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
devServerUrl: this._webviewDevServerUrl,
});
}
@@ -273,6 +273,7 @@ export class SessionEditorPanelProvider {
panelType: 'chat',
initialSessionId: sessionId ?? undefined,
viewMode: 'editor',
extensionVersion: String(this._context.extension?.packageJSON?.version || ''),
devServerUrl: this._webviewDevServerUrl,
});
}
+1 -1
View File
@@ -310,7 +310,7 @@ export async function handleSystemBridgeMessage(
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
? body.currentVersion.trim()
: 'unknown';
: String(ctx?.context?.extension?.packageJSON?.version || 'unknown');
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
? body.instanceMode.trim()
: 'local';
+6
View File
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import * as os from 'os';
import { getThemeKindName } from './theme';
import type { ConnectionStatus } from './opencode';
@@ -14,6 +15,7 @@ export interface WebviewHtmlOptions {
initialSessionId?: string;
viewMode?: 'sidebar' | 'editor';
devServerUrl?: string | null;
extensionVersion?: string;
}
const asCspToken = (value: string | null | undefined): string | null => {
@@ -50,6 +52,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
initialSessionId,
viewMode = 'sidebar',
devServerUrl,
extensionVersion = '',
} = options;
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
@@ -166,6 +169,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
theme: "${themeKind}",
connectionStatus: "${initialStatus}",
cliAvailable: ${cliAvailable},
extensionVersion: "${extensionVersion.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}",
platform: "${os.platform()}",
arch: "${os.arch()}",
panelType: "${panelType}",
viewMode: "${viewMode}",
initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'},
+5 -2
View File
@@ -23,6 +23,9 @@ declare global {
theme: string;
connectionStatus: string;
cliAvailable?: boolean;
extensionVersion?: string;
platform?: string;
arch?: string;
panelType?: PanelType;
viewMode?: 'sidebar' | 'editor';
initialSessionId?: string | null;
@@ -919,8 +922,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const currentVersion = url.searchParams.get('currentVersion') || undefined;
const instanceMode = url.searchParams.get('instanceMode') || 'local';
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
const platform = url.searchParams.get('platform') || undefined;
const arch = url.searchParams.get('arch') || undefined;
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
const data = await sendBridgeMessage('api:openchamber:update-check', {
+9 -2
View File
@@ -29,7 +29,7 @@ function getOpenChamberConfigDir() {
}
function sanitizeInstallScope(scope) {
if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
if (scope === 'desktop-electron' || scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
return 'web';
}
@@ -65,7 +65,7 @@ function mapArch(value) {
}
function normalizeAppType(value) {
if (value === 'web' || value === 'desktop-tauri' || value === 'vscode') return value;
if (value === 'web' || value === 'desktop-electron' || value === 'desktop-tauri' || value === 'vscode') return value;
return 'web';
}
@@ -701,10 +701,17 @@ export async function fetchChangelogNotes(fromVersion, toVersion) {
export async function checkForUpdates(options = {}) {
const currentVersion = options.currentVersion || getCurrentVersion();
const pm = detectPackageManager();
const appType = normalizeAppType(options.appType);
if (currentVersion !== 'unknown') {
const remote = await checkForUpdatesFromApi(currentVersion, options);
if (remote) {
if (remote.available && appType === 'web') {
const npmLatest = await getLatestVersion();
if (!npmLatest || compareVersions(npmLatest, remote.version) < 0) {
remote.available = false;
}
}
return {
...remote,
packageManager: pm,
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process to prevent real spawnSync calls that would hang in tests
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })),
}));
const { checkForUpdates } = await import('./package-manager.js');
/** Helper: create a fetch mock that routes by URL pattern */
function createFetchMock() {
const handlers = new Map();
const mock = vi.fn((url, options) => {
const urlStr = typeof url === 'string' ? url : url.toString();
for (const [pattern, response] of handlers) {
if (urlStr.includes(pattern)) {
return Promise.resolve(response);
}
}
return Promise.reject(new Error(`Unexpected fetch call: ${urlStr}`));
});
mock.when = (pattern, response) => {
handlers.set(pattern, response);
return mock;
};
return mock;
}
describe('checkForUpdates', () => {
let fetchMock;
beforeEach(() => {
fetchMock = createFetchMock();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// --- Scenario: API says update available, npm confirms ---
it('returns available=true when both API and npm confirm a newer version', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.10.0',
updateAvailable: true,
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
}),
})
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.10.0' },
}),
})
.when('raw.githubusercontent.com', {
ok: true,
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(true);
expect(result.version).toBe('1.10.0');
expect(result.currentVersion).toBe('1.9.10');
});
// --- Scenario (THE FIX): API says update available, npm does NOT have it ---
it('returns available=false when API claims update but npm has same version', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.10.0',
updateAvailable: true,
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
}),
})
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.9.10' },
}),
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
it('does not cross-check desktop update claims against npm', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.10.0',
updateAvailable: true,
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
}),
});
const result = await checkForUpdates({
appType: 'desktop-tauri',
currentVersion: '1.9.10',
});
expect(result.available).toBe(true);
expect(result.version).toBe('1.10.0');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('accepts electron desktop update claims without npm cross-checking', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.10.0',
updateAvailable: true,
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
}),
});
const result = await checkForUpdates({
appType: 'desktop-electron',
currentVersion: '1.9.10',
});
expect(result.available).toBe(true);
expect(result.version).toBe('1.10.0');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('returns available=false when API claims update but npm is behind', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.10.0',
updateAvailable: true,
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
}),
})
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.9.9' },
}),
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
// --- Scenario: API says no update, npm agrees ---
it('returns available=false when API says no update and versions match', async () => {
fetchMock.when('api.openchamber.dev', {
ok: true,
json: async () => ({
latestVersion: '1.9.10',
updateAvailable: false,
}),
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
// --- Scenario: API unreachable, npm fallback ---
it('returns available=true from npm fallback when API is unreachable and npm has newer version', async () => {
fetchMock
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.10.0' },
}),
})
.when('raw.githubusercontent.com', {
ok: true,
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(true);
expect(result.version).toBe('1.10.0');
});
it('returns available=false from npm fallback when API is unreachable and versions match', async () => {
fetchMock
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.9.10' },
}),
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
// --- Scenario: API returns null (bad response), npm fallback ---
it('returns available=false when API returns non-ok status and versions match on npm', async () => {
fetchMock
.when('api.openchamber.dev', {
ok: false,
status: 500,
json: async () => ({}),
})
.when('registry.npmjs.org', {
ok: true,
json: async () => ({
'dist-tags': { latest: '1.9.10' },
}),
});
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
// --- Scenario: Both API and npm are unreachable ---
it('returns available=false when both sources are unreachable', async () => {
fetchMock
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
.when('registry.npmjs.org', Promise.reject(new Error('Registry unreachable')));
const result = await checkForUpdates({ currentVersion: '1.9.10' });
expect(result.available).toBe(false);
});
});