/** * `omp auth-broker` command handlers. * * Sub-verbs: * - `serve [--bind=…]` — boots the broker against the local SQLite store. * - `token` / `token --regenerate` — manages the bearer token file. * - `login [--via=user@host]` — logs into a provider locally, or * via SSH tunnel into a remote broker host. * - `import ` — imports CLIProxyAPI-style JSON credentials into * the local SQLite store (typical use: `import ~/.cliproxy/auth`). * - `migrate --from-local [--include-env] [--include-oauth] [--dry-run]` — * uploads local SQLite + env API keys to the broker, skipping anything * the broker already has. * - `status` — health-pings the configured remote broker. */ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { AuthBrokerClient, type AuthCredential, AuthStorage, type CredentialDisabledEvent, DEFAULT_AUTH_BROKER_BIND, getEnvApiKey, getOAuthProviders, listProvidersWithEnvKey, type OAuthCredential, type OAuthProvider, SqliteAuthCredentialStore, startAuthBroker, } from "@oh-my-pi/pi-ai"; import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { resolveAuthBrokerConfig } from "../session/auth-broker-config"; export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate"; export interface AuthBrokerCommandArgs { action: AuthBrokerAction; flags: { json?: boolean; bind?: string; regenerate?: boolean; via?: string; provider?: string; dryRun?: boolean; /** `login`/`logout`: provider id. `import`: filesystem path. */ source?: string; /** `import`: keep credentials whose JSON had `disabled: true`. */ includeDisabled?: boolean; /** `migrate`: also upload local OAuth (default: api_key only, since OAuth is via cliproxy import). */ includeOauth?: boolean; /** `migrate`: also capture env-var API keys for providers not yet on broker. */ includeEnv?: boolean; /** `migrate`: required `--from-local` source. Reserved for future sources. */ fromLocal?: boolean; }; } const ACTIONS: readonly AuthBrokerAction[] = ["serve", "token", "login", "logout", "import", "migrate", "status"]; /** Callback ports baked from the per-provider OAuth flow modules. */ const CALLBACK_PORTS: Record = { anthropic: 54545, "openai-codex": 1455, "google-gemini-cli": 8085, "google-antigravity": 51121, "gitlab-duo": 8080, }; function getTokenFilePath(): string { return path.join(getConfigRootDir(), "auth-broker.token"); } async function readToken(): Promise { try { const raw = await Bun.file(getTokenFilePath()).text(); const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : null; } catch (err) { if (isEnoent(err)) return null; throw err; } } async function writeToken(token: string): Promise { const file = getTokenFilePath(); await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); await Bun.write(file, token); try { await fs.chmod(file, 0o600); } catch { // Best-effort (e.g. Windows). } } function generateToken(): string { return crypto.randomBytes(32).toString("base64url"); } async function ensureToken(): Promise { const existing = await readToken(); if (existing) return existing; const token = generateToken(); await writeToken(token); return token; } async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise { // The broker is a long-running headless service: route structured logs to // stdout so a process supervisor (pm2, journald, k8s) captures them, and // skip the rotating ~/.omp/logs/ file the TUI default would have used. logger.setTransports({ console: true, file: false }); const bind = flags.bind ?? DEFAULT_AUTH_BROKER_BIND; const token = await ensureToken(); const dbPath = getAgentDbPath(); const store = await SqliteAuthCredentialStore.open(dbPath); const storage = new AuthStorage(store); await storage.reload(); const handle = startAuthBroker({ storage, bind, bearerTokens: [token], version: VERSION, }); logger.info("auth-broker listening", { url: handle.url }); logger.info("auth-broker bearer token loaded", { path: getTokenFilePath(), mode: "0600" }); const credentialDisabledUnsub = storage.onCredentialDisabled((event: CredentialDisabledEvent) => { logger.warn("auth-broker credential disabled", { ...event }); }); const shutdown = async (signal: NodeJS.Signals): Promise => { logger.info("auth-broker shutting down", { signal }); credentialDisabledUnsub(); await handle.close(); storage.close(); process.exit(0); }; process.once("SIGINT", () => void shutdown("SIGINT")); process.once("SIGTERM", () => void shutdown("SIGTERM")); // Block forever; lifecycle is signal-driven. await new Promise(() => {}); } async function runToken(flags: AuthBrokerCommandArgs["flags"]): Promise { if (flags.regenerate) { const next = generateToken(); await writeToken(next); if (flags.json) { process.stdout.write(`${JSON.stringify({ token: next, path: getTokenFilePath() })}\n`); } else { process.stdout.write(`${next}\n`); } return; } const token = await ensureToken(); if (flags.json) { process.stdout.write(`${JSON.stringify({ token, path: getTokenFilePath() })}\n`); } else { process.stdout.write(`${token}\n`); } } async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise { const providerArg = flags.provider; if (!providerArg) { throw new Error("Usage: omp auth-broker login [--via=user@host]"); } const oauthProviders = new Set(getOAuthProviders().map(p => p.id)); if (!oauthProviders.has(providerArg)) { throw new Error(`Unknown OAuth provider '${providerArg}'. Known: ${[...oauthProviders].sort().join(", ")}`); } if (flags.via) { await runRemoteLogin(providerArg, flags.via, flags.dryRun ?? false); return; } await runLocalLogin(providerArg as OAuthProvider); } async function runLocalLogin(provider: OAuthProvider): Promise { // Spawn the pi-ai CLI in-process — it handles the per-provider OAuth dance // and persists into the same SQLite store the broker uses. const piAiCli = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-ai/cli")); const proc = Bun.spawn({ cmd: [process.execPath, piAiCli, "login", provider], stdin: "inherit", stdout: "inherit", stderr: "inherit", }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`pi-ai login exited with code ${exitCode}`); } } async function runRemoteLogin(provider: string, via: string, dryRun: boolean): Promise { const port = CALLBACK_PORTS[provider]; if (port === undefined) { throw new Error( `No known OAuth callback port for '${provider}'. Use device-code flow on the broker host directly.`, ); } const sshArgs = [ "-L", `${port}:127.0.0.1:${port}`, "-o", "ExitOnForwardFailure=yes", via, `${APP_NAME} auth-broker login ${provider}`, ]; if (dryRun) { process.stdout.write(`ssh ${sshArgs.map(a => (a.includes(" ") ? `'${a}'` : a)).join(" ")}\n`); return; } const sshBin = $which("ssh"); if (!sshBin) { throw new Error("ssh binary not found in PATH"); } const proc = Bun.spawn({ cmd: [sshBin, ...sshArgs], stdin: "inherit", stdout: "inherit", stderr: "inherit", }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`ssh exited with code ${exitCode}`); } } async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise { const providerArg = flags.provider; if (!providerArg) { throw new Error("Usage: omp auth-broker logout "); } const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); try { store.deleteAuthCredentialsForProvider(providerArg, "logged out by user"); process.stdout.write(`Logged out of ${providerArg}\n`); } finally { store.close(); } } // ─── CLIProxyAPI import ───────────────────────────────────────────────── /** * Maps the `type` field of a CLIProxyAPI credential JSON to the omp provider id. * The filename also encodes the type (e.g. `claude-foo@bar.json`), but the * in-file `type` is authoritative — we only fall back to filename if absent. */ const CLIPROXY_TYPE_TO_PROVIDER: Record = { claude: "anthropic", codex: "openai-codex", gemini: "google-gemini-cli", antigravity: "google-antigravity", "gemini-cli": "google-gemini-cli", }; interface CliProxyCredentialJson { type?: string; access_token?: string; refresh_token?: string; id_token?: string; expired?: string; last_refresh?: string; email?: string; account_id?: string; disabled?: boolean; } interface ImportPlanEntry { sourceFile: string; provider: string; email: string | null; accountId: string | null; expiresAt: number; disabled: boolean; credential: OAuthCredential; } function resolveCliProxyProvider(json: CliProxyCredentialJson, filename: string, overrideId?: string): string | null { if (overrideId && overrideId.length > 0) return overrideId; const typeField = json.type?.trim().toLowerCase(); if (typeField && CLIPROXY_TYPE_TO_PROVIDER[typeField]) return CLIPROXY_TYPE_TO_PROVIDER[typeField]; // Fall back to filename prefix: `-.json` const base = path.basename(filename, ".json").toLowerCase(); for (const prefix in CLIPROXY_TYPE_TO_PROVIDER) { const providerId = CLIPROXY_TYPE_TO_PROVIDER[prefix]; if (base.startsWith(`${prefix}-`) || base === prefix) return providerId; } return null; } function parseCliProxyExpiry(raw: string | undefined): number | null { if (!raw) return null; // CLIProxyAPI writes RFC3339-ish dates. `Date.parse` handles both `Z` and offsets. const ms = Date.parse(raw); if (!Number.isFinite(ms)) return null; return ms; } async function collectImportSources(target: string): Promise { const stat = await fs.stat(target); if (stat.isFile()) return [target]; if (!stat.isDirectory()) { throw new Error(`Import source is neither file nor directory: ${target}`); } const entries = await fs.readdir(target, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith(".json")) continue; files.push(path.join(target, entry.name)); } files.sort(); return files; } async function loadImportPlan( target: string, overrideProvider: string | undefined, includeDisabled: boolean, ): Promise<{ entries: ImportPlanEntry[]; skipped: Array<{ file: string; reason: string }> }> { const files = await collectImportSources(target); const entries: ImportPlanEntry[] = []; const skipped: Array<{ file: string; reason: string }> = []; for (const file of files) { let json: CliProxyCredentialJson; try { json = (await Bun.file(file).json()) as CliProxyCredentialJson; } catch (err) { skipped.push({ file, reason: `unreadable JSON: ${String(err)}` }); continue; } if (json.disabled === true && !includeDisabled) { skipped.push({ file, reason: "credential marked disabled (use --include-disabled to import anyway)" }); continue; } const provider = resolveCliProxyProvider(json, file, overrideProvider); if (!provider) { skipped.push({ file, reason: `cannot determine omp provider from type=${json.type ?? "?"} (pass --provider to override)`, }); continue; } if (!json.access_token || !json.refresh_token) { skipped.push({ file, reason: "missing access_token or refresh_token" }); continue; } const expiresAt = parseCliProxyExpiry(json.expired); if (expiresAt === null) { skipped.push({ file, reason: `cannot parse expired=${json.expired ?? "?"}` }); continue; } const email = typeof json.email === "string" && json.email.length > 0 ? json.email : null; const accountId = typeof json.account_id === "string" && json.account_id.length > 0 ? json.account_id : null; const credential: OAuthCredential = { type: "oauth", access: json.access_token, refresh: json.refresh_token, expires: expiresAt, ...(email !== null ? { email } : {}), ...(accountId !== null ? { accountId } : {}), }; entries.push({ sourceFile: file, provider, email, accountId, expiresAt, disabled: json.disabled === true, credential, }); } return { entries, skipped }; } function describeImportEntry(entry: ImportPlanEntry): string { const ident = entry.email ?? entry.accountId ?? "(no identity)"; const stale = entry.expiresAt < Date.now() ? " [expired]" : ""; const disabled = entry.disabled ? " [disabled]" : ""; return `${entry.provider}: ${ident}${stale}${disabled} from ${entry.sourceFile}`; } async function runImport(flags: AuthBrokerCommandArgs["flags"]): Promise { const target = flags.source; if (!target) { throw new Error("Usage: omp auth-broker import [--provider=] [--include-disabled] [--dry-run]"); } const resolvedTarget = path.resolve(target.startsWith("~") ? target.replace(/^~/, os.homedir()) : target); const { entries, skipped } = await loadImportPlan(resolvedTarget, flags.provider, flags.includeDisabled === true); if (flags.json) { process.stdout.write( `${JSON.stringify({ dryRun: flags.dryRun === true, imported: flags.dryRun ? [] : entries.map(e => ({ provider: e.provider, email: e.email, file: e.sourceFile })), plan: entries.map(e => ({ provider: e.provider, email: e.email, accountId: e.accountId, expiresAt: e.expiresAt, disabled: e.disabled, file: e.sourceFile, })), skipped, })}\n`, ); } if (!flags.json) { for (const skip of skipped) { process.stdout.write(`${chalk.yellow("skip")} ${skip.file}: ${skip.reason}\n`); } } if (entries.length === 0) { if (!flags.json) process.stdout.write(`No importable credentials in ${resolvedTarget}.\n`); return; } if (flags.dryRun === true) { if (!flags.json) { process.stdout.write(`Dry run — would import ${entries.length} credential(s):\n`); for (const entry of entries) process.stdout.write(` ${describeImportEntry(entry)}\n`); } return; } const brokerConfig = await resolveAuthBrokerConfig(); if (brokerConfig) { const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token }); for (const entry of entries) { try { await client.uploadCredential(entry.provider, entry.credential); if (!flags.json) { process.stdout.write(`${chalk.green("uploaded")} ${describeImportEntry(entry)} → ${brokerConfig.url}\n`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (flags.json) { process.stdout.write(`${JSON.stringify({ error: message, file: entry.sourceFile })}\n`); } else { process.stdout.write(`${chalk.red("failed")} ${describeImportEntry(entry)}: ${message}\n`); } process.exitCode = 1; } } return; } const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); try { for (const entry of entries) { store.upsertAuthCredentialForProvider(entry.provider, entry.credential); if (!flags.json) process.stdout.write(`${chalk.green("imported")} ${describeImportEntry(entry)}\n`); } } finally { store.close(); } } // ─── Migrate: local SQLite + env → broker ────────────────────────────── interface MigratePlanEntry { source: "local-sqlite" | "env"; provider: string; credential: AuthCredential; identity: string; } interface MigrateSkip { source: "local-sqlite" | "env"; provider: string; identity: string; reason: string; } function credentialIdentity(provider: string, credential: AuthCredential): string { if (credential.type === "api_key") return "(api key)"; return credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`; } /** * Build the set of "identities already on the broker" so re-runs are idempotent. * For OAuth, identity = email|accountId|projectId. For api_key, we collapse * to a single marker per provider (broker has no concept of "multiple api keys * per provider with different identities"; upsert would coalesce them). */ function indexBrokerSnapshot(snapshot: { credentials: Array<{ provider: string; credential: { type: string; email?: string; accountId?: string; projectId?: string }; }>; }): Map> { const out = new Map>(); for (const entry of snapshot.credentials) { const ids = out.get(entry.provider) ?? new Set(); if (entry.credential.type === "api_key") { ids.add("@api_key"); } else { if (entry.credential.email) ids.add(`email:${entry.credential.email}`); if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}`); if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}`); } out.set(entry.provider, ids); } return out; } function brokerAlreadyHas(existing: Map>, provider: string, credential: AuthCredential): boolean { const ids = existing.get(provider); if (!ids) return false; if (credential.type === "api_key") return ids.has("@api_key"); if (credential.email && ids.has(`email:${credential.email}`)) return true; if (credential.accountId && ids.has(`accountId:${credential.accountId}`)) return true; if (credential.projectId && ids.has(`projectId:${credential.projectId}`)) return true; return false; } async function runMigrate(flags: AuthBrokerCommandArgs["flags"]): Promise { const brokerConfig = await resolveAuthBrokerConfig(); if (!brokerConfig) { throw new Error( "OMP_AUTH_BROKER_URL must be set (or `auth.broker.url` in config.yml). `migrate` uploads local credentials to a configured broker.", ); } if (flags.fromLocal !== true) { throw new Error( "`omp auth-broker migrate` requires an explicit source. Pass `--from-local` to migrate from the local SQLite store and env vars.", ); } const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token }); const snapshotResult = await client.fetchSnapshot(); if (snapshotResult.status !== 200) throw new Error("Auth broker returned no snapshot"); const existing = indexBrokerSnapshot(snapshotResult.snapshot); const plan: MigratePlanEntry[] = []; const skipped: MigrateSkip[] = []; // 1. Local SQLite rows. const localDbPath = getAgentDbPath(); const localStore = await SqliteAuthCredentialStore.open(localDbPath); const plannedApiKeyProviders = new Set(); try { for (const row of localStore.listAuthCredentials()) { // Skip placeholder sentinels that pi-ai treats as "authenticated via // out-of-band mechanism" (Bedrock/Vertex ``). They // aren't real keys and uploading them would store garbage on the // broker. Mirrors the env-var path's guard below. if (row.credential.type === "api_key" && row.credential.key === "") { skipped.push({ source: "local-sqlite", provider: row.provider, identity: "(api key)", reason: "placeholder sentinel '' is not a real key", }); continue; } const identity = credentialIdentity(row.provider, row.credential); if (row.credential.type === "oauth" && flags.includeOauth !== true) { skipped.push({ source: "local-sqlite", provider: row.provider, identity, reason: "OAuth from local SQLite skipped by default (use --include-oauth)", }); continue; } if (brokerAlreadyHas(existing, row.provider, row.credential)) { skipped.push({ source: "local-sqlite", provider: row.provider, identity, reason: "already on broker", }); continue; } if (row.credential.type === "api_key" && plannedApiKeyProviders.has(row.provider)) { skipped.push({ source: "local-sqlite", provider: row.provider, identity, reason: "another local api_key for this provider already planned", }); continue; } if (row.credential.type === "api_key") plannedApiKeyProviders.add(row.provider); plan.push({ source: "local-sqlite", provider: row.provider, credential: row.credential, identity }); } } finally { localStore.close(); } // 2. Env-var API keys (opt-in). if (flags.includeEnv === true) { for (const provider of listProvidersWithEnvKey()) { const envValue = getEnvApiKey(provider); if (!envValue) continue; if (envValue === "") continue; // Bedrock/Vertex sentinels — not literal keys. const credential: AuthCredential = { type: "api_key", key: envValue }; if (brokerAlreadyHas(existing, provider, credential)) { skipped.push({ source: "env", provider, identity: "(api key)", reason: "already on broker (provider has an api_key)", }); continue; } // Also skip if local SQLite already produced an entry for this provider in this batch. if (plan.some(p => p.provider === provider && p.credential.type === "api_key")) { skipped.push({ source: "env", provider, identity: "(api key)", reason: "local SQLite already supplied an api_key for this provider", }); continue; } plan.push({ source: "env", provider, credential, identity: "(api key)" }); } } if (flags.json) { process.stdout.write( `${JSON.stringify({ dryRun: flags.dryRun === true, plan: plan.map(p => ({ source: p.source, provider: p.provider, identity: p.identity })), skipped, })}\n`, ); } else { for (const skip of skipped) { process.stdout.write( `${chalk.yellow("skip")} [${skip.source}] ${skip.provider} ${skip.identity}: ${skip.reason}\n`, ); } } if (plan.length === 0) { if (!flags.json) process.stdout.write("Nothing to migrate.\n"); return; } if (flags.dryRun === true) { if (!flags.json) { process.stdout.write(`Dry run — would upload ${plan.length} credential(s):\n`); for (const entry of plan) { process.stdout.write(` [${entry.source}] ${entry.provider} ${entry.identity}\n`); } } return; } for (const entry of plan) { try { await client.uploadCredential(entry.provider, entry.credential); if (!flags.json) { process.stdout.write(`${chalk.green("uploaded")} [${entry.source}] ${entry.provider} ${entry.identity}\n`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (flags.json) { process.stdout.write(`${JSON.stringify({ error: message, provider: entry.provider })}\n`); } else { process.stdout.write(`${chalk.red("failed")} [${entry.source}] ${entry.provider}: ${message}\n`); } process.exitCode = 1; } } } async function runStatus(flags: AuthBrokerCommandArgs["flags"]): Promise { const cfg = await resolveAuthBrokerConfig(); if (!cfg) { const message = "No auth-broker configured (set OMP_AUTH_BROKER_URL to enable)."; if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, reason: "not_configured" })}\n`); else process.stdout.write(`${chalk.yellow(message)}\n`); return; } const client = new AuthBrokerClient({ url: cfg.url, token: cfg.token }); try { const health = await client.healthz(); if (flags.json) { process.stdout.write(`${JSON.stringify({ url: cfg.url, ...health })}\n`); } else { process.stdout.write(`${chalk.green("OK")} ${cfg.url} (version=${health.version ?? "unknown"})\n`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (flags.json) { process.stdout.write(`${JSON.stringify({ ok: false, url: cfg.url, error: message })}\n`); } else { process.stdout.write(`${chalk.red("FAILED")} ${cfg.url}: ${message}\n`); } process.exitCode = 1; } } export async function runAuthBrokerCommand(cmd: AuthBrokerCommandArgs): Promise { switch (cmd.action) { case "serve": await runServe(cmd.flags); return; case "token": await runToken(cmd.flags); return; case "login": await runLogin(cmd.flags); return; case "logout": await runLogout(cmd.flags); return; case "import": await runImport(cmd.flags); return; case "migrate": await runMigrate(cmd.flags); return; case "status": await runStatus(cmd.flags); return; default: { // Exhaustive check. const _exhaustive: never = cmd.action; throw new Error(`Unknown auth-broker action: ${String(_exhaustive)}`); } } } export { ACTIONS as AUTH_BROKER_ACTIONS }; // Touch `$` so Bun's tree-shaker keeps the shell helper imported (used by future verbs). void $;