From a23f5e75456791886832c83044ca2d634c1da26f Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Fri, 1 May 2026 12:23:58 +0300 Subject: [PATCH] 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 * 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 Co-authored-by: Bohdan Triapitsyn --- .../ui/src/components/layout/VSCodeLayout.tsx | 36 +++ packages/ui/src/components/ui/AboutDialog.tsx | 35 +-- packages/ui/src/stores/useUpdateStore.ts | 13 +- .../vscode/src/AgentManagerPanelProvider.ts | 1 + packages/vscode/src/ChatViewProvider.ts | 1 + .../vscode/src/SessionEditorPanelProvider.ts | 1 + packages/vscode/src/bridge-system-runtime.ts | 2 +- packages/vscode/src/webviewHtml.ts | 6 + packages/vscode/webview/main.tsx | 7 +- packages/web/server/lib/package-manager.js | 11 +- .../web/server/lib/package-manager.test.js | 250 ++++++++++++++++++ 11 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 packages/web/server/lib/package-manager.test.js diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 53f604a5..8b643405 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -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 = diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index e960ab41..01a79ed2 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -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 = ({ 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(() => { diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 795efb3e..a2b825c7 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -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 { 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' }, diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 70900ebd..5bacfbbc 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -234,6 +234,7 @@ export class AgentManagerPanelProvider { initialStatus: this._cachedStatus, cliAvailable, panelType: 'agentManager', + extensionVersion: String(this._context.extension?.packageJSON?.version || ''), devServerUrl: this._webviewDevServerUrl, }); } diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index c30c6fda..7f2963d4 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -406,6 +406,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { workspaceFolder, initialStatus, cliAvailable, + extensionVersion: String(this._context.extension?.packageJSON?.version || ''), devServerUrl: this._webviewDevServerUrl, }); } diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index d6377027..e2e8dad3 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -273,6 +273,7 @@ export class SessionEditorPanelProvider { panelType: 'chat', initialSessionId: sessionId ?? undefined, viewMode: 'editor', + extensionVersion: String(this._context.extension?.packageJSON?.version || ''), devServerUrl: this._webviewDevServerUrl, }); } diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 4cc78dbe..76b453c0 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -310,7 +310,7 @@ export async function handleSystemBridgeMessage( const body = (payload && typeof payload === 'object' ? payload : {}) as Record; 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'; diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index 8fb61899..7b157b38 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -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'}, diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index e5a3cd47..1ab4cbd4 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -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', { diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 1dec6959..7e8f9195 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -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, diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js new file mode 100644 index 00000000..e8419e42 --- /dev/null +++ b/packages/web/server/lib/package-manager.test.js @@ -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); + }); +});