fix(opencode): name the release when upgrading OpenCode

Since opencode 1.18.x, `POST /global/upgrade` requires a `target` semver in
the body. OpenChamber sent an empty object, so every "Update OpenCode" click
came back 400. The rejection arrives as `{name, data:{message}}`, which has
no `error` field, so the user was left with the bare status text: "Bad
Request".

Resolve the target from the latest release — the same lookup the upgrade
prompt already uses to decide there is anything to offer — and fail with an
explicit code when it cannot be resolved, rather than sending a body opencode
is guaranteed to reject. Read the upstream rejection message so a refused
upgrade explains itself.

The VS Code extension carries its own copy of this flow and had the same two
defects; both are fixed there.

fixes #3121
This commit is contained in:
Iuliia Ivashko
2026-08-28 18:48:27 +03:00
parent 6950e113f4
commit 20cda28cac
6 changed files with 242 additions and 22 deletions
+1
View File
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8).
- Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran).
- Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss).
- Updates: "Update OpenCode" no longer fails with a bare "Bad Request". OpenChamber now names the release to install, which recent OpenCode versions require, and when an update is refused the reason from OpenCode is shown instead of the HTTP status. This affects setups where OpenChamber runs an OpenCode you installed yourself; the desktop app bundles OpenCode and never offered the button.
- Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour).
- Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name.
- Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry.
+1
View File
@@ -3,6 +3,7 @@
- Picking a remote branch such as `origin/main` in the Git branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name.
- GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss).
- The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure.
- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install, which recent OpenCode versions require, and shows OpenCode's own reason when an update is refused.
## [1.21.0] - 2026-08-26
@@ -75,16 +75,72 @@ describe('VS Code OpenCode upgrades', () => {
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
});
test('names the latest release when the caller sends no target', async () => {
const { manager } = createManager();
let upgradeBody: unknown;
// SAFETY: the stub answers the only two call shapes this test exercises —
// a URL string and an init bag — which is all `fetch` is used with here.
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = String(input);
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.23' }));
if (url.includes('api.github.com')) return new Response(JSON.stringify({ tag_name: 'v1.18.23' }));
upgradeBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ success: true, version: '1.18.23' }));
}) as typeof fetch;
assert.equal((await upgradeManagedOpenCode(manager)).status, 200);
assert.deepEqual(upgradeBody, { target: '1.18.23' });
});
test('fails without calling the updater when the latest release cannot be resolved', async () => {
const { manager, getRestartCount } = createManager();
// SAFETY: the stub answers the only call shape this test exercises — a URL
// string — and fails loudly if the updater is reached at all.
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
if (String(input).endsWith('/global/upgrade')) throw new Error('the updater must not be called without a target');
return new Response('nope', { status: 503 });
}) as typeof fetch;
const result = await upgradeManagedOpenCode(manager);
assert.equal(result.status, 502);
assert.equal(result.body.code, 'OPENCODE_UPGRADE_TARGET_UNRESOLVED');
assert.equal(getRestartCount(), 0);
});
test('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => {
const { manager } = createManager();
// SAFETY: the stub ignores its arguments and answers every call with the
// rejection shape under test, so no call signature is misrepresented.
globalThis.fetch = (async () => new Response(
JSON.stringify({ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }),
{ status: 400 },
)) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
status: 400,
body: { success: false, error: 'Expected a semantic version' },
});
});
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;
let upgradeCalled: () => void = () => {};
const upgradeReached = new Promise<void>((resolve) => { upgradeCalled = resolve; });
globalThis.fetch = ((input: Parameters<typeof fetch>[0]) => {
if (!String(input).endsWith('/global/upgrade')) {
return Promise.resolve(new Response(JSON.stringify({ version: '1.18.9' })));
}
upgradeCalled();
return 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');
await upgradeReached;
release(new Response(JSON.stringify({ success: true })));
assert.equal((await first).status, 200);
});
@@ -77,6 +77,19 @@ const fetchLatestVersion = async (): Promise<string> => {
return versions.sort((left, right) => compareVersions(right, left))[0];
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readUpgradeErrorMessage = (
payload: { error?: unknown; message?: unknown; data?: { message?: unknown } } | null,
response: Response,
): string => {
for (const candidate of [payload?.error, payload?.data?.message, payload?.message]) {
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim();
}
return response.statusText || 'Failed to upgrade OpenCode';
};
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
@@ -107,16 +120,33 @@ export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | u
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 requestedTarget = typeof target === 'string' ? target.trim() : '';
const operation = (async (): Promise<UpgradeResult> => {
// The lookup runs inside the operation so the in-flight lock above already
// holds while the release version is resolved.
let targetVersion = requestedTarget;
if (!targetVersion) {
try {
targetVersion = await fetchLatestVersion();
} catch (error) {
return {
status: 502,
body: {
success: false,
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
error: `Could not determine which OpenCode version to install: ${error instanceof Error ? error.message : String(error)}`,
},
};
}
}
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 } : {}),
body: JSON.stringify({ 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' } };
const payload = await response.json().catch(() => null) as { error?: unknown; message?: unknown; data?: { message?: unknown } } | null;
if (!response.ok) return { status: response.status, body: { success: false, error: readUpgradeErrorMessage(payload, response) } };
try {
await manager.restart();
} catch (error) {
@@ -9,6 +9,13 @@ afterEach(() => {
globalThis.fetch = originalFetch;
});
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const supportedCapability = { supported: true, manager: 'opencode', reason: null };
const createApp = (overrides = {}) => {
const app = express();
app.use(express.json());
@@ -67,22 +74,99 @@ describe('OpenCode upgrade routes', () => {
});
});
it('names the latest release as the upgrade target when the caller sends none', async () => {
const requests = [];
globalThis.fetch = vi.fn(async (url, init) => {
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
if (String(url).includes('registry.npmjs.org')) {
return jsonResponse({ version: '1.18.23' });
}
if (String(url).includes('api.github.com')) {
return jsonResponse({ tag_name: 'v1.18.23' });
}
return jsonResponse({ success: true, version: '1.18.23' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(200, { success: true, version: '1.18.23', restarted: true });
const upgradeRequest = requests.find((entry) => entry.url.includes('/global/upgrade'));
expect(upgradeRequest?.body).toEqual({ target: '1.18.23' });
});
it('keeps an explicitly requested target instead of resolving the latest release', async () => {
const requests = [];
globalThis.fetch = vi.fn(async (url, init) => {
requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
return jsonResponse({ success: true, version: '1.18.20' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({ target: '1.18.20' })
.expect(200);
expect(requests).toHaveLength(1);
expect(requests[0].url).toContain('/global/upgrade');
expect(requests[0].body).toEqual({ target: '1.18.20' });
});
it('fails without calling the updater when the latest release cannot be resolved', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/global/upgrade')) {
throw new Error('the updater must not be called without a target');
}
return new Response('nope', { status: 503 });
});
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
const response = await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(502);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe('OPENCODE_UPGRADE_TARGET_UNRESOLVED');
expect(response.body.error).toContain('Could not determine which OpenCode version to install');
expect(dependencies.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
});
it('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (String(url).includes('/global/upgrade')) {
return jsonResponse(
{ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } },
400,
);
}
return jsonResponse({ version: '1.18.23' });
});
const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
await request(app)
.post('/api/opencode/upgrade')
.send({})
.expect(400, { success: false, error: 'Expected a semantic version' });
});
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' },
}));
releaseUpgrade = () => resolve(jsonResponse({ success: true, version: '1.18.9' }));
});
globalThis.fetch = vi.fn(() => upstreamResponse);
const { app, dependencies } = createApp({
getOpenCodeUpgradeCapability: () => ({
supported: true,
manager: 'opencode',
reason: null,
}),
const upgradeCalls = vi.fn();
globalThis.fetch = vi.fn((url) => {
if (String(url).includes('/global/upgrade')) {
upgradeCalls();
return upstreamResponse;
}
return Promise.resolve(jsonResponse({ version: '1.18.9' }));
});
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability });
const first = request(app)
.post('/api/opencode/upgrade')
@@ -94,7 +178,7 @@ describe('OpenCode upgrade routes', () => {
})
.then((response) => response);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(upgradeCalls).toHaveBeenCalledTimes(1);
});
await request(app)
+53 -5
View File
@@ -164,6 +164,41 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
return versions.sort((left, right) => compareVersions(right, left))[0];
};
// OpenCode's `/global/upgrade` requires an explicit semver target and rejects
// a bodyless call, so "update to the latest" has to name the version. The
// release lookup is the same one the upgrade-status check already uses to
// decide there is anything to offer.
const resolveOpenCodeUpgradeTarget = async (requestedTarget) => {
if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) {
return { resolved: true, target: requestedTarget.trim() };
}
try {
const latest = await fetchLatestOpenCodeVersion();
if (!latest) {
return { resolved: false, reason: 'The latest OpenCode version could not be determined.' };
}
return { resolved: true, target: latest };
} catch (error) {
return {
resolved: false,
reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.',
};
}
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readOpenCodeUpgradeErrorMessage = (payload, response) => {
const candidates = [payload?.error, payload?.data?.message, payload?.message];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return response.statusText || 'Failed to upgrade OpenCode';
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
@@ -218,10 +253,23 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
});
}
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
? req.body.target.trim()
: undefined;
const requestedTarget = req.body?.target;
// The target lookup reaches the network, so it runs inside the operation:
// the in-flight lock is taken synchronously above, and a second click
// cannot slip past while the release version is being resolved.
const upgradeOperation = (async () => {
const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget);
if (!targetResolution.resolved) {
return {
status: 502,
body: {
success: false,
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`,
},
};
}
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
@@ -229,7 +277,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
body: JSON.stringify({ target: targetResolution.target }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
@@ -237,7 +285,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
status: response.status,
body: {
success: false,
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
error: readOpenCodeUpgradeErrorMessage(payload, response),
},
};
}