diff --git a/bin/baudbot b/bin/baudbot index 79c11ef..86eef51 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -145,6 +145,7 @@ usage() { echo " install Bootstrap install from GitHub (download script, then escalate)" echo " setup One-time system setup (user, deps, firewall, systemd; --experimental enables risky integrations)" echo " config Interactive secrets and config setup" + echo " login Authenticate with an LLM subscription (OAuth)" echo " env Manage env vars and backend source (set/get/sync/backend)" echo " deploy Deploy source + config to agent runtime" echo " broker Slack broker commands (register workspace linkage)" @@ -443,6 +444,33 @@ case "$COMMAND_NAME" in dispatch_registered_command "start" "$@" ;; + login) + require_root "login" + BAUDBOT_AGENT_USER="${BAUDBOT_AGENT_USER:-baudbot_agent}" + BAUDBOT_HOME="$(resolve_user_home "$BAUDBOT_AGENT_USER" 2>/dev/null || echo "/home/$BAUDBOT_AGENT_USER")" + AUTH_JSON="$BAUDBOT_HOME/.pi/agent/auth.json" + + NODE_BIN="$(resolve_node_bin || true)" + if [ -z "$NODE_BIN" ]; then + echo "❌ Could not find node runtime for OAuth login." + exit 1 + fi + + OAUTH_SCRIPT="$BAUDBOT_ROOT/bin/oauth-login.mjs" + if [ ! -f "$OAUTH_SCRIPT" ]; then + echo "❌ oauth-login.mjs not found at $OAUTH_SCRIPT" + exit 1 + fi + + "$NODE_BIN" "$OAUTH_SCRIPT" --auth-path "$AUTH_JSON" "$@" + + # Fix ownership + if [ -f "$AUTH_JSON" ] && id "$BAUDBOT_AGENT_USER" &>/dev/null; then + chown "$BAUDBOT_AGENT_USER:$BAUDBOT_AGENT_USER" "$AUTH_JSON" + chown "$BAUDBOT_AGENT_USER:$BAUDBOT_AGENT_USER" "$(dirname "$AUTH_JSON")" 2>/dev/null || true + fi + ;; + setup) if [ "${1:-}" = "--slack-broker" ]; then shift diff --git a/bin/config.sh b/bin/config.sh index bd9198d..0dd4ef1 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -361,62 +361,148 @@ fi echo -e "${BOLD}Required${RESET} ${DIM}(agent won't start without these)${RESET}" echo "" -# LLM provider picker -echo -e "${BOLD}LLM provider${RESET}" -LLM_CHOICE="$(ui_choose "Choose your primary LLM provider:" \ - "Anthropic" \ - "OpenAI" \ - "Gemini" \ - "OpenCode Zen")" - -case "$LLM_CHOICE" in - "Anthropic") - prompt_secret "ANTHROPIC_API_KEY" \ - "Anthropic API key" \ - "https://console.anthropic.com/settings/keys" \ - "required" \ - "sk-ant-" - ;; - "OpenAI") - prompt_secret "OPENAI_API_KEY" \ - "OpenAI API key" \ - "https://platform.openai.com/api-keys" \ - "required" \ - "sk-" - ;; - "Gemini") - prompt_secret "GEMINI_API_KEY" \ - "Google Gemini API key" \ - "https://aistudio.google.com/apikey" \ - "required" - ;; - "OpenCode Zen") - prompt_secret "OPENCODE_ZEN_API_KEY" \ - "OpenCode Zen API key (multi-provider router)" \ - "https://opencode.ai" \ - "required" - ;; -esac +# LLM authentication tier +echo -e "${BOLD}LLM authentication${RESET}" +LLM_AUTH_TIER="$(ui_choose "How would you like to authenticate with your LLM?" \ + "API key" \ + "Subscription login (OAuth)")" + +USED_SUBSCRIPTION_LOGIN=false + +if [ "$LLM_AUTH_TIER" = "API key" ]; then + # ── API key path ── + echo "" + LLM_CHOICE="$(ui_choose "Choose your primary LLM provider:" \ + "Anthropic" \ + "OpenAI" \ + "Gemini" \ + "OpenCode Zen")" + + case "$LLM_CHOICE" in + "Anthropic") + prompt_secret "ANTHROPIC_API_KEY" \ + "Anthropic API key" \ + "https://console.anthropic.com/settings/keys" \ + "required" \ + "sk-ant-" + ;; + "OpenAI") + prompt_secret "OPENAI_API_KEY" \ + "OpenAI API key" \ + "https://platform.openai.com/api-keys" \ + "required" \ + "sk-" + ;; + "Gemini") + prompt_secret "GEMINI_API_KEY" \ + "Google Gemini API key" \ + "https://aistudio.google.com/apikey" \ + "required" + ;; + "OpenCode Zen") + prompt_secret "OPENCODE_ZEN_API_KEY" \ + "OpenCode Zen API key (multi-provider router)" \ + "https://opencode.ai" \ + "required" + ;; + esac -SELECTED_LLM_KEY="" -case "$LLM_CHOICE" in - "Anthropic") SELECTED_LLM_KEY="ANTHROPIC_API_KEY" ;; - "OpenAI") SELECTED_LLM_KEY="OPENAI_API_KEY" ;; - "Gemini") SELECTED_LLM_KEY="GEMINI_API_KEY" ;; - "OpenCode Zen") SELECTED_LLM_KEY="OPENCODE_ZEN_API_KEY" ;; -esac + SELECTED_LLM_KEY="" + case "$LLM_CHOICE" in + "Anthropic") SELECTED_LLM_KEY="ANTHROPIC_API_KEY" ;; + "OpenAI") SELECTED_LLM_KEY="OPENAI_API_KEY" ;; + "Gemini") SELECTED_LLM_KEY="GEMINI_API_KEY" ;; + "OpenCode Zen") SELECTED_LLM_KEY="OPENCODE_ZEN_API_KEY" ;; + esac -if [ -z "${ENV_VARS[$SELECTED_LLM_KEY]:-}" ]; then - echo "❌ $SELECTED_LLM_KEY is required for selected provider '$LLM_CHOICE'" - exit 1 -fi + if [ -z "${ENV_VARS[$SELECTED_LLM_KEY]:-}" ]; then + echo "❌ $SELECTED_LLM_KEY is required for selected provider '$LLM_CHOICE'" + exit 1 + fi -# Keep only selected provider key for deterministic config. -for key in ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY OPENCODE_ZEN_API_KEY; do - if [ "$key" != "$SELECTED_LLM_KEY" ]; then - unset "ENV_VARS[$key]" + # Keep only selected provider key for deterministic config. + for key in ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY OPENCODE_ZEN_API_KEY; do + if [ "$key" != "$SELECTED_LLM_KEY" ]; then + unset "ENV_VARS[$key]" + fi + done + +else + # ── Subscription login (OAuth) path ── + clear_keys ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY OPENCODE_ZEN_API_KEY + LLM_CHOICE="Subscription" + SELECTED_LLM_KEY="" + + # Resolve the agent home for auth.json + BAUDBOT_HOME="${BAUDBOT_HOME:-/home/baudbot_agent}" + AUTH_JSON="$BAUDBOT_HOME/.pi/agent/auth.json" + + # Find Node.js for oauth-login.mjs + OAUTH_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/oauth-login.mjs" + OAUTH_NODE_BIN="" + if [ -f "$SCRIPT_DIR/../bin/lib/runtime-node.sh" ]; then + # shellcheck source=bin/lib/runtime-node.sh + source "$SCRIPT_DIR/../bin/lib/runtime-node.sh" 2>/dev/null || true + OAUTH_NODE_BIN="$(bb_resolve_runtime_node_bin "$BAUDBOT_HOME" 2>/dev/null || true)" + fi + if [ -z "$OAUTH_NODE_BIN" ] || [ ! -x "$OAUTH_NODE_BIN" ]; then + OAUTH_NODE_BIN="$(command -v node 2>/dev/null || true)" + fi + + if [ ! -f "$OAUTH_SCRIPT" ]; then + echo "❌ oauth-login.mjs not found at $OAUTH_SCRIPT" + exit 1 + fi + if [ -z "$OAUTH_NODE_BIN" ]; then + echo "❌ Node.js not found — required for OAuth login" + exit 1 fi -done + + # Check for existing OAuth credentials + HAS_EXISTING_OAUTH=false + EXISTING_OAUTH_PROVIDER="" + if [ -f "$AUTH_JSON" ] && command -v jq &>/dev/null; then + for op in "openai-codex" "anthropic"; do + if jq -e --arg p "$op" '.[$p]' "$AUTH_JSON" &>/dev/null; then + HAS_EXISTING_OAUTH=true + EXISTING_OAUTH_PROVIDER="$op" + break + fi + done + fi + + if [ "$HAS_EXISTING_OAUTH" = true ]; then + info "Existing OAuth credentials found ($EXISTING_OAUTH_PROVIDER)." + if ! ui_confirm "Re-authenticate with a different provider?" false; then + info "Keeping existing OAuth credentials." + USED_SUBSCRIPTION_LOGIN=true + fi + fi + + if [ "$USED_SUBSCRIPTION_LOGIN" = false ]; then + echo "" + dim " This will open an OAuth flow. You'll get a URL to open in your browser." + echo "" + + # Run oauth-login.mjs interactively + OAUTH_PROVIDER_ID="" + if OAUTH_PROVIDER_ID=$("$OAUTH_NODE_BIN" "$OAUTH_SCRIPT" --auth-path "$AUTH_JSON"); then + OAUTH_PROVIDER_ID="$(echo "$OAUTH_PROVIDER_ID" | tr -d '[:space:]')" + info "✓ OAuth login complete ($OAUTH_PROVIDER_ID)" + + # Fix ownership if running as root + if [ "$(id -u)" -eq 0 ] && id baudbot_agent &>/dev/null; then + chown baudbot_agent:baudbot_agent "$AUTH_JSON" + # Also fix parent dirs + chown baudbot_agent:baudbot_agent "$(dirname "$AUTH_JSON")" 2>/dev/null || true + fi + else + echo "❌ OAuth login failed" + exit 1 + fi + USED_SUBSCRIPTION_LOGIN=true + fi +fi echo "" @@ -655,7 +741,12 @@ VAR_COUNT=$(grep -c '=' "$CONFIG_FILE") info "Wrote $VAR_COUNT variables to $CONFIG_FILE" echo "" echo -e "${BOLD}Summary${RESET}" -echo -e " LLM provider: ${BOLD}${LLM_CHOICE}${RESET}" +echo -e " LLM auth: ${BOLD}${LLM_AUTH_TIER}${RESET}" +if [ "$LLM_AUTH_TIER" = "API key" ]; then + echo -e " LLM provider: ${BOLD}${LLM_CHOICE}${RESET}" +else + echo -e " LLM provider: ${BOLD}Subscription (OAuth via auth.json)${RESET}" +fi echo -e " Slack mode: ${BOLD}${SLACK_CHOICE}${RESET}" if [ "$SLACK_CHOICE" = "Use baudbot.ai Slack integration (easy)" ]; then echo -e " ${DIM}Next: run 'sudo baudbot broker register' after install${RESET}" diff --git a/bin/config.test.sh b/bin/config.test.sh index 154f28d..5e4c7e4 100644 --- a/bin/config.test.sh +++ b/bin/config.test.sh @@ -83,8 +83,9 @@ echo "=================" echo "" # Test 1: Advanced Slack path writes socket-mode keys only +# Input: 1=API key tier, 1=Anthropic, key, 2=advanced Slack, tokens, ... HOME1="$TMPDIR/advanced" -run_config "$HOME1" '1\nsk-ant-test\n2\nxoxb-test\nxapp-test\n\nn\nn\n' +run_config "$HOME1" '1\n1\nsk-ant-test\n2\nxoxb-test\nxapp-test\n\nn\nn\n' ENV1="$HOME1/.baudbot/.env" expect_file_contains "advanced path writes Anthropic key" "$ENV1" "ANTHROPIC_API_KEY=sk-ant-test" expect_file_contains "advanced path writes SLACK_BOT_TOKEN" "$ENV1" "SLACK_BOT_TOKEN=xoxb-test" @@ -92,41 +93,68 @@ expect_file_contains "advanced path writes SLACK_APP_TOKEN" "$ENV1" "SLACK_APP_T expect_file_not_contains "advanced path does not write OPENAI key" "$ENV1" "OPENAI_API_KEY=" # Test 2: Easy Slack path avoids socket-mode keys +# Input: 1=API key tier, 2=OpenAI, key, 1=easy Slack, ... HOME2="$TMPDIR/easy" -run_config "$HOME2" '2\nsk-openai-test\n1\n\nn\nn\n' +run_config "$HOME2" '1\n2\nsk-openai-test\n1\n\nn\nn\n' ENV2="$HOME2/.baudbot/.env" expect_file_contains "easy path writes OpenAI key" "$ENV2" "OPENAI_API_KEY=sk-openai-test" expect_file_not_contains "easy path omits SLACK_BOT_TOKEN" "$ENV2" "SLACK_BOT_TOKEN=" expect_file_not_contains "easy path omits SLACK_APP_TOKEN" "$ENV2" "SLACK_APP_TOKEN=" # Test 3: Optional integration toggle prompts conditionally +# Input: 1=API key tier, 3=Gemini, key, 2=advanced Slack, tokens, ..., y=kernel, key, n=sentry HOME3="$TMPDIR/kernel" -run_config "$HOME3" '3\ngem-key\n2\nxoxb-test\nxapp-test\n\ny\nkernel-key\nn\n' +run_config "$HOME3" '1\n3\ngem-key\n2\nxoxb-test\nxapp-test\n\ny\nkernel-key\nn\n' ENV3="$HOME3/.baudbot/.env" expect_file_contains "kernel enabled writes key" "$ENV3" "KERNEL_API_KEY=kernel-key" expect_file_not_contains "sentry skipped omits token" "$ENV3" "SENTRY_AUTH_TOKEN=" expect_file_not_contains "email skipped omits AgentMail" "$ENV3" "AGENTMAIL_API_KEY=" # Test 4: Selected LLM key is required +# Input: 1=API key tier, 1=Anthropic, empty key HOME4="$TMPDIR/missing-llm" -expect_exit_nonzero "fails when selected provider key is missing" "$HOME4" '1\n\n' +expect_exit_nonzero "fails when selected provider key is missing" "$HOME4" '1\n1\n\n' # Test 5: Re-run preserves existing selected LLM key when input is blank +# Input: 1=API key tier, 1=Anthropic, blank (keep existing), 1=easy Slack, ... HOME5="$TMPDIR/rerun-keep-llm" write_existing_env "$HOME5" 'ANTHROPIC_API_KEY=sk-ant-existing\n' -run_config "$HOME5" '1\n\n1\n\nn\nn\n' +run_config "$HOME5" '1\n1\n\n1\n\nn\nn\n' ENV5="$HOME5/.baudbot/.env" expect_file_contains "rerun keeps existing Anthropic key" "$ENV5" "ANTHROPIC_API_KEY=sk-ant-existing" # Test 6: Advanced Slack mode clears stale broker registration keys +# Input: 1=API key tier, 2=OpenAI, key, 2=advanced Slack, tokens, ... HOME6="$TMPDIR/clear-broker" write_existing_env "$HOME6" 'OPENAI_API_KEY=sk-old\nSLACK_BROKER_URL=https://broker.example.com\nSLACK_BROKER_WORKSPACE_ID=T0123\nSLACK_BROKER_PUBLIC_KEY=abc\n' -run_config "$HOME6" '2\nsk-openai-new\n2\nxoxb-new\nxapp-new\n\nn\nn\n' +run_config "$HOME6" '1\n2\nsk-openai-new\n2\nxoxb-new\nxapp-new\n\nn\nn\n' ENV6="$HOME6/.baudbot/.env" expect_file_not_contains "advanced clears broker URL" "$ENV6" "SLACK_BROKER_URL=" expect_file_not_contains "advanced clears broker workspace" "$ENV6" "SLACK_BROKER_WORKSPACE_ID=" expect_file_contains "advanced retains socket bot token" "$ENV6" "SLACK_BOT_TOKEN=xoxb-new" +# Test 7: Subscription login tier with existing auth.json skips OAuth flow +# Input: 2=Subscription tier, n=don't re-auth, 1=easy Slack, n=kernel, n=sentry +HOME7="$TMPDIR/subscription" +mkdir -p "$HOME7/.pi/agent" +echo '{"anthropic":{"type":"oauth","access":"tok","refresh":"ref","expires":9999999999999}}' \ + > "$HOME7/.pi/agent/auth.json" +config_user="$(id -un)" +printf "%b" '2\nn\n1\n\nn\nn\n' \ + | HOME="$HOME7" BAUDBOT_HOME="$HOME7" BAUDBOT_CONFIG_USER="$config_user" BAUDBOT_TRY_INSTALL_GUM=0 \ + bash "$CONFIG_SCRIPT" >"$OUT_FILE" 2>"$ERR_FILE" +ENV7="$HOME7/.baudbot/.env" +expect_file_not_contains "subscription path omits ANTHROPIC_API_KEY" "$ENV7" "ANTHROPIC_API_KEY=" +expect_file_not_contains "subscription path omits OPENAI_API_KEY" "$ENV7" "OPENAI_API_KEY=" +# Verify subscription was detected in output +if grep -q "Subscription" "$OUT_FILE"; then + echo " PASS: subscription tier shown in summary" + PASS=$((PASS + 1)) +else + echo " FAIL: subscription tier shown in summary" + FAIL=$((FAIL + 1)) +fi + echo "" echo "Results: $PASS passed, $FAIL failed" diff --git a/bin/doctor.sh b/bin/doctor.sh index d74bdf5..c52c267 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -188,7 +188,23 @@ if [ -f "$ENV_FILE" ]; then if [ "$VALID_LLM_COUNT" -gt 0 ]; then pass "at least one valid LLM API key is set" else - fail "no valid LLM API key set (need ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENCODE_ZEN_API_KEY)" + # Check auth.json for OAuth subscription credentials + AUTH_JSON="$BAUDBOT_HOME/.pi/agent/auth.json" + HAS_OAUTH=false + OAUTH_PROVIDERS="" + if [ -f "$AUTH_JSON" ] && command -v jq &>/dev/null; then + for oauth_provider in "openai-codex" "anthropic" "google" "github-copilot"; do + if jq -e --arg p "$oauth_provider" '.[$p]' "$AUTH_JSON" &>/dev/null; then + HAS_OAUTH=true + OAUTH_PROVIDERS="${OAUTH_PROVIDERS:+$OAUTH_PROVIDERS, }$oauth_provider" + fi + done + fi + if [ "$HAS_OAUTH" = true ]; then + pass "OAuth subscription credentials found in auth.json ($OAUTH_PROVIDERS)" + else + fail "no valid LLM API key set (need ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENCODE_ZEN_API_KEY; or use: sudo baudbot login)" + fi fi read_first_env_value() { diff --git a/bin/oauth-login.mjs b/bin/oauth-login.mjs new file mode 100644 index 0000000..9b90620 --- /dev/null +++ b/bin/oauth-login.mjs @@ -0,0 +1,439 @@ +#!/usr/bin/env node +/** + * OAuth subscription login for baudbot. + * + * Authenticates with an LLM provider using OAuth (subscription-based access) + * and writes credentials to pi's auth.json format. + * + * Supported providers: + * - openai-codex ChatGPT Plus/Pro (Codex Subscription) + * - anthropic Anthropic (Claude Pro/Max) + * + * Usage: + * node oauth-login.mjs --provider openai-codex --auth-path /path/to/auth.json + * node oauth-login.mjs # interactive provider picker + * + * Exit codes: + * 0 Login successful + * 1 Login failed or cancelled + * + * On success, prints the provider ID to stdout (e.g. "openai-codex"). + * All prompts and status messages go to stderr so stdout stays clean for callers. + */ + +import { createInterface } from "node:readline"; +import { webcrypto } from "node:crypto"; +import { randomBytes } from "node:crypto"; +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; + +const { subtle } = webcrypto; + +// ── Provider definitions ──────────────────────────────────────────────────── + +const PROVIDERS = { + "openai-codex": { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + scope: "openid profile email offline_access", + usesCallbackServer: true, + }, + anthropic: { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + authorizeUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://console.anthropic.com/v1/oauth/token", + clientId: atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"), // public OAuth client ID; base64 only to avoid secret-scanner false positives + redirectUri: "https://console.anthropic.com/oauth/code/callback", + scope: "org:create_api_key user:profile user:inference", + usesCallbackServer: false, + }, +}; + +// ── PKCE ──────────────────────────────────────────────────────────────────── + +function base64urlEncode(bytes) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +async function generatePKCE() { + const verifierBytes = new Uint8Array(32); + webcrypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + const data = new TextEncoder().encode(verifier); + const hashBuffer = await subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + return { verifier, challenge }; +} + +// ── Readline helpers ──────────────────────────────────────────────────────── + +function createRL() { + return createInterface({ input: process.stdin, output: process.stderr }); +} + +function ask(rl, question) { + return new Promise((resolve) => rl.question(question, resolve)); +} + +function info(msg) { + process.stderr.write(`${msg}\n`); +} + +// ── OAuth: OpenAI Codex ───────────────────────────────────────────────────── + +function decodeJwt(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + return JSON.parse(atob(parts[1])); + } catch { + return null; + } +} + +function parseAuthorizationInput(input) { + const value = input.trim(); + if (!value) return {}; + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { /* not a URL */ } + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + return { code: value }; +} + +function extractCode(input, expectedState) { + const parsed = parseAuthorizationInput(input); + // State is optional — users may paste a bare authorization code with no state param + if (parsed.state && parsed.state !== expectedState) throw new Error("State mismatch"); + return parsed.code; +} + +function startLocalCallbackServer(expectedState) { + let lastCode = null; + let cancelled = false; + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url || "", "http://localhost"); + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.end("Not found"); + return; + } + if (url.searchParams.get("state") !== expectedState) { + res.statusCode = 400; + res.end("State mismatch"); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + res.statusCode = 400; + res.end("Missing authorization code"); + return; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end("

