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
@@ -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),
},
};
}