/** * Brain — OS keychain adapter for OAuth tokens (Issue #235). * * Zero new dependencies — we shell out to the native secret-store CLI * on each platform, with a filesystem fallback at * `~/.mandu/credentials.json` (mode 0600) when no CLI is available. * * - macOS → `security find-generic-password` / `add-generic-password` * - Windows → `cmdkey` (read) + PowerShell `ConvertFrom-SecureString` * is too heavyweight; we use a narrow `cmdkey /add` + * `cmdkey /list` shell dance instead. Windows does NOT let * `cmdkey` read back the password, so we fall through to * the filesystem store on Windows and mark it 0600 — the * file lives under the user profile which is already * ACL'd to the current user. This matches the behavior of * `gh auth`, `npm`, and `docker-credential-wincred` * when run without their companion helper installed. * - Linux → `secret-tool store` / `secret-tool lookup` (libsecret). * * API surface is intentionally minimal: * * - `saveToken(provider, token)` → persist * - `loadToken(provider)` → returns token or null * - `deleteToken(provider)` → idempotent delete * - `listProviders()` → for `mandu brain status` * * The token type is arbitrary JSON (access_token + refresh_token + * expires_at) — `CredentialStore` serializes it as a JSON string. */ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; const SERVICE = "mandu-brain"; const FALLBACK_DIR = path.join(os.homedir(), ".mandu"); const FALLBACK_FILE = path.join(FALLBACK_DIR, "credentials.json"); /** * Stored token shape. `access_token` is required; the refresh fields * are optional because template-only adapters never write them. */ export interface StoredToken { access_token: string; refresh_token?: string; /** Unix-epoch seconds when the access_token expires. */ expires_at?: number; /** ISO timestamp of last successful use — for `mandu brain status`. */ last_used_at?: string; /** Cached default model chosen at login time (override via config). */ default_model?: string; /** Scope string returned by the OAuth provider. */ scope?: string; /** Provider identifier — for audit. */ provider?: "openai" | "anthropic"; } /** * Pluggable backend for the credential store. Tests inject an * in-memory backend; production auto-selects by OS. */ export interface CredentialBackend { readonly name: string; save(provider: string, token: StoredToken): Promise; load(provider: string): Promise; delete(provider: string): Promise; list(): Promise; } /* -------------------------------------------------------------------- */ /* Filesystem fallback — always available, always the safety net. */ /* -------------------------------------------------------------------- */ /** * Read the fallback credentials file. Missing file → empty object. * Corrupt file → empty object + a warning to stderr (so a bad edit * does not brick `mandu brain status`). */ async function readFallback(): Promise> { try { const raw = await fs.readFile(FALLBACK_FILE, "utf8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { return parsed as Record; } return {}; } catch (err) { if ( err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT" ) { return {}; } // Corrupt JSON — treat as empty, do not throw (Brain never blocks). return {}; } } async function writeFallback(all: Record): Promise { await fs.mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 }); const payload = JSON.stringify(all, null, 2); // Write atomically: write to tmp then rename, setting 0600 on the // final file. mode on `writeFile` is advisory on Windows, enforced // on POSIX. const tmp = `${FALLBACK_FILE}.${process.pid}.tmp`; await fs.writeFile(tmp, payload, { mode: 0o600 }); await fs.rename(tmp, FALLBACK_FILE); try { await fs.chmod(FALLBACK_FILE, 0o600); } catch { // chmod is best-effort on Windows. } } export const filesystemBackend: CredentialBackend = { name: "filesystem", async save(provider, token) { const all = await readFallback(); all[provider] = token; await writeFallback(all); }, async load(provider) { const all = await readFallback(); return all[provider] ?? null; }, async delete(provider) { const all = await readFallback(); if (provider in all) { delete all[provider]; await writeFallback(all); } }, async list() { const all = await readFallback(); return Object.keys(all); }, }; /* -------------------------------------------------------------------- */ /* Native backends — best-effort; fall through to filesystem on error. */ /* -------------------------------------------------------------------- */ /** * Shell out to a binary with a short stdin payload and return stdout. * Never throws — returns `null` on any non-zero exit. We never log the * stderr verbatim because some utilities echo the payload back on * failure. */ async function runWithStdin( cmd: string, args: string[], stdin: string, ): Promise<{ ok: boolean; stdout: string } | null> { try { const proc = Bun.spawn([cmd, ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe", }); await proc.stdin.write(stdin); await proc.stdin.end(); const [stdout, exitCode] = await Promise.all([ new Response(proc.stdout).text(), proc.exited, ]); return { ok: exitCode === 0, stdout }; } catch { return null; } } async function runCapture( cmd: string, args: string[], ): Promise<{ ok: boolean; stdout: string } | null> { try { const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe", }); const [stdout, exitCode] = await Promise.all([ new Response(proc.stdout).text(), proc.exited, ]); return { ok: exitCode === 0, stdout }; } catch { return null; } } /** * macOS Keychain backend — uses the `security` CLI. * * `security add-generic-password -U -a -s mandu-brain -w ` * to store; `find-generic-password -a -s mandu-brain -w` * to read the password back. Deletion uses `delete-generic-password`. */ export const macosBackend: CredentialBackend = { name: "macos-keychain", async save(provider, token) { const payload = JSON.stringify(token); const res = await runCapture("security", [ "add-generic-password", "-U", // update if exists "-a", provider, "-s", SERVICE, "-w", payload, ]); if (!res || !res.ok) { // Fall back to filesystem — never let a keychain hiccup block login. await filesystemBackend.save(provider, token); } }, async load(provider) { const res = await runCapture("security", [ "find-generic-password", "-a", provider, "-s", SERVICE, "-w", ]); if (!res || !res.ok) { return filesystemBackend.load(provider); } try { return JSON.parse(res.stdout.trim()) as StoredToken; } catch { return null; } }, async delete(provider) { await runCapture("security", [ "delete-generic-password", "-a", provider, "-s", SERVICE, ]); // Also scrub filesystem fallback, in case a previous save hit it. await filesystemBackend.delete(provider); }, async list() { // `security` has no "list by service" flag that returns accounts // without the full dump; use filesystem fallback as the source of // truth for the list, since saves always also go through keychain // (or fell back to the file). return filesystemBackend.list(); }, }; /** * Linux libsecret backend — `secret-tool`. */ export const linuxBackend: CredentialBackend = { name: "linux-secret-tool", async save(provider, token) { const payload = JSON.stringify(token); const res = await runWithStdin( "secret-tool", ["store", "--label=mandu-brain", "service", SERVICE, "account", provider], payload, ); if (!res || !res.ok) { await filesystemBackend.save(provider, token); } }, async load(provider) { const res = await runCapture("secret-tool", [ "lookup", "service", SERVICE, "account", provider, ]); if (!res || !res.ok || res.stdout.trim().length === 0) { return filesystemBackend.load(provider); } try { return JSON.parse(res.stdout.trim()) as StoredToken; } catch { return null; } }, async delete(provider) { await runCapture("secret-tool", [ "clear", "service", SERVICE, "account", provider, ]); await filesystemBackend.delete(provider); }, async list() { return filesystemBackend.list(); }, }; /** * Windows — `cmdkey` can store but cannot read back the password * portion without the Credential Manager API (which we would need a * native binding for). Use filesystem fallback directly; the file * lives under `%USERPROFILE%\.mandu\credentials.json` which is ACL'd * to the current user by default. This matches the behavior of * `gh auth login` when the Git Credential Manager is not installed. */ export const windowsBackend: CredentialBackend = { name: "windows-filesystem", save: filesystemBackend.save, load: filesystemBackend.load, delete: filesystemBackend.delete, list: filesystemBackend.list, }; /** * Pick the best backend for the current platform. Tests override this * by constructing a `CredentialStore` with an explicit backend. */ export function pickPlatformBackend(): CredentialBackend { if (process.platform === "darwin") return macosBackend; if (process.platform === "win32") return windowsBackend; if (process.platform === "linux") return linuxBackend; return filesystemBackend; } /* -------------------------------------------------------------------- */ /* Public store */ /* -------------------------------------------------------------------- */ export class CredentialStore { constructor(private backend: CredentialBackend = pickPlatformBackend()) {} /** Which backend is active — surfaced in `mandu brain status`. */ get backendName(): string { return this.backend.name; } async save(provider: string, token: StoredToken): Promise { await this.backend.save(provider, token); } async load(provider: string): Promise { return this.backend.load(provider); } async delete(provider: string): Promise { await this.backend.delete(provider); } async list(): Promise { return this.backend.list(); } /** * Touch the last_used_at timestamp on an existing token without * rotating the secret. Best-effort — swallow errors (telemetry). */ async touch(provider: string): Promise { try { const tok = await this.load(provider); if (tok) { tok.last_used_at = new Date().toISOString(); await this.save(provider, tok); } } catch { /* ignore */ } } /** * File path of the filesystem fallback — used by tests + the CLI * `mandu brain status` to point users at the 0600 file. Always * returns the same path regardless of backend. */ static fallbackPath(): string { return FALLBACK_FILE; } } /** Default singleton — production path. */ let defaultStore: CredentialStore | null = null; export function getCredentialStore(): CredentialStore { if (!defaultStore) { defaultStore = new CredentialStore(); } return defaultStore; } /** Override the singleton (test affordance). */ export function setCredentialStore(store: CredentialStore): void { defaultStore = store; }