Files
SRCmail/lib/ai/opencode.ts
T
Bernd Rodler bd778adf12 feat(electron): supervise a password-protected opencode server (B1+B3)
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.
2026-08-07 09:57:51 +02:00

185 lines
7.7 KiB
TypeScript

// Shared server-side helpers for the OpenCode AI class.
//
// OpenCode (github.com/sst/opencode) runs as a local headless server
// (`opencode serve`) — the same runtime Paperclip drives as an agent adapter.
// Here it is used only as a one-shot chat backend for the mail assistant, so
// its HTTP surface is enough and no subprocess needs spawning from this app.
//
// IT IS NOT OpenAI-COMPATIBLE, despite `/v1/models` and `/v1/chat/completions`
// both answering 200: opencode serves a web UI from the same port with a
// catch-all route, so ANY unknown path returns the SPA's index.html with a 200.
// Checking `res.ok` alone therefore "verifies" endpoints that do not exist —
// verified the hard way, by believing exactly that before reading a body.
// Every probe here validates the parsed SHAPE, never the status code alone.
//
// The real API (from the server's own /doc OpenAPI spec):
// GET /api/model -> { data: [{ id, providerID, name, ... }] }
// POST /session -> { id: "ses_..." }
// POST /session/{id}/message -> { info, parts: [{ type: 'text', text }, ...] }
//
// Address resolution is deliberately narrow: loopback only. This class exists
// to reach a runtime on the user's OWN machine — pointing it at a remote host
// would silently turn "local, no keys, nothing leaves the device" into the
// opposite, so a non-loopback OPENCODE_BASE_URL is refused rather than honoured.
const DEFAULT_BASE_URLS = ['http://127.0.0.1:4096', 'http://localhost:4096'];
const PROBE_TIMEOUT_MS = 2500;
const PROMPT_TIMEOUT_MS = 120_000;
function isLoopback(raw: string): boolean {
try {
const url = new URL(raw);
return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
} catch {
return false;
}
}
/** Candidate addresses, honouring an explicit OPENCODE_BASE_URL when it is
* loopback. `opencode serve` defaults to a RANDOM port (`--port 0`), so the
* conventional 4096 only finds a server deliberately started there; the env
* var is how someone on another port points us at it. */
export function opencodeBaseUrls(): string[] {
const configured = process.env.OPENCODE_BASE_URL?.trim();
if (configured) {
if (!isLoopback(configured)) {
console.error('[opencode] ignoring non-loopback OPENCODE_BASE_URL:', configured);
return DEFAULT_BASE_URLS;
}
return [configured.replace(/\/+$/, ''), ...DEFAULT_BASE_URLS];
}
return DEFAULT_BASE_URLS;
}
interface OpencodeModelListResponse {
data?: Array<{ id?: string; providerID?: string; name?: string }>;
}
export interface OpencodeModel {
/** "providerID/modelID" — the reference shown in the picker and stored in
* settings, matching how opencode itself names models on the CLI. */
ref: string;
providerID: string;
modelID: string;
label: string;
}
/** Splits the stored "providerID/modelID" reference back into the pair the
* message API wants. Returns null for anything malformed rather than
* guessing, so a corrupted setting surfaces as a clear error. */
export function parseModelRef(ref: string): { providerID: string; modelID: string } | null {
const slash = ref.indexOf('/');
if (slash <= 0 || slash === ref.length - 1) return null;
return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
}
/**
* Auth header for a password-protected server.
*
* HTTP Basic with the username EXACTLY `opencode` — verified against 1.18.14:
* an empty username, an arbitrary one, a Bearer token and every plausible
* custom header all 401 with the correct password. Its own OpenAPI spec
* declares no securitySchemes at all, so this is only knowable by trying it.
* Absent password = an unsecured server (the desktop shell always sets one;
* a hand-started `opencode serve` typically has none).
*/
function authHeaders(): Record<string, string> {
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (!password) return {};
return { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` };
}
async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<unknown | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
...init,
headers: { ...authHeaders(), ...(init.headers as Record<string, string> | undefined) },
signal: controller.signal,
});
if (!res.ok) return null;
// The SPA catch-all returns HTML with a 200 for unknown paths — see the
// module header. Content-type is what actually distinguishes a real API
// response from the web UI.
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) return null;
return await res.json();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** First reachable candidate that answers /api/model with a real model list. */
export async function findOpencodeServer(): Promise<{ baseUrl: string; models: OpencodeModel[] } | null> {
for (const baseUrl of opencodeBaseUrls()) {
const body = (await fetchJson(`${baseUrl}/api/model`, {}, PROBE_TIMEOUT_MS)) as OpencodeModelListResponse | null;
if (!body || !Array.isArray(body.data)) continue;
const models: OpencodeModel[] = body.data
.filter((m): m is { id: string; providerID: string; name?: string } =>
typeof m?.id === 'string' && !!m.id && typeof m?.providerID === 'string' && !!m.providerID)
.map((m) => ({
ref: `${m.providerID}/${m.id}`,
providerID: m.providerID,
modelID: m.id,
label: m.name ? `${m.name} (${m.providerID})` : `${m.providerID}/${m.id}`,
}));
if (models.length > 0) return { baseUrl, models };
}
return null;
}
interface OpencodeMessageResponse {
parts?: Array<{ type?: string; text?: string }>;
}
/**
* One prompt, one answer. Creates a throwaway session per question — this is
* a stateless "ask about my mail" box, not a running conversation, and a fresh
* session keeps one question's context from leaking into the next.
*
* `system` is passed as opencode's own system field rather than as a message
* part, so the retrieved-mail prompt keeps the same shape it has for every
* other provider class (see buildPrompt in lib/ai/local-client.ts).
*/
export async function opencodePrompt(
baseUrl: string,
model: { providerID: string; modelID: string },
system: string | undefined,
userText: string,
): Promise<{ ok: true; answer: string } | { ok: false; error: string }> {
const session = (await fetchJson(
`${baseUrl}/session`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' },
PROBE_TIMEOUT_MS,
)) as { id?: string } | null;
if (!session?.id) return { ok: false, error: 'OpenCode would not start a session' };
const body = (await fetchJson(
`${baseUrl}/session/${encodeURIComponent(session.id)}/message`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
...(system ? { system } : {}),
parts: [{ type: 'text', text: userText }],
}),
},
PROMPT_TIMEOUT_MS,
)) as OpencodeMessageResponse | null;
if (!body) return { ok: false, error: 'OpenCode returned no usable response' };
// A reply carries several parts (step-start / reasoning / text / step-finish).
// Only the `text` parts are the answer; `reasoning` is the model's private
// chain of thought and must not be shown as the reply.
const answer = (body.parts ?? [])
.filter((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim())
.map((p) => (p.text as string).trim())
.join('\n\n');
if (!answer) return { ok: false, error: 'OpenCode returned no message content' };
return { ok: true, answer };
}