diff --git a/cli/node/src/commands/agent-mode.ts b/cli/node/src/commands/agent-mode.ts index 64c64fd0e..dd351dc9e 100644 --- a/cli/node/src/commands/agent-mode.ts +++ b/cli/node/src/commands/agent-mode.ts @@ -1,16 +1,12 @@ /** - * Agent Mode commands — bootstrap (unattended signup) and claim (human upgrade). + * Agent Mode commands — bootstrap (unattended signup) and OTP-based claim. */ -import { randomBytes } from "node:crypto"; -import { setTimeout as sleep } from "node:timers/promises"; +import readline from "node:readline"; import { colors, printError, printInfo, printSuccess } from "../branding.js"; import { type Mem0Config, saveConfig } from "../config.js"; -const { dim } = colors; - -const POLL_INTERVAL_MS = 2_000; -const POLL_TIMEOUT_MS = 600_000; // 10 minutes — fits within backend's 15-minute CLILoginRequest expiry. +const { brand, dim } = colors; const SOURCE_HEADERS = { "X-Mem0-Source": "cli", @@ -89,9 +85,17 @@ export async function bootstrapViaBackend( ); } -export async function claimViaDeviceFlow( +/** + * Claim an existing Agent Mode account via OTP — no browser, no polling. + * + * Hits /api/v1/auth/email_code/ to send a verification code, prompts for it + * interactively (or accepts via `code`), then sends it to /verify/ alongside + * `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place + * inline and returns the claim result. + */ +export async function claimViaOtp( config: Mem0Config, - { email }: { email: string }, + { email, code }: { email: string; code?: string }, ): Promise { const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); if (!config.platform.apiKey || !config.platform.agentMode) { @@ -99,97 +103,105 @@ export async function claimViaDeviceFlow( process.exit(1); } - const cliToken = randomBytes(32).toString("base64url"); const rawKey = config.platform.apiKey; - // 1. CLI initiates with claim_for_apikey - let initResp: Response; - try { - initResp = await fetch(`${baseUrl}/api/v1/accounts/cli_login/`, { + // Step 1: request OTP (unless --code was supplied) + if (!code) { + const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, { method: "POST", - headers: { - ...SOURCE_HEADERS, - "Content-Type": "application/json", - }, - body: JSON.stringify({ token: cliToken, claim_for_apikey: rawKey }), + headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ email }), signal: AbortSignal.timeout(30_000), }); - } catch (err) { - printError(`Could not initiate claim: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - if (!initResp.ok) { - let detail: string = initResp.statusText; - try { - const errBody = (await initResp.json()) as { error?: string }; - if (errBody.error) detail = errBody.error; - } catch { - /* statusText fallback */ + if (sendResp.status === 429) { + printError("Too many attempts. Try again in a few minutes."); + process.exit(1); } - printError(`Could not initiate claim: ${detail}`); - process.exit(1); - } - - const initBody = (await initResp.json()) as { login_url?: string }; - const loginUrl = initBody.login_url ?? ""; - printInfo("Open in your browser to claim:"); - console.log(` ${dim(loginUrl)}`); - // Best-effort open in the user's browser — fall back to printing the URL. - try { - const { default: open } = await import("open"); - await open(loginUrl); - } catch { - /* user has the URL printed above */ - } - - // 2. Poll for completion - const deadline = Date.now() + POLL_TIMEOUT_MS; - while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); - - let poll: Response; - try { - poll = await fetch(`${baseUrl}/api/v1/accounts/get_api_key_from_cli_token/`, { - method: "POST", - headers: { - ...SOURCE_HEADERS, - "Content-Type": "application/json", - }, - body: JSON.stringify({ token: cliToken }), - signal: AbortSignal.timeout(15_000), - }); - } catch { - continue; // transient — keep polling - } - - if (!poll.ok) { - let err = ""; + if (!sendResp.ok) { + let detail: string = sendResp.statusText; try { - const errBody = (await poll.json()) as { error?: string }; - err = errBody.error ?? ""; + const errBody = (await sendResp.json()) as { error?: string }; + if (errBody.error) detail = errBody.error; } catch { - /* ignore */ + /* leave as statusText */ } - if (err.toLowerCase().includes("expired")) { - printError("Claim link expired. Run `mem0 init --email ` again."); - process.exit(1); - } - continue; + printError(`Failed to send code: ${detail}`); + process.exit(1); } - const body = (await poll.json()) as { claimed?: boolean; claimed_at?: string }; - if (body.claimed) { - config.platform.agentMode = false; - config.platform.claimedAt = body.claimed_at ?? new Date().toISOString(); - config.platform.userEmail = email; - config.platform.createdVia = "email"; - saveConfig(config); - printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`); - return; + printSuccess(`Verification code sent to ${email}. Check your inbox.`); + + if (!process.stdin.isTTY) { + printError( + "No --code provided and terminal is non-interactive.", + `Re-run: mem0 init --email ${email} --code `, + ); + process.exit(1); + } + + console.log(); + code = await promptLine(` ${brand("Verification Code")}`); + if (!code) { + printError("Code is required."); + process.exit(1); } } - printError("Claim timed out. Run `mem0 init --email ` again."); - process.exit(1); + // Step 2: verify + claim atomically + const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, { + method: "POST", + headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + code: code.trim(), + agent_mode_api_key: rawKey, + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!verifyResp.ok) { + let detail: string = verifyResp.statusText; + let errCode = ""; + try { + const errBody = (await verifyResp.json()) as { error?: string; code?: string }; + if (errBody.error) detail = errBody.error; + if (errBody.code) errCode = errBody.code; + } catch { + /* leave as statusText */ + } + printError(`Claim failed: ${detail}`); + if (errCode === "email_already_claimed") { + console.log( + ` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link ` to attach this agent.")}`, + ); + } + process.exit(1); + } + + const body = (await verifyResp.json()) as { claimed?: boolean; claimed_at?: string }; + if (!body.claimed) { + printError(`Unexpected verify response: ${JSON.stringify(body)}`); + process.exit(1); + } + + config.platform.agentMode = false; + config.platform.claimedAt = body.claimed_at ?? new Date().toISOString(); + config.platform.userEmail = email; + config.platform.createdVia = "email"; + saveConfig(config); + + printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`); +} + +function promptLine(label: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${label}: `, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); } diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 2c2a26d6d..29a422fa3 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -254,7 +254,7 @@ export async function runInit( } = {}, ): Promise { const { detectAgentCaller } = await import("../agent-detect.js"); - const { bootstrapViaBackend, claimViaDeviceFlow } = await import("./agent-mode.js"); + const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js"); const { isAgentMode } = await import("../state.js"); const { captureEvent } = await import("../telemetry.js"); @@ -290,7 +290,7 @@ export async function runInit( const email = opts.email.trim().toLowerCase(); validateEmail(email); printInfo(`Claiming Agent Mode account to ${email}...`); - await claimViaDeviceFlow(savedConfig, { email }); + await claimViaOtp(savedConfig, { email, code: opts.code }); fireInit("email", true); return; } diff --git a/cli/python/src/mem0_cli/commands/agent_mode_cmd.py b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py index d19778d91..30a0fbb71 100644 --- a/cli/python/src/mem0_cli/commands/agent_mode_cmd.py +++ b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py @@ -1,18 +1,18 @@ -"""Agent Mode commands — bootstrap (unattended signup) and claim (human upgrade).""" +"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade).""" from __future__ import annotations -import secrets -import time -import webbrowser +import sys from datetime import datetime, timezone from typing import Any import httpx import typer from rich.console import Console +from rich.prompt import Prompt from mem0_cli.branding import ( + BRAND_COLOR, DIM_COLOR, print_error, print_info, @@ -23,11 +23,6 @@ from mem0_cli.config import Mem0Config, save_config console = Console() err_console = Console(stderr=True) -# Claim polling: 2-second interval, 10-minute timeout matches the backend's -# CLILoginRequest expires_at (15 minutes — we give up before the token does). -_POLL_INTERVAL_SECS = 2 -_POLL_TIMEOUT_SECS = 600 - _SOURCE_HEADERS = { "X-Mem0-Source": "cli", "X-Mem0-Client-Language": "python", @@ -89,13 +84,16 @@ def bootstrap_via_backend( console.print(f" [{DIM_COLOR}]To claim this account later: {envelope.get('claim_command', 'mem0 init --email ')}[/]") -def claim_via_device_flow(config: Mem0Config, *, email: str) -> None: - """Run the claim flow against an existing agent-mode config. +def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None: + """Claim an existing Agent Mode account via OTP — no browser, no polling. - Reuses the existing CLI device flow (initiate_cli_login → frontend OTP → - associate_cli_token → get_api_key_from_cli_token poll). The raw API key - never leaves the device — backend confirms claim, CLI updates only - `platform.agent_mode` and `platform.claimed_at`. + Reuses the standard email-code flow (`/api/v1/auth/email_code/` then + `/.../verify/`) and adds the local agent-mode API key in the verify body + as `agent_mode_api_key`. Backend's `verify_email_code` runs the + upgrade-in-place transaction inline and returns claim result. + + On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps + `user_email`. The api_key value itself never changes. """ base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/") if not config.platform.api_key or not config.platform.agent_mode: @@ -105,72 +103,78 @@ def claim_via_device_flow(config: Mem0Config, *, email: str) -> None: ) raise typer.Exit(1) - cli_token = secrets.token_urlsafe(32) raw_key = config.platform.api_key with httpx.Client(timeout=30.0) as client: - try: - init_resp = client.post( - f"{base_url}/api/v1/accounts/cli_login/", - json={"token": cli_token, "claim_for_apikey": raw_key}, - headers=_SOURCE_HEADERS, + # Step 1: request OTP (unless --code provided) + if not code: + send = client.post( + f"{base_url}/api/v1/auth/email_code/", + headers={**_SOURCE_HEADERS, "Content-Type": "application/json"}, + json={"email": email}, ) - except httpx.HTTPError as exc: - print_error(err_console, f"Could not initiate claim: {exc}") - raise typer.Exit(1) from exc - - if init_resp.status_code != 200: - try: - detail = init_resp.json().get("error", init_resp.text) - except Exception: - detail = init_resp.text - print_error(err_console, f"Could not initiate claim: {detail}") - raise typer.Exit(1) - - login_url = init_resp.json().get("login_url", "") - print_info(console, "Open in your browser to claim:") - console.print(f" [{DIM_COLOR}]{login_url}[/]") - try: - webbrowser.open(login_url) - except Exception: - pass # Printing the URL is sufficient - - # Poll for completion - deadline = time.monotonic() + _POLL_TIMEOUT_SECS - while time.monotonic() < deadline: - time.sleep(_POLL_INTERVAL_SECS) - try: - poll = client.post( - f"{base_url}/api/v1/accounts/get_api_key_from_cli_token/", - json={"token": cli_token}, - headers=_SOURCE_HEADERS, - ) - except httpx.HTTPError: - continue # transient — keep polling - - if poll.status_code != 200: - # 400 "Token expired" / "Invalid token" → bail + if send.status_code == 429: + print_error(err_console, "Too many attempts. Try again in a few minutes.") + raise typer.Exit(1) + if send.status_code != 200: try: - err = poll.json().get("error", "") + detail = send.json().get("error", send.text) except Exception: - err = poll.text - if "expired" in err.lower(): - print_error(err_console, "Claim link expired. Run `mem0 init --email ` again.") - raise typer.Exit(1) - continue + detail = send.text + print_error(err_console, f"Failed to send code: {detail}") + raise typer.Exit(1) - body = poll.json() - if body.get("claimed"): - config.platform.agent_mode = False - config.platform.claimed_at = body.get("claimed_at") or _utcnow_iso() - config.platform.user_email = email - config.platform.created_via = "email" - save_config(config) - print_success(console, f"Agent claimed to {email}. Your API key is unchanged.") - return + print_success(console, f"Verification code sent to {email}. Check your inbox.") - print_error(err_console, "Claim timed out. Run `mem0 init --email ` again.") - raise typer.Exit(1) + if not sys.stdin.isatty(): + print_error( + err_console, + "No --code provided and terminal is non-interactive.", + hint=f"Re-run: mem0 init --email {email} --code ", + ) + raise typer.Exit(1) + + console.print() + code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]") + if not code: + print_error(err_console, "Code is required.") + raise typer.Exit(1) + + # Step 2: verify + claim in one shot + verify = client.post( + f"{base_url}/api/v1/auth/email_code/verify/", + headers={**_SOURCE_HEADERS, "Content-Type": "application/json"}, + json={ + "email": email, + "code": code.strip(), + "agent_mode_api_key": raw_key, + }, + ) + + if verify.status_code != 200: + try: + body = verify.json() + detail = body.get("error", verify.text) + code_str = body.get("code", "") + except Exception: + detail = verify.text + code_str = "" + print_error(err_console, f"Claim failed: {detail}") + if code_str == "email_already_claimed": + console.print(f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link ` to attach this agent.[/]") + raise typer.Exit(1) + + body = verify.json() + if not body.get("claimed"): + print_error(err_console, f"Unexpected verify response: {body}") + raise typer.Exit(1) + + config.platform.agent_mode = False + config.platform.claimed_at = body.get("claimed_at") or _utcnow_iso() + config.platform.user_email = email + config.platform.created_via = "email" + save_config(config) + print_success(console, f"Agent claimed to {email}. Your API key is unchanged.") def _utcnow_iso() -> str: diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index 9764da388..f0a83b60a 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -203,7 +203,7 @@ def run_init( email-based key. """ from mem0_cli.agent_detect import detect_agent_caller - from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_device_flow + from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp from mem0_cli.state import is_agent_mode as _global_agent_mode from mem0_cli.telemetry import capture_event @@ -235,7 +235,7 @@ def run_init( email = email.strip().lower() _validate_email(email) print_info(console, f"Claiming Agent Mode account to {email}...") - claim_via_device_flow(existing, email=email) + claim_via_otp(existing, email=email, code=code) _fire_init("email", claimed=True) return