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 { Toaster } from '@/components/ui/sonner';
|
||||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
|
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
@@ -107,6 +108,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
|||||||
<div className="h-full text-foreground bg-background">
|
<div className="h-full text-foreground bg-background">
|
||||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||||
<AgentManagerView />
|
<AgentManagerView />
|
||||||
|
<OpenCodeUpdateToast />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
|||||||
<div className="h-full text-foreground bg-background">
|
<div className="h-full text-foreground bg-background">
|
||||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||||
<VSCodeLayout />
|
<VSCodeLayout />
|
||||||
|
<OpenCodeUpdateToast />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
<ConfigUpdateOverlay />
|
<ConfigUpdateOverlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
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",
|
title: "typography-ui-label !font-medium !text-foreground",
|
||||||
description: "typography-meta !text-muted-foreground !mt-0.5",
|
description: "typography-meta !text-muted-foreground !mt-0.5",
|
||||||
actionButton:
|
actionButton:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import {
|
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;
|
let cancelled = false;
|
||||||
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
|
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
|
||||||
const checkForUpdate = async (attempt: number) => {
|
const checkForUpdate = async (attempt: number, runtimeKey = getRuntimeKey()) => {
|
||||||
try {
|
try {
|
||||||
const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } });
|
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');
|
if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed');
|
||||||
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null;
|
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null;
|
||||||
const version = resolveOpenCodeUpgradeStatusVersion(status);
|
const version = resolveOpenCodeUpgradeStatusVersion(status);
|
||||||
if (!cancelled && version) {
|
if (!cancelled && runtimeKey === getRuntimeKey() && version) {
|
||||||
showUpdateAvailableToast(version);
|
showUpdateAvailableToast(version);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const delay = CHECK_RETRY_DELAYS_MS[attempt];
|
const delay = CHECK_RETRY_DELAYS_MS[attempt];
|
||||||
if (!cancelled && delay !== undefined) {
|
if (!cancelled && runtimeKey === getRuntimeKey() && delay !== undefined) {
|
||||||
timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1); }, delay));
|
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) {
|
if (showOpenCodeUpdateNotifications) {
|
||||||
timeoutIds.push(setTimeout(() => { void checkForUpdate(0); }, INITIAL_CHECK_DELAY_MS));
|
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);
|
window.addEventListener('openchamber:opencode-update-available', onUpdateAvailable);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
for (const timeoutId of timeoutIds) clearTimeout(timeoutId);
|
for (const timeoutId of timeoutIds) clearTimeout(timeoutId);
|
||||||
|
unsubscribeRuntime();
|
||||||
window.removeEventListener('openchamber:opencode-update-available', onUpdateAvailable);
|
window.removeEventListener('openchamber:opencode-update-available', onUpdateAvailable);
|
||||||
};
|
};
|
||||||
}, [runUpgrade, showOpenCodeUpdateNotifications, t]);
|
}, [runUpgrade, showOpenCodeUpdateNotifications, t]);
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
|||||||
resolveOpenCodeUpgradeStatusVersion({
|
resolveOpenCodeUpgradeStatusVersion({
|
||||||
available: true,
|
available: true,
|
||||||
latestVersion: '1.16.0',
|
latestVersion: '1.16.0',
|
||||||
|
upgrade: { supported: true },
|
||||||
}),
|
}),
|
||||||
).toBe('1.16.0');
|
).toBe('1.16.0');
|
||||||
});
|
});
|
||||||
@@ -190,6 +191,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
|||||||
resolveOpenCodeUpgradeStatusVersion({
|
resolveOpenCodeUpgradeStatusVersion({
|
||||||
available: true,
|
available: true,
|
||||||
latestVersion: ' 1.16.0 ',
|
latestVersion: ' 1.16.0 ',
|
||||||
|
upgrade: { supported: true },
|
||||||
}),
|
}),
|
||||||
).toBe('1.16.0');
|
).toBe('1.16.0');
|
||||||
});
|
});
|
||||||
@@ -211,6 +213,22 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
|||||||
).toBe('');
|
).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', () => {
|
test('returns empty string when available is missing or null', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveOpenCodeUpgradeStatusVersion({
|
resolveOpenCodeUpgradeStatusVersion({
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export const resolveOpenCodeUpdateVersion = (detail: unknown): string => {
|
|||||||
export interface OpenCodeUpgradeStatusLike {
|
export interface OpenCodeUpgradeStatusLike {
|
||||||
readonly available?: boolean | null;
|
readonly available?: boolean | null;
|
||||||
readonly latestVersion?: string | null;
|
readonly latestVersion?: string | null;
|
||||||
|
readonly upgrade?: {
|
||||||
|
readonly supported?: boolean | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +93,7 @@ export const resolveOpenCodeUpgradeStatusVersion = (
|
|||||||
status: OpenCodeUpgradeStatusLike | null | undefined,
|
status: OpenCodeUpgradeStatusLike | null | undefined,
|
||||||
): string => {
|
): string => {
|
||||||
if (!status) return '';
|
if (!status) return '';
|
||||||
|
if (status.upgrade?.supported !== true) return '';
|
||||||
if (status.available !== true) return '';
|
if (status.available !== true) return '';
|
||||||
if (typeof status.latestVersion !== 'string') return '';
|
if (typeof status.latestVersion !== 'string') return '';
|
||||||
return status.latestVersion.trim();
|
return status.latestVersion.trim();
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
|||||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||||
import { runtimeFetch } from "@/lib/runtime-fetch"
|
|
||||||
import {
|
import {
|
||||||
EMPTY_SESSION_MESSAGE_LOAD_STATE,
|
EMPTY_SESSION_MESSAGE_LOAD_STATE,
|
||||||
SessionMessageLoader,
|
SessionMessageLoader,
|
||||||
@@ -1741,40 +1740,6 @@ const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => {
|
|||||||
window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload }))
|
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: {
|
export function SyncProvider(props: {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
directory: string
|
directory: string
|
||||||
@@ -2017,7 +1982,7 @@ export function SyncProvider(props: {
|
|||||||
? (payload.properties as { version: string }).version
|
? (payload.properties as { version: string }).version
|
||||||
: ""
|
: ""
|
||||||
if (version) {
|
if (version) {
|
||||||
dispatchOpenCodeUpdateAvailableUnlessBundled({ version })
|
dispatchOpenCodeUpdateAvailable({ version })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleEvent(directory, payload, childStores, routingIndex, runtimeKey, false, currentDirectoryRef.current, batch)
|
handleEvent(directory, payload, childStores, routingIndex, runtimeKey, false, currentDirectoryRef.current, batch)
|
||||||
|
|||||||
@@ -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.
|
- System/editor/provider/quota/notification/update-check message handlers.
|
||||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
- 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`).
|
- 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`
|
- `bridge-permission-auto-accept-runtime.ts`
|
||||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
- 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 { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||||
|
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
||||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||||
|
|
||||||
type BridgeMessageInput = {
|
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': {
|
case 'api:session-activity:get': {
|
||||||
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -993,6 +993,17 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
|
||||||
|
const data = await sendBridgeMessage('api:opencode/upgrade-status');
|
||||||
|
return jsonResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
|
||||||
|
const body = await extractJsonBody(input, init, method);
|
||||||
|
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
|
||||||
|
return jsonResponse(result.body, result.status);
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/zen/models' && method === 'GET') {
|
if (pathname === '/api/zen/models' && method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const data = await sendBridgeMessage('api:zen:models');
|
const data = await sendBridgeMessage('api:zen:models');
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js
|
|||||||
import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js';
|
import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js';
|
||||||
import { createSettingsRuntime } from './lib/opencode/settings-runtime.js';
|
import { createSettingsRuntime } from './lib/opencode/settings-runtime.js';
|
||||||
import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js';
|
import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js';
|
||||||
|
import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js';
|
||||||
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
|
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
|
||||||
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
|
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
|
||||||
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
||||||
@@ -666,6 +667,7 @@ const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEn
|
|||||||
const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args);
|
const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args);
|
||||||
const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args);
|
const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args);
|
||||||
const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args);
|
const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args);
|
||||||
|
const isBundledOpenCodeCliPath = (...args) => openCodeEnvRuntime.isBundledOpenCodeCliPath(...args);
|
||||||
const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args);
|
const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args);
|
||||||
const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args);
|
const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args);
|
||||||
const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args);
|
const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args);
|
||||||
@@ -1070,6 +1072,18 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getOpenCodeUpgradeCapability = () => {
|
||||||
|
const activeBinary = lastOpenCodeLaunchDiagnostics?.sourceBinary
|
||||||
|
|| lastOpenCodeLaunchDiagnostics?.binary
|
||||||
|
|| resolvedOpencodeBinary;
|
||||||
|
return resolveOpenCodeUpgradeCapability({
|
||||||
|
isExternal: isExternalOpenCode,
|
||||||
|
hasManagedProcess: Boolean(openCodeProcess),
|
||||||
|
activeBinary,
|
||||||
|
isBundledBinary: isBundledOpenCodeCliPath,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args);
|
const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args);
|
||||||
const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args);
|
const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args);
|
||||||
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
||||||
@@ -1600,6 +1614,7 @@ async function main(options = {}) {
|
|||||||
readCustomThemesFromDisk,
|
readCustomThemesFromDisk,
|
||||||
refreshOpenCodeAfterConfigChange,
|
refreshOpenCodeAfterConfigChange,
|
||||||
getOpenCodeResolutionSnapshot,
|
getOpenCodeResolutionSnapshot,
|
||||||
|
getOpenCodeUpgradeCapability,
|
||||||
formatSettingsResponse,
|
formatSettingsResponse,
|
||||||
readSettingsFromDisk,
|
readSettingsFromDisk,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
|
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
|
||||||
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
|
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
|
||||||
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
|
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
|
||||||
|
- `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
|
||||||
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
|
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
|
||||||
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
||||||
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
||||||
@@ -76,8 +77,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `GET /api/config/settings`
|
- `GET /api/config/settings`
|
||||||
- `PUT /api/config/settings`
|
- `PUT /api/config/settings`
|
||||||
- `GET /api/config/opencode-resolution`
|
- `GET /api/config/opencode-resolution`
|
||||||
- `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active)
|
- `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active)
|
||||||
- `GET /api/opencode/upgrade-status`
|
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||||
- `POST /api/opencode/directory`
|
- `POST /api/opencode/directory`
|
||||||
- `GET /api/provider/:providerId/source`
|
- `GET /api/provider/:providerId/source`
|
||||||
- `DELETE /api/provider/:providerId/auth`
|
- `DELETE /api/provider/:providerId/auth`
|
||||||
|
|||||||
@@ -301,6 +301,23 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canonicalExecutablePath = (candidate) => {
|
||||||
|
if (typeof candidate !== 'string' || !candidate.trim()) return null;
|
||||||
|
try {
|
||||||
|
return fs.realpathSync.native(candidate.trim());
|
||||||
|
} catch {
|
||||||
|
return path.resolve(candidate.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBundledOpenCodeCliPath = (candidate) => {
|
||||||
|
const canonicalCandidate = canonicalExecutablePath(candidate);
|
||||||
|
if (!canonicalCandidate) return false;
|
||||||
|
return bundledOpenCodeCliCandidates().some((bundledCandidate) => (
|
||||||
|
canonicalExecutablePath(bundledCandidate) === canonicalCandidate
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const bundledOpenCodeCliFallback = () => {
|
const bundledOpenCodeCliFallback = () => {
|
||||||
const bundled = resolveBundledOpenCodeCliPath();
|
const bundled = resolveBundledOpenCodeCliPath();
|
||||||
if (!bundled) return null;
|
if (!bundled) return null;
|
||||||
@@ -1164,6 +1181,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
applyOpencodeBinaryFromSettings,
|
applyOpencodeBinaryFromSettings,
|
||||||
getLoginShellEnvSnapshot,
|
getLoginShellEnvSnapshot,
|
||||||
resolveOpencodeCliPath,
|
resolveOpencodeCliPath,
|
||||||
|
isBundledOpenCodeCliPath,
|
||||||
resolveManagedOpenCodeLaunchSpec,
|
resolveManagedOpenCodeLaunchSpec,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
searchPathFor,
|
searchPathFor,
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ describe('OpenCode env runtime', () => {
|
|||||||
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
|
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes the bundled CLI by canonical path', () => {
|
||||||
|
const bundledDir = createTempDir('openchamber-bundled-opencode-');
|
||||||
|
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
||||||
|
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
|
||||||
|
if (process.platform !== 'win32') fs.chmodSync(bundledBinary, 0o755);
|
||||||
|
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
|
||||||
|
const { runtime } = createRuntime({});
|
||||||
|
|
||||||
|
expect(runtime.isBundledOpenCodeCliPath(bundledBinary)).toBe(true);
|
||||||
|
expect(runtime.isBundledOpenCodeCliPath(path.join(bundledDir, 'other'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
|
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
|
||||||
const bundledDir = createTempDir('openchamber-bundled-opencode-');
|
const bundledDir = createTempDir('openchamber-bundled-opencode-');
|
||||||
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|||||||
readCustomThemesFromDisk,
|
readCustomThemesFromDisk,
|
||||||
refreshOpenCodeAfterConfigChange,
|
refreshOpenCodeAfterConfigChange,
|
||||||
getOpenCodeResolutionSnapshot,
|
getOpenCodeResolutionSnapshot,
|
||||||
|
getOpenCodeUpgradeCapability,
|
||||||
formatSettingsResponse,
|
formatSettingsResponse,
|
||||||
readSettingsFromDisk,
|
readSettingsFromDisk,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
@@ -121,6 +122,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|||||||
crypto,
|
crypto,
|
||||||
clientReloadDelayMs,
|
clientReloadDelayMs,
|
||||||
getOpenCodeResolutionSnapshot,
|
getOpenCodeResolutionSnapshot,
|
||||||
|
getOpenCodeUpgradeCapability,
|
||||||
formatSettingsResponse,
|
formatSettingsResponse,
|
||||||
readSettingsFromDisk,
|
readSettingsFromDisk,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
|
|
||||||
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
|
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
|
||||||
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||||
|
const sourceBinary = binary;
|
||||||
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||||
let launchWrapperType = null;
|
let launchWrapperType = null;
|
||||||
|
|
||||||
@@ -264,6 +265,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
|
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
|
||||||
state.lastOpenCodeLaunchDiagnostics = {
|
state.lastOpenCodeLaunchDiagnostics = {
|
||||||
launchedAt: new Date().toISOString(),
|
launchedAt: new Date().toISOString(),
|
||||||
|
sourceBinary,
|
||||||
binary,
|
binary,
|
||||||
args,
|
args,
|
||||||
cwd,
|
cwd,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { registerOpenCodeRoutes } from './routes.js';
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createApp = (overrides = {}) => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const dependencies = {
|
||||||
|
getOpenCodeUpgradeCapability: () => ({
|
||||||
|
supported: false,
|
||||||
|
manager: 'openchamber',
|
||||||
|
reason: 'bundled',
|
||||||
|
}),
|
||||||
|
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
|
||||||
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
refreshOpenCodeAfterConfigChange: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
registerOpenCodeRoutes(app, dependencies);
|
||||||
|
return { app, dependencies };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OpenCode upgrade routes', () => {
|
||||||
|
it('fails closed without contacting the bundled OpenCode updater', async () => {
|
||||||
|
globalThis.fetch = vi.fn();
|
||||||
|
const { app } = createApp();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/opencode/upgrade')
|
||||||
|
.send({})
|
||||||
|
.expect(409, {
|
||||||
|
success: false,
|
||||||
|
code: 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER',
|
||||||
|
error: 'OpenCode is bundled with OpenChamber Desktop and updates with the app.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports bundled update ownership through the capability contract', async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.18.8' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
const { app } = createApp();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/opencode/upgrade-status')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
available: false,
|
||||||
|
currentVersion: '1.18.8',
|
||||||
|
latestVersion: null,
|
||||||
|
upgrade: {
|
||||||
|
supported: false,
|
||||||
|
manager: 'openchamber',
|
||||||
|
reason: 'bundled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes supported upgrades and preserves the in-flight lock', async () => {
|
||||||
|
let releaseUpgrade;
|
||||||
|
const upstreamResponse = new Promise((resolve) => {
|
||||||
|
releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
globalThis.fetch = vi.fn(() => upstreamResponse);
|
||||||
|
const { app, dependencies } = createApp({
|
||||||
|
getOpenCodeUpgradeCapability: () => ({
|
||||||
|
supported: true,
|
||||||
|
manager: 'opencode',
|
||||||
|
reason: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = request(app)
|
||||||
|
.post('/api/opencode/upgrade')
|
||||||
|
.send({})
|
||||||
|
.expect(200, {
|
||||||
|
success: true,
|
||||||
|
version: '1.18.9',
|
||||||
|
restarted: true,
|
||||||
|
})
|
||||||
|
.then((response) => response);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/opencode/upgrade')
|
||||||
|
.send({})
|
||||||
|
.expect(409, {
|
||||||
|
success: false,
|
||||||
|
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
|
||||||
|
error: 'An OpenCode upgrade is already in progress.',
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseUpgrade();
|
||||||
|
await first;
|
||||||
|
expect(dependencies.refreshOpenCodeAfterConfigChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
crypto,
|
crypto,
|
||||||
clientReloadDelayMs,
|
clientReloadDelayMs,
|
||||||
getOpenCodeResolutionSnapshot,
|
getOpenCodeResolutionSnapshot,
|
||||||
|
getOpenCodeUpgradeCapability,
|
||||||
formatSettingsResponse,
|
formatSettingsResponse,
|
||||||
readSettingsFromDisk,
|
readSettingsFromDisk,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
@@ -41,12 +42,6 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
return trimmed || null;
|
return trimmed || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBundledOpenCodeBinaryActive = async () => {
|
|
||||||
const settings = await readSettingsFromDiskMigrated();
|
|
||||||
const resolution = await getOpenCodeResolutionSnapshot(settings);
|
|
||||||
return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled';
|
|
||||||
};
|
|
||||||
|
|
||||||
const readOpenCodeCurrentVersion = async () => {
|
const readOpenCodeCurrentVersion = async () => {
|
||||||
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -153,48 +148,84 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let openCodeUpgradePromise = null;
|
||||||
|
|
||||||
app.post('/api/opencode/upgrade', async (req, res) => {
|
app.post('/api/opencode/upgrade', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (await isBundledOpenCodeBinaryActive()) {
|
const capability = getOpenCodeUpgradeCapability();
|
||||||
|
if (!capability.supported) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
|
code: capability.reason === 'bundled'
|
||||||
|
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
|
||||||
|
: 'OPENCODE_UPGRADE_UNSUPPORTED',
|
||||||
|
error: capability.reason === 'bundled'
|
||||||
|
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
|
||||||
|
: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (openCodeUpgradePromise) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
|
||||||
|
error: 'An OpenCode upgrade is already in progress.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
||||||
? req.body.target.trim()
|
? req.body.target.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
const upgradeOperation = (async () => {
|
||||||
method: 'POST',
|
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
Accept: 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getOpenCodeAuthHeaders(),
|
Accept: 'application/json',
|
||||||
},
|
...getOpenCodeAuthHeaders(),
|
||||||
body: JSON.stringify(target ? { target } : {}),
|
},
|
||||||
});
|
body: JSON.stringify(target ? { target } : {}),
|
||||||
const payload = await response.json().catch(() => null);
|
|
||||||
if (!response.ok) {
|
|
||||||
return res.status(response.status).json({
|
|
||||||
success: false,
|
|
||||||
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
|
||||||
});
|
});
|
||||||
}
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
||||||
|
} catch (restartError) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
upgraded: true,
|
||||||
|
error: restartError instanceof Error
|
||||||
|
? `OpenCode upgraded, but restart failed: ${restartError.message}`
|
||||||
|
: 'OpenCode upgraded, but restart failed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { ...(payload ?? { success: true }), restarted: true },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
openCodeUpgradePromise = upgradeOperation;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
const result = await upgradeOperation;
|
||||||
} catch (restartError) {
|
return res.status(result.status).json(result.body);
|
||||||
return res.status(500).json({
|
} finally {
|
||||||
success: false,
|
if (openCodeUpgradePromise === upgradeOperation) {
|
||||||
upgraded: true,
|
openCodeUpgradePromise = null;
|
||||||
error: restartError instanceof Error
|
}
|
||||||
? `OpenCode upgraded, but restart failed: ${restartError.message}`
|
|
||||||
: 'OpenCode upgraded, but restart failed',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ ...(payload ?? { success: true }), restarted: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upgrade OpenCode:', error);
|
console.error('Failed to upgrade OpenCode:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
@@ -206,13 +237,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
app.get('/api/opencode/upgrade-status', async (_req, res) => {
|
app.get('/api/opencode/upgrade-status', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
if (await isBundledOpenCodeBinaryActive()) {
|
const capability = getOpenCodeUpgradeCapability();
|
||||||
|
if (!capability.supported) {
|
||||||
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
|
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
|
||||||
return res.json({
|
return res.json({
|
||||||
available: false,
|
available: false,
|
||||||
currentVersion: current.ok ? current.currentVersion : null,
|
currentVersion: current.ok ? current.currentVersion : null,
|
||||||
latestVersion: null,
|
latestVersion: null,
|
||||||
source: 'bundled',
|
upgrade: capability,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +271,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
available,
|
available,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
|
upgrade: capability,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const resolveOpenCodeUpgradeCapability = ({
|
||||||
|
isExternal,
|
||||||
|
hasManagedProcess,
|
||||||
|
activeBinary,
|
||||||
|
isBundledBinary,
|
||||||
|
}) => {
|
||||||
|
if (isExternal) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
manager: 'external',
|
||||||
|
reason: 'external',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasManagedProcess || !activeBinary) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
manager: null,
|
||||||
|
reason: 'unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBundledBinary(activeBinary)) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
manager: 'openchamber',
|
||||||
|
reason: 'bundled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
manager: 'opencode',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resolveOpenCodeUpgradeCapability } from './upgrade-capability.js';
|
||||||
|
|
||||||
|
describe('OpenCode upgrade capability', () => {
|
||||||
|
it('assigns bundled binaries to the OpenChamber updater', () => {
|
||||||
|
const isBundledBinary = vi.fn(() => true);
|
||||||
|
|
||||||
|
expect(resolveOpenCodeUpgradeCapability({
|
||||||
|
isExternal: false,
|
||||||
|
hasManagedProcess: true,
|
||||||
|
activeBinary: '/Applications/OpenChamber.app/Contents/Resources/opencode-cli/opencode',
|
||||||
|
isBundledBinary,
|
||||||
|
})).toEqual({
|
||||||
|
supported: false,
|
||||||
|
manager: 'openchamber',
|
||||||
|
reason: 'bundled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never upgrades external or unresolved runtimes', () => {
|
||||||
|
const isBundledBinary = vi.fn(() => false);
|
||||||
|
|
||||||
|
expect(resolveOpenCodeUpgradeCapability({
|
||||||
|
isExternal: true,
|
||||||
|
hasManagedProcess: false,
|
||||||
|
activeBinary: null,
|
||||||
|
isBundledBinary,
|
||||||
|
})).toEqual({
|
||||||
|
supported: false,
|
||||||
|
manager: 'external',
|
||||||
|
reason: 'external',
|
||||||
|
});
|
||||||
|
expect(resolveOpenCodeUpgradeCapability({
|
||||||
|
isExternal: false,
|
||||||
|
hasManagedProcess: false,
|
||||||
|
activeBinary: '/usr/local/bin/opencode',
|
||||||
|
isBundledBinary,
|
||||||
|
})).toEqual({
|
||||||
|
supported: false,
|
||||||
|
manager: null,
|
||||||
|
reason: 'unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows OpenCode to upgrade a managed non-bundled binary', () => {
|
||||||
|
expect(resolveOpenCodeUpgradeCapability({
|
||||||
|
isExternal: false,
|
||||||
|
hasManagedProcess: true,
|
||||||
|
activeBinary: '/Users/alice/.opencode/bin/opencode',
|
||||||
|
isBundledBinary: () => false,
|
||||||
|
})).toEqual({
|
||||||
|
supported: true,
|
||||||
|
manager: 'opencode',
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user