Files
SRCmail/app/api/ai/opencode/models/route.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

44 lines
1.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { findOpencodeServer } from '@/lib/ai/opencode';
export const runtime = 'nodejs';
/**
* GET /api/ai/opencode/models — models a locally-running `opencode serve`
* exposes. Proxied rather than fetched directly by the renderer: the desktop
* shell's origin is a random localhost port that changes every launch, so a
* direct call would need opencode's CORS allowlist updated each time.
*
* Listing is not a billable action, so a valid session is enough — no seat
* check (matching /api/ai/server/models).
*/
export async function GET(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
await configManager.ensureLoaded();
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
}
const found = await findOpencodeServer();
if (!found) {
// 503 not 500: "nothing is listening" is a normal state (opencode simply
// isn't running), and the client turns it into setup guidance rather than
// an error banner.
return NextResponse.json(
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
{ status: 503 },
);
}
return NextResponse.json(
{ models: found.models.map((m) => ({ ref: m.ref, label: m.label })) },
{ headers: { 'Cache-Control': 'no-store' } },
);
}