B1 — LIFECYCLE. The OpenCode class previously required the user to remember to run `opencode serve` in a terminal before opening their mail app, and again after every reboot; in practice that means the feature quietly stops existing. The desktop shell now owns it: finds the binary (OPENCODE_BIN, then ~/.opencode/bin — its installer's default, which is NOT on the PATH a macOS GUI app inherits, so PATH alone finds nothing for most users), starts it on a free port, restarts up to 3 times if it dies, and kills it on quit. Absent binary = the class simply stays unavailable, no error. B3 — SECURITY. opencode's own startup warns "OPENCODE_SERVER_PASSWORD is not set; server is unsecured" — without one, any local process can drive the agent. A per-launch password is now always generated (never persisted: the server dies with the app, so a durable secret would be pure liability) and handed to the standalone server alongside the base URL. The auth scheme is worth recording because it is NOT in opencode's own OpenAPI spec, which declares no securitySchemes at all: HTTP Basic with the username EXACTLY `opencode`. Verified against 1.18.14 by trying them — an empty username, an arbitrary one, Bearer, and every plausible custom header all 401 with the correct password. Pinned by a unit test that decodes the header, so a future refactor can't silently drop it. Verified live against a real password-protected server on 4097: authenticated discovery + prompt round-tripped, AND the same call with no password was rejected — proving the auth is real rather than decorative. Also removed now-stale guidance: the 503 no longer says "start one with opencode serve", because the app does that; it says to install the CLI. Gate: tsc clean, eslint clean, build clean, 2521/2522 tests. The one failure is lib/__tests__/jmap-client-resilience.test.ts's onConnectionChange timing flake — byte-identical to what is already running in prod (git diff vs origin/main for that file and lib/jmap/ is empty), pre-existing, and unrelated to anything here.
183 lines
7.2 KiB
TypeScript
183 lines
7.2 KiB
TypeScript
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
import { findOpencodeServer, parseModelRef, opencodeBaseUrls, opencodePrompt } from '../opencode';
|
|
|
|
/**
|
|
* The single most important behaviour under test is the SPA-catch-all trap:
|
|
* `opencode serve` answers 200 with the web UI's index.html for ANY unknown
|
|
* path, so a probe that trusts `res.ok` "verifies" endpoints that do not
|
|
* exist. That is not hypothetical — it is exactly how this integration was
|
|
* first built wrong (against an assumed OpenAI-compatible `/v1/models` that
|
|
* only ever returned HTML).
|
|
*/
|
|
|
|
const HTML_CATCHALL = {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
|
|
json: async () => {
|
|
throw new Error('not json');
|
|
},
|
|
};
|
|
|
|
function jsonResponse(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => body,
|
|
};
|
|
}
|
|
|
|
describe('parseModelRef', () => {
|
|
it('splits providerID/modelID, keeping slashes inside the model id', () => {
|
|
expect(parseModelRef('opencode/deepseek-v4-flash-free')).toEqual({
|
|
providerID: 'opencode',
|
|
modelID: 'deepseek-v4-flash-free',
|
|
});
|
|
// Real provider ids do contain slashes (e.g. openrouter's
|
|
// "anthropic/claude-..."), so only the FIRST slash separates.
|
|
expect(parseModelRef('openrouter/anthropic/claude-sonnet-4.5')).toEqual({
|
|
providerID: 'openrouter',
|
|
modelID: 'anthropic/claude-sonnet-4.5',
|
|
});
|
|
});
|
|
|
|
it('rejects malformed refs rather than guessing', () => {
|
|
expect(parseModelRef('noslash')).toBeNull();
|
|
expect(parseModelRef('/leading')).toBeNull();
|
|
expect(parseModelRef('trailing/')).toBeNull();
|
|
expect(parseModelRef('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodeBaseUrls', () => {
|
|
const original = process.env.OPENCODE_BASE_URL;
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.OPENCODE_BASE_URL;
|
|
else process.env.OPENCODE_BASE_URL = original;
|
|
});
|
|
|
|
it('refuses a non-loopback override — this class must never reach off-machine', () => {
|
|
process.env.OPENCODE_BASE_URL = 'https://evil.example.com';
|
|
const urls = opencodeBaseUrls();
|
|
expect(urls.some((u) => u.includes('evil.example.com'))).toBe(false);
|
|
expect(urls[0]).toMatch(/127\.0\.0\.1|localhost/);
|
|
});
|
|
|
|
it('honours a loopback override, trying it first', () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://127.0.0.1:9999/';
|
|
expect(opencodeBaseUrls()[0]).toBe('http://127.0.0.1:9999');
|
|
});
|
|
});
|
|
|
|
describe('auth', () => {
|
|
const originalFetch = global.fetch;
|
|
const originalPw = process.env.OPENCODE_SERVER_PASSWORD;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
if (originalPw === undefined) delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
else process.env.OPENCODE_SERVER_PASSWORD = originalPw;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('sends HTTP Basic with the username EXACTLY "opencode"', async () => {
|
|
// Verified against 1.18.14: an empty or arbitrary username 401s even with
|
|
// the right password, and no bearer/custom-header form works. Its OpenAPI
|
|
// spec declares no securitySchemes, so this is only knowable by trying it
|
|
// - which makes it exactly the kind of thing to pin with a test.
|
|
process.env.OPENCODE_SERVER_PASSWORD = 'hunter2';
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await findOpencodeServer();
|
|
const sentHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
const decoded = Buffer.from(sentHeaders.Authorization.replace('Basic ', ''), 'base64').toString();
|
|
expect(decoded).toBe('opencode:hunter2');
|
|
});
|
|
|
|
it('sends no auth header at all when no password is configured', async () => {
|
|
delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
await findOpencodeServer();
|
|
const sentHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
expect(sentHeaders.Authorization).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('findOpencodeServer', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('does NOT accept the web UI catch-all as a working API (200 + HTML)', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
|
|
it('parses the real /api/model shape into providerID/modelID refs', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
data: [
|
|
{ id: 'deepseek-v4-flash-free', providerID: 'opencode', name: 'DeepSeek V4 Flash Free' },
|
|
{ id: 'deepseek-chat', providerID: 'deepseek' },
|
|
{ id: '', providerID: 'broken' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const found = await findOpencodeServer();
|
|
expect(found?.models.map((m) => m.ref)).toEqual([
|
|
'opencode/deepseek-v4-flash-free',
|
|
'deepseek/deepseek-chat',
|
|
]);
|
|
expect(found?.models[0].label).toBe('DeepSeek V4 Flash Free (opencode)');
|
|
});
|
|
|
|
it('returns null when the server answers JSON with no models', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodePrompt', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns only the text parts — never the model\'s private reasoning', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
parts: [
|
|
{ type: 'step-start' },
|
|
{ type: 'reasoning', text: 'SECRET chain of thought that must not be shown' },
|
|
{ type: 'text', text: 'The visible answer.' },
|
|
{ type: 'step-finish' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'opencode', modelID: 'm' }, 'sys', 'q');
|
|
expect(result).toEqual({ ok: true, answer: 'The visible answer.' });
|
|
if (result.ok) expect(result.answer).not.toContain('SECRET');
|
|
});
|
|
|
|
it('fails cleanly when no session can be created', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('fails cleanly when the reply carries no text part', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(jsonResponse({ parts: [{ type: 'step-start' }, { type: 'reasoning', text: 'only thinking' }] })) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' });
|
|
});
|
|
});
|