feat(cli): rework claim to OTP-only — drop device flow + browser + polling

Replace claim_via_device_flow / claimViaDeviceFlow with claim_via_otp /
claimViaOtp. The new flow:
  1. POST /api/v1/auth/email_code/ with the user's email
  2. Prompt for the verification code (or accept via --code for non-TTY)
  3. POST /.../verify/ with {email, code, agent_mode_api_key: <local key>}
  4. Backend's verify_email_code runs upgrade-in-place inline and returns
     {claimed: true, claimed_at, ...}

No browser open, no localhost:3000 frontend dependency, no 10-minute poll
loop. Just two HTTP calls + an OTP prompt. Same upgrade-in-place
semantics on the backend; same key-value-unchanged guarantee for the
caller.

--code flag still supported on `mem0 init --email` for non-interactive
use (CI, agent-driven claim scripts).
This commit is contained in:
Mgeeeek
2026-05-13 23:08:20 +05:30
parent bcba0560c4
commit 1c92c466c4
4 changed files with 181 additions and 165 deletions
+100 -88
View File
@@ -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 readline from "node:readline";
import { setTimeout as sleep } from "node:timers/promises";
import { colors, printError, printInfo, printSuccess } from "../branding.js"; import { colors, printError, printInfo, printSuccess } from "../branding.js";
import { type Mem0Config, saveConfig } from "../config.js"; import { type Mem0Config, saveConfig } from "../config.js";
const { dim } = colors; const { brand, 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 SOURCE_HEADERS = { const SOURCE_HEADERS = {
"X-Mem0-Source": "cli", "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, config: Mem0Config,
{ email }: { email: string }, { email, code }: { email: string; code?: string },
): Promise<void> { ): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, "");
if (!config.platform.apiKey || !config.platform.agentMode) { if (!config.platform.apiKey || !config.platform.agentMode) {
@@ -99,97 +103,105 @@ export async function claimViaDeviceFlow(
process.exit(1); process.exit(1);
} }
const cliToken = randomBytes(32).toString("base64url");
const rawKey = config.platform.apiKey; const rawKey = config.platform.apiKey;
// 1. CLI initiates with claim_for_apikey // Step 1: request OTP (unless --code was supplied)
let initResp: Response; if (!code) {
try { const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
initResp = await fetch(`${baseUrl}/api/v1/accounts/cli_login/`, {
method: "POST", method: "POST",
headers: { headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
...SOURCE_HEADERS, body: JSON.stringify({ email }),
"Content-Type": "application/json",
},
body: JSON.stringify({ token: cliToken, claim_for_apikey: rawKey }),
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
} catch (err) { if (sendResp.status === 429) {
printError(`Could not initiate claim: ${err instanceof Error ? err.message : String(err)}`); printError("Too many attempts. Try again in a few minutes.");
process.exit(1); 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 */
} }
printError(`Could not initiate claim: ${detail}`); if (!sendResp.ok) {
process.exit(1); let detail: string = sendResp.statusText;
}
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 = "";
try { try {
const errBody = (await poll.json()) as { error?: string }; const errBody = (await sendResp.json()) as { error?: string };
err = errBody.error ?? ""; if (errBody.error) detail = errBody.error;
} catch { } catch {
/* ignore */ /* leave as statusText */
} }
if (err.toLowerCase().includes("expired")) { printError(`Failed to send code: ${detail}`);
printError("Claim link expired. Run `mem0 init --email <addr>` again."); process.exit(1);
process.exit(1);
}
continue;
} }
const body = (await poll.json()) as { claimed?: boolean; claimed_at?: string }; printSuccess(`Verification code sent to ${email}. Check your inbox.`);
if (body.claimed) {
config.platform.agentMode = false; if (!process.stdin.isTTY) {
config.platform.claimedAt = body.claimed_at ?? new Date().toISOString(); printError(
config.platform.userEmail = email; "No --code provided and terminal is non-interactive.",
config.platform.createdVia = "email"; `Re-run: mem0 init --email ${email} --code <code>`,
saveConfig(config); );
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`); process.exit(1);
return; }
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 <addr>` again."); // Step 2: verify + claim atomically
process.exit(1); 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 <key>` 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<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${label}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
} }
+2 -2
View File
@@ -254,7 +254,7 @@ export async function runInit(
} = {}, } = {},
): Promise<void> { ): Promise<void> {
const { detectAgentCaller } = await import("../agent-detect.js"); 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 { isAgentMode } = await import("../state.js");
const { captureEvent } = await import("../telemetry.js"); const { captureEvent } = await import("../telemetry.js");
@@ -290,7 +290,7 @@ export async function runInit(
const email = opts.email.trim().toLowerCase(); const email = opts.email.trim().toLowerCase();
validateEmail(email); validateEmail(email);
printInfo(`Claiming Agent Mode account to ${email}...`); printInfo(`Claiming Agent Mode account to ${email}...`);
await claimViaDeviceFlow(savedConfig, { email }); await claimViaOtp(savedConfig, { email, code: opts.code });
fireInit("email", true); fireInit("email", true);
return; return;
} }
@@ -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 from __future__ import annotations
import secrets import sys
import time
import webbrowser
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx import httpx
import typer import typer
from rich.console import Console from rich.console import Console
from rich.prompt import Prompt
from mem0_cli.branding import ( from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR, DIM_COLOR,
print_error, print_error,
print_info, print_info,
@@ -23,11 +23,6 @@ from mem0_cli.config import Mem0Config, save_config
console = Console() console = Console()
err_console = Console(stderr=True) 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 = { _SOURCE_HEADERS = {
"X-Mem0-Source": "cli", "X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python", "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 <your-email>')}[/]") console.print(f" [{DIM_COLOR}]To claim this account later: {envelope.get('claim_command', 'mem0 init --email <your-email>')}[/]")
def claim_via_device_flow(config: Mem0Config, *, email: str) -> None: def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
"""Run the claim flow against an existing agent-mode config. """Claim an existing Agent Mode account via OTP — no browser, no polling.
Reuses the existing CLI device flow (initiate_cli_login → frontend OTP → Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
associate_cli_token → get_api_key_from_cli_token poll). The raw API key `/.../verify/`) and adds the local agent-mode API key in the verify body
never leaves the device — backend confirms claim, CLI updates only as `agent_mode_api_key`. Backend's `verify_email_code` runs the
`platform.agent_mode` and `platform.claimed_at`. 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("/") base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
if not config.platform.api_key or not config.platform.agent_mode: 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) raise typer.Exit(1)
cli_token = secrets.token_urlsafe(32)
raw_key = config.platform.api_key raw_key = config.platform.api_key
with httpx.Client(timeout=30.0) as client: with httpx.Client(timeout=30.0) as client:
try: # Step 1: request OTP (unless --code provided)
init_resp = client.post( if not code:
f"{base_url}/api/v1/accounts/cli_login/", send = client.post(
json={"token": cli_token, "claim_for_apikey": raw_key}, f"{base_url}/api/v1/auth/email_code/",
headers=_SOURCE_HEADERS, headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={"email": email},
) )
except httpx.HTTPError as exc: if send.status_code == 429:
print_error(err_console, f"Could not initiate claim: {exc}") print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1) from exc raise typer.Exit(1)
if send.status_code != 200:
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
try: try:
err = poll.json().get("error", "") detail = send.json().get("error", send.text)
except Exception: except Exception:
err = poll.text detail = send.text
if "expired" in err.lower(): print_error(err_console, f"Failed to send code: {detail}")
print_error(err_console, "Claim link expired. Run `mem0 init --email <addr>` again.") raise typer.Exit(1)
raise typer.Exit(1)
continue
body = poll.json() print_success(console, f"Verification code sent to {email}. Check your inbox.")
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_error(err_console, "Claim timed out. Run `mem0 init --email <addr>` again.") if not sys.stdin.isatty():
raise typer.Exit(1) print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint=f"Re-run: mem0 init --email {email} --code <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 <key>` 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: def _utcnow_iso() -> str:
+2 -2
View File
@@ -203,7 +203,7 @@ def run_init(
email-based key. email-based key.
""" """
from mem0_cli.agent_detect import detect_agent_caller 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.state import is_agent_mode as _global_agent_mode
from mem0_cli.telemetry import capture_event from mem0_cli.telemetry import capture_event
@@ -235,7 +235,7 @@ def run_init(
email = email.strip().lower() email = email.strip().lower()
_validate_email(email) _validate_email(email)
print_info(console, f"Claiming Agent Mode account to {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) _fire_init("email", claimed=True)
return return