diff --git a/app/api/ai/opencode/chat/route.ts b/app/api/ai/opencode/chat/route.ts index 5c97ac63..bde99901 100644 --- a/app/api/ai/opencode/chat/route.ts +++ b/app/api/ai/opencode/chat/route.ts @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { const found = await findOpencodeServer(); if (!found) { return NextResponse.json( - { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { 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 }, ); } diff --git a/app/api/ai/opencode/models/route.ts b/app/api/ai/opencode/models/route.ts index 7b5e668a..ae68b4e1 100644 --- a/app/api/ai/opencode/models/route.ts +++ b/app/api/ai/opencode/models/route.ts @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { // isn't running), and the client turns it into setup guidance rather than // an error banner. return NextResponse.json( - { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { 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 }, ); } diff --git a/electron/main.ts b/electron/main.ts index 855a6403..91fda357 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -160,6 +160,89 @@ function ensureSessionSecretFile(): string | null { } } +// ── OpenCode agent server ──────────────────────────────────────────────── +// +// The `opencode` AI class talks to a locally-running `opencode serve`. Left to +// the user that means "remember to start a terminal process before opening +// your mail app, and again after every reboot" - which is to say the feature +// quietly stops existing. So the desktop shell owns its lifecycle: start it if +// the binary is installed, restart it if it dies, kill it on quit. +// +// SECURITY: opencode itself warns "OPENCODE_SERVER_PASSWORD is not set; server +// is unsecured" - without one, any local process can drive the agent. We always +// generate one. Auth is HTTP Basic with the username EXACTLY `opencode` +// (verified against 1.18.14: an empty or arbitrary username 401s even with the +// right password, and no bearer/custom-header form works) - undocumented in its +// own OpenAPI spec, which declares no securitySchemes at all. + +let opencodeProcess: ChildProcess | null = null; +let opencodeRestarts = 0; +/** Set by stopOpencodeServer() so the exit handler can tell a deliberate + * shutdown from a crash and not fight the quit by respawning. */ +let opencodeStopping = false; +const OPENCODE_MAX_RESTARTS = 3; + +/** Where the binary lives. `~/.opencode/bin` is its own installer's default and + * is NOT on the PATH a GUI app inherits on macOS, so PATH alone finds nothing + * for most users. */ +function findOpencodeBinary(): string | null { + const explicit = process.env.OPENCODE_BIN?.trim(); + if (explicit && fs.existsSync(explicit)) return explicit; + const candidates = [ + path.join(app.getPath("home"), ".opencode", "bin", "opencode"), + "/opt/homebrew/bin/opencode", + "/usr/local/bin/opencode", + "/usr/bin/opencode", + ]; + return candidates.find((c) => fs.existsSync(c)) ?? null; +} + +interface OpencodeHandle { + baseUrl: string; + password: string; +} + +async function startOpencodeServer(): Promise { + const binary = findOpencodeBinary(); + if (!binary) return null; // not installed - the class simply stays unavailable + + const port = await getFreePort(); + // Per-launch, never persisted: the server dies with the app, so there is no + // value in a durable secret and every reason not to leave one on disk. + const password = randomBytes(24).toString("hex"); + const baseUrl = `http://127.0.0.1:${port}`; + + const spawnOnce = () => { + opencodeProcess = spawn(binary, ["serve", "--port", String(port), "--hostname", "127.0.0.1"], { + env: { ...process.env, OPENCODE_SERVER_PASSWORD: password }, + stdio: "ignore", + }); + opencodeProcess.on("exit", (code, signal) => { + opencodeProcess = null; + // A deliberate shutdown arrives as SIGTERM from stopOpencodeServer(). + if (opencodeStopping || signal === "SIGTERM") return; + if (opencodeRestarts >= OPENCODE_MAX_RESTARTS) { + console.error(`[opencode] gave up restarting after ${OPENCODE_MAX_RESTARTS} attempts (last code=${code})`); + return; + } + opencodeRestarts += 1; + console.error(`[opencode] server exited (code=${code}); restart ${opencodeRestarts}/${OPENCODE_MAX_RESTARTS}`); + setTimeout(spawnOnce, 1000 * opencodeRestarts); + }); + }; + spawnOnce(); + + return { baseUrl, password }; +} + +function stopOpencodeServer(): void { + opencodeStopping = true; + if (!opencodeProcess) return; + const proc = opencodeProcess; + opencodeProcess = null; + proc.kill("SIGTERM"); +} + /** * Locates the standalone server's entrypoint. Packaged builds ship it as an * extraResource (see electron-builder.config.js) because .next/standalone @@ -256,6 +339,9 @@ async function startStandaloneServer(): Promise { // using the OS keychain at all. The fd NUMBER below is not a secret; only // what travels over it is. const sessionSecretFile = ensureSessionSecretFile(); + // Started before the app server so its address can be handed over as env; + // null when opencode isn't installed, in which case the class stays absent. + const opencode = await startOpencodeServer(); serverProcess = spawn(process.execPath, [serverEntry], { env: { @@ -268,6 +354,9 @@ async function startStandaloneServer(): Promise { // SESSION_SECRET env var outranks any file in getSessionSecret()'s // resolution order regardless). ...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}), + ...(opencode + ? { OPENCODE_BASE_URL: opencode.baseUrl, OPENCODE_SERVER_PASSWORD: opencode.password } + : {}), ...process.env, ELECTRON_RUN_AS_NODE: "1", PORT: String(port), @@ -408,6 +497,7 @@ app.whenReady().then(() => { app.on("window-all-closed", () => { stopStandaloneServer(); + stopOpencodeServer(); if (process.platform !== "darwin") { app.quit(); } @@ -415,6 +505,7 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { stopStandaloneServer(); + stopOpencodeServer(); }); app.on("activate", () => { diff --git a/lib/ai/__tests__/opencode.test.ts b/lib/ai/__tests__/opencode.test.ts index 71355ce8..4ac676a0 100644 --- a/lib/ai/__tests__/opencode.test.ts +++ b/lib/ai/__tests__/opencode.test.ts @@ -68,6 +68,41 @@ describe('opencodeBaseUrls', () => { }); }); +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; + 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; + expect(sentHeaders.Authorization).toBeUndefined(); + }); +}); + describe('findOpencodeServer', () => { const originalFetch = global.fetch; afterEach(() => { diff --git a/lib/ai/opencode.ts b/lib/ai/opencode.ts index e25c3887..1546f1b9 100644 --- a/lib/ai/opencode.ts +++ b/lib/ai/opencode.ts @@ -73,11 +73,31 @@ export function parseModelRef(ref: string): { providerID: string; modelID: strin 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 { + 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 { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { ...init, signal: controller.signal }); + const res = await fetch(url, { + ...init, + headers: { ...authHeaders(), ...(init.headers as Record | 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