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
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user