Authentication successful. Return to your terminal to continue.

"); + lastCode = code; + } catch { + res.statusCode = 500; + res.end("Internal error"); + } + }); + + return new Promise((resolve) => { + server + .listen(1455, "127.0.0.1", () => { + resolve({ + close: () => server.close(), + cancel: () => { cancelled = true; }, + waitForCode: async () => { + for (let i = 0; i < 600; i++) { + if (lastCode) return lastCode; + if (cancelled) return null; + await new Promise((r) => setTimeout(r, 100)); + } + return null; + }, + }); + }) + .on("error", () => { + resolve({ + close: () => { try { server.close(); } catch { /* ignore */ } }, + cancel: () => {}, + waitForCode: async () => null, + }); + }); + }); +} + +async function loginOpenAICodex(rl) { + const provider = PROVIDERS["openai-codex"]; + const { verifier, challenge } = await generatePKCE(); + const state = randomBytes(16).toString("hex"); + + const url = new URL(provider.authorizeUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", provider.clientId); + url.searchParams.set("redirect_uri", provider.redirectUri); + url.searchParams.set("scope", provider.scope); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "baudbot"); + + const server = await startLocalCallbackServer(state); + + info(""); + info(" Open this URL in your browser to authenticate:"); + info(` ${url.toString()}`); + info(""); + info(" If your browser can reach this machine on port 1455, login will"); + info(" complete automatically. Otherwise, paste the redirect URL below."); + info(""); + + // User either pastes a redirect URL or presses Enter to wait for browser callback + const input = await ask(rl, " Paste redirect URL (or press Enter to wait for browser): "); + + let code; + if (input.trim()) { + server.cancel(); + code = extractCode(input, state); + } else { + // User pressed Enter — wait for the browser callback (polls lastCode, resolves + // immediately if the callback already arrived while the prompt was open) + const serverCode = await server.waitForCode(); + if (serverCode) code = serverCode; + } + + if (!code) { + // Neither path produced a code — one final prompt + const fallback = await ask(rl, " Paste the authorization code (or full redirect URL): "); + code = extractCode(fallback, state); + } + + server.close(); + + if (!code) throw new Error("No authorization code received"); + + // Exchange code for tokens + const tokenResp = await fetch(provider.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: provider.clientId, + code, + code_verifier: verifier, + redirect_uri: provider.redirectUri, + }), + }); + + if (!tokenResp.ok) { + const text = await tokenResp.text().catch(() => ""); + throw new Error(`Token exchange failed: ${tokenResp.status} ${text}`); + } + + const tokens = await tokenResp.json(); + if (!tokens.access_token || !tokens.refresh_token || typeof tokens.expires_in !== "number") { + throw new Error("Token response missing required fields"); + } + + const payload = decodeJwt(tokens.access_token); + const auth = payload?.["https://api.openai.com/auth"]; + const accountId = auth?.chatgpt_account_id; + if (!accountId) throw new Error("Failed to extract accountId from token"); + + return { + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + tokens.expires_in * 1000 - 5 * 60 * 1000, // refresh 5 min early to avoid edge-of-expiry 401s + accountId, + }; +} + +// ── OAuth: Anthropic ──────────────────────────────────────────────────────── + +async function loginAnthropic(rl) { + const provider = PROVIDERS.anthropic; + const { verifier, challenge } = await generatePKCE(); + + const url = new URL(provider.authorizeUrl); + url.searchParams.set("code", "true"); + url.searchParams.set("client_id", provider.clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", provider.redirectUri); + url.searchParams.set("scope", provider.scope); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", verifier); + + info(""); + info(" Open this URL in your browser to authenticate:"); + info(` ${url.toString()}`); + info(""); + info(" After logging in, you'll see an authorization code."); + info(" Copy it and paste it below (format: code#state)."); + info(""); + + const input = await ask(rl, " Paste the authorization code: "); + if (!input.trim()) throw new Error("No authorization code provided"); + + const splits = input.trim().split("#"); + const code = splits[0]; + const state = splits[1]; + + // Exchange code for tokens + const tokenResp = await fetch(provider.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: provider.clientId, + code, + state, + redirect_uri: provider.redirectUri, + code_verifier: verifier, + }), + }); + + if (!tokenResp.ok) { + const text = await tokenResp.text().catch(() => ""); + throw new Error(`Token exchange failed: ${tokenResp.status} ${text}`); + } + + const tokens = await tokenResp.json(); + if (!tokens.access_token || !tokens.refresh_token || typeof tokens.expires_in !== "number") { + throw new Error("Token response missing required fields"); + } + + return { + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + tokens.expires_in * 1000 - 5 * 60 * 1000, // refresh 5 min early to avoid edge-of-expiry 401s + }; +} + +// ── Auth storage ──────────────────────────────────────────────────────────── + +function readAuthJson(authPath) { + try { + if (fs.existsSync(authPath)) { + return JSON.parse(fs.readFileSync(authPath, "utf-8")); + } + } catch { /* ignore */ } + return {}; +} + +function writeAuthJson(authPath, data) { + const dir = path.dirname(authPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(authPath, JSON.stringify(data, null, 2), "utf-8"); + fs.chmodSync(authPath, 0o600); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + let providerId = null; + let authPath = path.join( + process.env.HOME || "/home/baudbot_agent", + ".pi/agent/auth.json", + ); + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--provider" && args[i + 1]) { + providerId = args[++i]; + } else if (args[i] === "--auth-path" && args[i + 1]) { + authPath = args[++i]; + } else if (args[i] === "--help" || args[i] === "-h") { + info("Usage: oauth-login.mjs [--provider openai-codex|anthropic] [--auth-path ]"); + info(""); + info("Providers:"); + for (const p of Object.values(PROVIDERS)) { + info(` ${p.id.padEnd(16)} ${p.name}`); + } + process.exit(0); + } + } + + const rl = createRL(); + + try { + // Provider selection + if (!providerId) { + info(""); + info("Choose subscription provider:"); + const providerList = Object.values(PROVIDERS); + providerList.forEach((p, i) => info(` ${i + 1}) ${p.name}`)); + info(""); + const choice = await ask(rl, "Enter choice [1-" + providerList.length + "]: "); + const idx = parseInt(choice, 10) - 1; + if (idx < 0 || idx >= providerList.length) { + throw new Error("Invalid choice"); + } + providerId = providerList[idx].id; + } + + if (!PROVIDERS[providerId]) { + throw new Error(`Unknown provider: ${providerId}. Supported: ${Object.keys(PROVIDERS).join(", ")}`); + } + + info(`\n Logging in with ${PROVIDERS[providerId].name}...`); + + // Run the login flow + let credentials; + if (providerId === "openai-codex") { + credentials = await loginOpenAICodex(rl); + } else if (providerId === "anthropic") { + credentials = await loginAnthropic(rl); + } else { + throw new Error(`Login not implemented for ${providerId}`); + } + + // Merge into existing auth.json + const existing = readAuthJson(authPath); + existing[providerId] = { type: "oauth", ...credentials }; + writeAuthJson(authPath, existing); + + info(`\n ✓ Logged in with ${PROVIDERS[providerId].name}`); + info(` Credentials saved to ${authPath}`); + + // Print provider ID to stdout for callers + process.stdout.write(providerId + "\n"); + } finally { + rl.close(); + } +} + +main().catch((err) => { + info(`\n ✗ Login failed: ${err.message}`); + process.exit(1); +}); diff --git a/install.sh b/install.sh index 917310b..12d6ff3 100755 --- a/install.sh +++ b/install.sh @@ -268,8 +268,18 @@ if [ -f "$ENV_FILE" ]; then if grep -q "^${k}=.\+" "$ENV_FILE" 2>/dev/null; then HAS_LLM=true; break; fi done fi +# Also check auth.json for OAuth subscription credentials +AUTH_JSON="$BAUDBOT_HOME/.pi/agent/auth.json" +if [ "$HAS_LLM" = false ] && [ -f "$AUTH_JSON" ] && command -v jq &>/dev/null; then + for oauth_provider in "openai-codex" "anthropic"; do + if jq -e --arg p "$oauth_provider" '.[$p]' "$AUTH_JSON" &>/dev/null; then + HAS_LLM=true + break + fi + done +fi if [ "$HAS_LLM" = false ]; then - MISSING+=" - LLM key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENCODE_ZEN_API_KEY)\n" + MISSING+=" - LLM key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENCODE_ZEN_API_KEY) or subscription login (sudo baudbot login)\n" fi HAS_SOCKET=false HAS_BROKER=false diff --git a/start.sh b/start.sh index 893a091..474bbf5 100755 --- a/start.sh +++ b/start.sh @@ -121,8 +121,23 @@ elif [ -n "${GEMINI_API_KEY:-}" ]; then MODEL="google/gemini-3-pro-preview" elif [ -n "${OPENCODE_ZEN_API_KEY:-}" ]; then MODEL="opencode-zen/claude-opus-4-6" +elif [ -f "$HOME/.pi/agent/auth.json" ] && command -v jq &>/dev/null; then + # OAuth subscription fallback: check auth.json for credentials saved via `baudbot login` or `pi /login` + if jq -e '."openai-codex"' "$HOME/.pi/agent/auth.json" &>/dev/null; then + MODEL="openai-codex/gpt-5.2-codex" + elif jq -e '.anthropic' "$HOME/.pi/agent/auth.json" &>/dev/null; then + MODEL="anthropic/claude-opus-4-6" + elif jq -e '.google' "$HOME/.pi/agent/auth.json" &>/dev/null; then + MODEL="google/gemini-3-pro-preview" + elif jq -e '."github-copilot"' "$HOME/.pi/agent/auth.json" &>/dev/null; then + MODEL="github-copilot/claude-sonnet-4" + else + echo "❌ No LLM credentials found in env vars or auth.json" + exit 1 + fi else echo "❌ No LLM API key found — set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENCODE_ZEN_API_KEY" + echo " Or use subscription login: sudo baudbot login" exit 1 fi