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:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-07-29 19:59:41 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent c0405d3fa4
commit c88dd16d2a
21 changed files with 620 additions and 84 deletions
+15
View File
@@ -69,6 +69,7 @@ import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js
import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js';
import { createSettingsRuntime } from './lib/opencode/settings-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 { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
@@ -666,6 +667,7 @@ const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEn
const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args);
const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args);
const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args);
const isBundledOpenCodeCliPath = (...args) => openCodeEnvRuntime.isBundledOpenCodeCliPath(...args);
const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args);
const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...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 waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args);
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
@@ -1600,6 +1614,7 @@ async function main(options = {}) {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
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/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/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/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.
@@ -76,8 +77,8 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /api/config/settings`
- `PUT /api/config/settings`
- `GET /api/config/opencode-resolution`
- `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active)
- `GET /api/opencode/upgrade-status`
- `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` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
- `POST /api/opencode/directory`
- `GET /api/provider/:providerId/source`
- `DELETE /api/provider/:providerId/auth`
@@ -301,6 +301,23 @@ export const createOpenCodeEnvRuntime = (deps) => {
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 bundled = resolveBundledOpenCodeCliPath();
if (!bundled) return null;
@@ -1164,6 +1181,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
applyOpencodeBinaryFromSettings,
getLoginShellEnvSnapshot,
resolveOpencodeCliPath,
isBundledOpenCodeCliPath,
resolveManagedOpenCodeLaunchSpec,
isExecutable,
searchPathFor,
@@ -183,6 +183,18 @@ describe('OpenCode env runtime', () => {
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', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
@@ -86,6 +86,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -121,6 +122,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -241,6 +241,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const sourceBinary = binary;
let args = ['serve', '--hostname', hostname, '--port', String(port)];
let launchWrapperType = null;
@@ -264,6 +265,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
state.lastOpenCodeLaunchDiagnostics = {
launchedAt: new Date().toISOString(),
sourceBinary,
binary,
args,
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);
});
});
+69 -36
View File
@@ -8,6 +8,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
@@ -41,12 +42,6 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
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 healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
@@ -153,48 +148,84 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
let openCodeUpgradePromise = null;
app.post('/api/opencode/upgrade', async (req, res) => {
try {
if (await isBundledOpenCodeBinaryActive()) {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
return res.status(409).json({
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
? req.body.target.trim()
: undefined;
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
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 upgradeOperation = (async () => {
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
});
}
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 {
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
} catch (restartError) {
return res.status(500).json({
success: false,
upgraded: true,
error: restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${restartError.message}`
: 'OpenCode upgraded, but restart failed',
});
const result = await upgradeOperation;
return res.status(result.status).json(result.body);
} finally {
if (openCodeUpgradePromise === upgradeOperation) {
openCodeUpgradePromise = null;
}
}
return res.json({ ...(payload ?? { success: true }), restarted: true });
} catch (error) {
console.error('Failed to upgrade OpenCode:', error);
return res.status(500).json({
@@ -206,13 +237,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
app.get('/api/opencode/upgrade-status', async (_req, res) => {
try {
if (await isBundledOpenCodeBinaryActive()) {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
return res.json({
available: false,
currentVersion: current.ok ? current.currentVersion : null,
latestVersion: null,
source: 'bundled',
upgrade: capability,
});
}
@@ -239,6 +271,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
available,
currentVersion,
latestVersion,
upgrade: capability,
});
} catch (error) {
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,
});
});
});