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.
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OpencodeHandle | null> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
// 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", () => {
|
||||
|
||||
@@ -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<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(() => {
|
||||
|
||||
+21
-1
@@ -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<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, signal: controller.signal });
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user