@echomem/mcp 1.4.20 → 1.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js CHANGED
@@ -1,16 +1,17 @@
1
1
  /**
2
- * `echomem-mcp init | setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
2
+ * `echomem-mcp init | setup | login | unlock | status | logout` — local bridge lifecycle (spec §8).
3
3
  *
4
4
  * Design goals from the spec:
5
- * - One command one browser approval one reload.
5
+ * - `login` establishes the account and trusted device only; it never reads local history.
6
+ * - `init` runs one ordered flow: local-history permission/report, login, plan if needed, then extraction.
6
7
  * - Both secrets (API token + encryption key) ride a single browser flow and land in the local
7
8
  * keystore — never in the client's MCP config, never in the agent's chat context.
8
9
  * - Re-unlock after the key's TTL is one step, not a re-setup.
9
10
  *
10
- * The browser flow posts `{ token, key? }` to a localhost callback this process opens. The
11
- * "connect device" web page that drives it is the one piece that lives in the web app (not here);
12
- * until it ships, the same flow is fully usable via the manual flags (`--token`, `--key`,
13
- * `--passphrase`), which is also the documented headless/SSH path (spec §8).
11
+ * Both browser flows stay on a localhost page. Login collects email OTP consent and an encryption
12
+ * passphrase; onboarding separately asks to access local coding history. This process asks the
13
+ * hosted API to send/verify OTP and mint a device token. Secrets land in the local keystore
14
+ * never in the client's MCP config or the agent's chat context.
14
15
  */
15
16
  import http from "node:http";
16
17
  import { randomUUID } from "node:crypto";
@@ -23,7 +24,7 @@ import readline from "node:readline";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
  import axios from "axios";
25
26
  import { KeyStore } from "./keystore.js";
26
- import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
27
+ import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
27
28
  import { collect, runReport, buildStatsPayload } from "./report.js";
28
29
  import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
29
30
  import { syncCodexUsage } from "./codex-sync.js";
@@ -33,12 +34,32 @@ import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
33
34
  import { installHooks } from "./hud/hooks.js";
34
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
35
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
36
- // The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
37
- // served by this localhost bridge, where local logs and processed/unprocessed counts never leave the
38
- // device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
39
- const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
37
+ // The setup dashboard, account login, and encryption passphrase entry are all served by this
38
+ // localhost bridge. The hosted API only sends OTP email, verifies the code, and mints a device token.
40
39
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
41
40
  const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
41
+ function hostedBillingEndpoint(pathname) {
42
+ const url = new URL(PRICING_URL);
43
+ url.pathname = pathname;
44
+ url.search = "";
45
+ url.hash = "";
46
+ return url.toString();
47
+ }
48
+ function isExpectedStripeUrl(value, kind) {
49
+ if (typeof value !== "string")
50
+ return false;
51
+ try {
52
+ const url = new URL(value);
53
+ if (url.protocol !== "https:")
54
+ return false;
55
+ return kind === "checkout"
56
+ ? url.hostname === "checkout.stripe.com"
57
+ : url.hostname === "billing.stripe.com";
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
42
63
  const CODEX_SKILL_NAMES = [
43
64
  "echomem-search",
44
65
  "echomem-save",
@@ -268,6 +289,52 @@ function resolveGlobalEntry() {
268
289
  }
269
290
  return null;
270
291
  }
292
+ export function needsDurableGlobalUpdate(runningEntry, durableEntry, targetVersion = MCP_PACKAGE_VERSION) {
293
+ if (!isEphemeralNpxPath(runningEntry))
294
+ return false;
295
+ const installedVersion = durableEntry
296
+ ? packageVersionFromPath(durableEntry)?.version
297
+ : undefined;
298
+ return installedVersion !== targetVersion;
299
+ }
300
+ function installDurableGlobalUpdate() {
301
+ const runningEntry = (() => {
302
+ try {
303
+ return fs.realpathSync(process.argv[1] || "");
304
+ }
305
+ catch {
306
+ return process.argv[1] || "";
307
+ }
308
+ })();
309
+ const currentGlobalEntry = resolveGlobalEntry();
310
+ if (!needsDurableGlobalUpdate(runningEntry, currentGlobalEntry))
311
+ return;
312
+ const packageSpec = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
313
+ console.log(`Installing durable ${packageSpec} before updating client configs…`);
314
+ const npmExecPath = process.env.npm_execpath;
315
+ try {
316
+ if (npmExecPath && fs.existsSync(npmExecPath)) {
317
+ execFileSync(process.execPath, [npmExecPath, "install", "-g", packageSpec], {
318
+ stdio: "inherit",
319
+ });
320
+ }
321
+ else {
322
+ execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", packageSpec], {
323
+ stdio: "inherit",
324
+ });
325
+ }
326
+ }
327
+ catch (error) {
328
+ throw new Error(`Could not install ${packageSpec} globally: ${error instanceof Error ? error.message : String(error)}`);
329
+ }
330
+ const installedEntry = resolveGlobalEntry();
331
+ const installedVersion = installedEntry
332
+ ? packageVersionFromPath(installedEntry)?.version
333
+ : undefined;
334
+ if (installedVersion !== MCP_PACKAGE_VERSION) {
335
+ throw new Error(`Global EchoMem bridge is ${installedVersion || "missing"} after update; expected ${MCP_PACKAGE_VERSION}.`);
336
+ }
337
+ }
271
338
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
272
339
  export function codexTomlBlock(entry) {
273
340
  const command = JSON.stringify(String(entry.command));
@@ -1191,9 +1258,9 @@ function publicRunningForensicProgress(value) {
1191
1258
  };
1192
1259
  }
1193
1260
  /**
1194
- * Start the persistent localhost bridge used by the connect-device page. It accepts the token,
1195
- * serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
1196
- * import session.
1261
+ * Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
1262
+ * hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
1263
+ * response until cmdLogin has created a cloud import session.
1197
1264
  */
1198
1265
  export function startCallbackServer(opts = {}) {
1199
1266
  const timeoutMs = opts.timeoutMs ?? 15 * 60_000;
@@ -1203,6 +1270,11 @@ export function startCallbackServer(opts = {}) {
1203
1270
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1204
1271
  const expectedNonce = opts.nonce;
1205
1272
  const scanId = opts.scanId ?? randomUUID();
1273
+ const flow = opts.flow ?? "onboarding";
1274
+ const isLoginFlow = flow === "login";
1275
+ // A login screen must not be blocked by a local-history permission. That permission belongs to
1276
+ // onboarding and is intentionally enforced separately below.
1277
+ const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1206
1278
  return new Promise((resolveOuter, rejectOuter) => {
1207
1279
  const onToken = deferred();
1208
1280
  const decision = deferred();
@@ -1210,8 +1282,10 @@ export function startCallbackServer(opts = {}) {
1210
1282
  let stats = null;
1211
1283
  let authUrl = "";
1212
1284
  let switchAccountUrl = "";
1213
- let connected = false;
1214
- let reportConsentGranted = opts.requireReportConsent !== true;
1285
+ let connected = Boolean(opts.initialToken?.token);
1286
+ let activeDeviceToken = opts.initialToken?.token || "";
1287
+ let pendingLocalAuth = null;
1288
+ let reportConsentGranted = !requiresReportConsent;
1215
1289
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1216
1290
  let migrateStarted = false;
1217
1291
  let tokenRefreshHandler = null;
@@ -1227,6 +1301,26 @@ export function startCallbackServer(opts = {}) {
1227
1301
  const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
1228
1302
  const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
1229
1303
  const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
1304
+ const revokeDeviceToken = async (token) => {
1305
+ try {
1306
+ await authedAxios(token).post("/api/extension/mcp/local-auth/revoke-device", {}, { timeout: 10_000 });
1307
+ return true;
1308
+ }
1309
+ catch (error) {
1310
+ console.error(`Could not revoke incomplete local login: ${error instanceof Error ? error.message : String(error)}`);
1311
+ return false;
1312
+ }
1313
+ };
1314
+ const revokePendingLocalAuth = async () => {
1315
+ const pending = pendingLocalAuth;
1316
+ pendingLocalAuth = null;
1317
+ if (!pending)
1318
+ return false;
1319
+ return revokeDeviceToken(pending.token);
1320
+ };
1321
+ const completeDeviceLogin = async (token) => {
1322
+ await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", {}, { timeout: 10_000 });
1323
+ };
1230
1324
  const close = () => {
1231
1325
  if (timer)
1232
1326
  clearTimeout(timer);
@@ -1234,6 +1328,7 @@ export function startCallbackServer(opts = {}) {
1234
1328
  if (closed)
1235
1329
  return;
1236
1330
  closed = true;
1331
+ void revokePendingLocalAuth();
1237
1332
  server.close();
1238
1333
  for (const socket of sockets)
1239
1334
  socket.destroy();
@@ -1245,7 +1340,7 @@ export function startCallbackServer(opts = {}) {
1245
1340
  const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
1246
1341
  timer = setTimeout(() => {
1247
1342
  if (!onToken.settled()) {
1248
- onToken.reject(new Error(`browser approval did not finish in ${approvalTimeoutLabel}`));
1343
+ onToken.reject(new Error(`local setup did not finish in ${approvalTimeoutLabel}`));
1249
1344
  }
1250
1345
  else if (!decision.settled()) {
1251
1346
  decision.resolve("timeout");
@@ -1257,7 +1352,7 @@ export function startCallbackServer(opts = {}) {
1257
1352
  const handleCallback = (res, token, key, nonce) => {
1258
1353
  if (!checkNonce(nonce))
1259
1354
  return void text(res, 403, "bad nonce");
1260
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1355
+ if (requiresReportConsent && !reportConsentGranted) {
1261
1356
  return void json(res, 403, {
1262
1357
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1263
1358
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1265,9 +1360,10 @@ export function startCallbackServer(opts = {}) {
1265
1360
  }
1266
1361
  if (!token)
1267
1362
  return void text(res, 400, "missing token");
1268
- console.log(`[${new Date().toISOString()}] Browser approval callback received.`);
1363
+ console.log(`[${new Date().toISOString()}] Device token callback received.`);
1269
1364
  const firstToken = !onToken.settled();
1270
1365
  connected = true;
1366
+ activeDeviceToken = token;
1271
1367
  const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
1272
1368
  res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local setup page...</p><p><a href="${setupPath}">Continue</a></p><script>(function(){var target=${JSON.stringify(setupPath)};try{if(window.opener&&!window.opener.closed){window.opener.postMessage({type:"echomem:connected",nonce:${JSON.stringify(nonce || "")}},window.location.origin);window.close();setTimeout(function(){window.location.href=target;},500);return;}}catch(_){}window.location.href=target;})();</script></body></html>`);
1273
1369
  const callbackToken = { token, key };
@@ -1281,17 +1377,198 @@ export function startCallbackServer(opts = {}) {
1281
1377
  }
1282
1378
  armTimeout();
1283
1379
  };
1380
+ const rejectIfLocalAuthBlocked = (res, nonce) => {
1381
+ if (!checkNonce(nonce)) {
1382
+ text(res, 403, "bad nonce");
1383
+ return true;
1384
+ }
1385
+ if (requiresReportConsent && !reportConsentGranted) {
1386
+ json(res, 403, {
1387
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1388
+ message: "Allow local history access in the setup page before connecting EchoMem.",
1389
+ });
1390
+ return true;
1391
+ }
1392
+ return false;
1393
+ };
1394
+ const resolveLocalToken = (token, key) => {
1395
+ const firstToken = !onToken.settled();
1396
+ connected = true;
1397
+ activeDeviceToken = token;
1398
+ pendingLocalAuth = null;
1399
+ const callbackToken = { token, key };
1400
+ if (firstToken) {
1401
+ onToken.resolve(callbackToken);
1402
+ }
1403
+ else if (tokenRefreshHandler) {
1404
+ Promise.resolve(tokenRefreshHandler(callbackToken)).catch((e) => {
1405
+ console.error(`Could not refresh local login: ${e instanceof Error ? e.message : String(e)}`);
1406
+ });
1407
+ }
1408
+ armTimeout();
1409
+ };
1410
+ const isOnboardingOnlyRoute = (route) => [
1411
+ "/report-consent",
1412
+ "/report",
1413
+ "/stats",
1414
+ "/billing-status",
1415
+ "/billing-checkout",
1416
+ "/billing-portal",
1417
+ "/progress",
1418
+ "/migrate",
1419
+ "/skip",
1420
+ ].includes(route);
1421
+ const handleLocalSendOtp = async (res, body) => {
1422
+ if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1423
+ return;
1424
+ const email = normalizeEmail(body.email);
1425
+ if (!validEmail(email))
1426
+ return void json(res, 400, { ok: false, error: "Invalid email address" });
1427
+ if (!acceptedLocalTerms(body)) {
1428
+ return void json(res, 400, {
1429
+ ok: false,
1430
+ error: "Confirm your age and accept Echo's terms before continuing.",
1431
+ });
1432
+ }
1433
+ try {
1434
+ const response = await apiAxios().post("/api/extension/mcp/local-auth/send-otp", {
1435
+ email,
1436
+ acceptedTerms: true,
1437
+ ageConfirmed: true,
1438
+ }, { timeout: 10_000 });
1439
+ json(res, 200, { ok: true, email: asString(response.data?.email) || email });
1440
+ }
1441
+ catch (error) {
1442
+ const detail = publicAxiosError(error, "Failed to send verification code");
1443
+ json(res, detail.status, { ok: false, error: detail.message });
1444
+ }
1445
+ };
1446
+ const handleLocalVerifyOtp = async (res, body) => {
1447
+ if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1448
+ return;
1449
+ const email = normalizeEmail(body.email);
1450
+ const otp = asString(body.otp)?.trim() || "";
1451
+ if (!validEmail(email))
1452
+ return void json(res, 400, { ok: false, error: "Invalid email address" });
1453
+ if (!/^\d{6}$/.test(otp)) {
1454
+ return void json(res, 400, { ok: false, error: "Enter the 6-digit verification code." });
1455
+ }
1456
+ if (!acceptedLocalTerms(body)) {
1457
+ return void json(res, 400, {
1458
+ ok: false,
1459
+ error: "Confirm your age and accept Echo's terms before continuing.",
1460
+ });
1461
+ }
1462
+ try {
1463
+ const response = await apiAxios().post("/api/extension/mcp/local-auth/verify-otp", {
1464
+ email,
1465
+ otp,
1466
+ acceptedTerms: true,
1467
+ ageConfirmed: true,
1468
+ }, { timeout: 15_000 });
1469
+ const token = asString(response.data?.api_key);
1470
+ const userId = asString(response.data?.user_id);
1471
+ if (!token || !userId) {
1472
+ return void json(res, 502, { ok: false, error: "The EchoMem API did not return a device token." });
1473
+ }
1474
+ let config;
1475
+ try {
1476
+ config = await fetchEncryptionConfig(authedAxios(token));
1477
+ }
1478
+ catch (error) {
1479
+ await revokeDeviceToken(token);
1480
+ throw error;
1481
+ }
1482
+ await revokePendingLocalAuth();
1483
+ pendingLocalAuth = {
1484
+ token,
1485
+ userId,
1486
+ email: asString(response.data?.email) || email,
1487
+ mode: config.enabled ? "unlock" : "setup",
1488
+ config: config.enabled ? config : undefined,
1489
+ expiresAtMs: Date.now() + 10 * 60_000,
1490
+ };
1491
+ json(res, 200, {
1492
+ ok: true,
1493
+ stage: "passphrase",
1494
+ mode: pendingLocalAuth.mode,
1495
+ email: pendingLocalAuth.email,
1496
+ });
1497
+ }
1498
+ catch (error) {
1499
+ const detail = publicAxiosError(error, "Failed to verify code");
1500
+ json(res, detail.status, { ok: false, error: detail.message });
1501
+ }
1502
+ };
1503
+ const handleLocalPassphrase = async (res, body) => {
1504
+ if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1505
+ return;
1506
+ const passphrase = typeof body.passphrase === "string" ? body.passphrase : "";
1507
+ if (passphrase.length < 4) {
1508
+ return void json(res, 400, { ok: false, error: "Enter an encryption passphrase with at least 4 characters." });
1509
+ }
1510
+ if (!pendingLocalAuth || pendingLocalAuth.expiresAtMs <= Date.now()) {
1511
+ await revokePendingLocalAuth();
1512
+ return void json(res, 409, {
1513
+ ok: false,
1514
+ error: "This local login expired. Send a new verification code.",
1515
+ reset: true,
1516
+ });
1517
+ }
1518
+ try {
1519
+ if (pendingLocalAuth.mode === "unlock") {
1520
+ const key = pendingLocalAuth.config
1521
+ ? await deriveAndVerifyKey(passphrase, pendingLocalAuth.config)
1522
+ : null;
1523
+ if (!key)
1524
+ return void json(res, 400, { ok: false, error: "Incorrect encryption passphrase." });
1525
+ await completeDeviceLogin(pendingLocalAuth.token);
1526
+ resolveLocalToken(pendingLocalAuth.token, key);
1527
+ return void json(res, 200, { ok: true, connected: true });
1528
+ }
1529
+ const setup = await setupNewEncryptionKey(passphrase);
1530
+ await authedAxios(pendingLocalAuth.token).post("/api/extension/account/encryption", {
1531
+ salt: setup.saltBase64,
1532
+ verification: setup.verification,
1533
+ iterations: setup.iterations,
1534
+ }, {
1535
+ timeout: 10_000,
1536
+ headers: { "X-Encryption-Key": setup.keyBase64 },
1537
+ });
1538
+ pendingLocalAuth.mode = "unlock";
1539
+ pendingLocalAuth.config = {
1540
+ enabled: true,
1541
+ salt: setup.saltBase64,
1542
+ verification: setup.verification,
1543
+ iterations: setup.iterations,
1544
+ };
1545
+ await completeDeviceLogin(pendingLocalAuth.token);
1546
+ resolveLocalToken(pendingLocalAuth.token, setup.keyBase64);
1547
+ json(res, 200, { ok: true, connected: true });
1548
+ }
1549
+ catch (error) {
1550
+ const detail = publicAxiosError(error, "Failed to unlock encrypted memory");
1551
+ json(res, detail.status, { ok: false, error: detail.message });
1552
+ }
1553
+ };
1284
1554
  server = http.createServer((req, res) => {
1285
1555
  res.setHeader("Access-Control-Allow-Origin", "*");
1286
1556
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1287
1557
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1288
- // Let the HTTPS connect-device page reach this localhost server (Chrome Private Network Access).
1289
- res.setHeader("Access-Control-Allow-Private-Network", "true");
1290
1558
  if (req.method === "OPTIONS")
1291
1559
  return void res.writeHead(204).end();
1292
1560
  const url = new URL(req.url || "/", "http://127.0.0.1");
1293
1561
  const route = url.pathname;
1294
1562
  const run = async () => {
1563
+ // Defense in depth: the login bridge never exposes the routes that can inspect or move
1564
+ // local conversation history. The browser UI also routes around them, but the server is
1565
+ // the authority for this privacy boundary.
1566
+ if (isLoginFlow && isOnboardingOnlyRoute(route)) {
1567
+ return void json(res, 404, {
1568
+ error: "ONBOARDING_REQUIRED",
1569
+ message: "Run `echomem-mcp init` to access local-history onboarding.",
1570
+ });
1571
+ }
1295
1572
  if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
1296
1573
  serveRepoCityAsset(route, res);
1297
1574
  return;
@@ -1327,11 +1604,13 @@ export function startCallbackServer(opts = {}) {
1327
1604
  return void text(res, 403, "bad nonce");
1328
1605
  json(res, 200, {
1329
1606
  connected,
1607
+ flow,
1330
1608
  authUrl,
1331
1609
  switchAccountUrl: switchAccountUrl || authUrl,
1332
1610
  localOnly: true,
1611
+ localAuth: true,
1333
1612
  workspacePath: process.cwd(),
1334
- consentRequired: opts.requireReportConsent === true,
1613
+ consentRequired: requiresReportConsent,
1335
1614
  consentGranted: reportConsentGranted,
1336
1615
  });
1337
1616
  return;
@@ -1385,10 +1664,46 @@ export function startCallbackServer(opts = {}) {
1385
1664
  handleCallback(res, asString(body.token), asString(body.key), asString(body.nonce));
1386
1665
  return;
1387
1666
  }
1667
+ if (route === "/local-auth/send-otp" && req.method === "POST") {
1668
+ let body;
1669
+ try {
1670
+ body = await readJsonBody(req);
1671
+ }
1672
+ catch {
1673
+ text(res, 400, "bad json");
1674
+ return;
1675
+ }
1676
+ await handleLocalSendOtp(res, body);
1677
+ return;
1678
+ }
1679
+ if (route === "/local-auth/verify-otp" && req.method === "POST") {
1680
+ let body;
1681
+ try {
1682
+ body = await readJsonBody(req);
1683
+ }
1684
+ catch {
1685
+ text(res, 400, "bad json");
1686
+ return;
1687
+ }
1688
+ await handleLocalVerifyOtp(res, body);
1689
+ return;
1690
+ }
1691
+ if (route === "/local-auth/passphrase" && req.method === "POST") {
1692
+ let body;
1693
+ try {
1694
+ body = await readJsonBody(req);
1695
+ }
1696
+ catch {
1697
+ text(res, 400, "bad json");
1698
+ return;
1699
+ }
1700
+ await handleLocalPassphrase(res, body);
1701
+ return;
1702
+ }
1388
1703
  if (route === "/stats" && req.method === "GET") {
1389
1704
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1390
1705
  return void text(res, 403, "bad nonce");
1391
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1706
+ if (requiresReportConsent && !reportConsentGranted) {
1392
1707
  return void json(res, 403, {
1393
1708
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1394
1709
  message: "Allow local history access before continuing setup.",
@@ -1403,13 +1718,13 @@ export function startCallbackServer(opts = {}) {
1403
1718
  if (route === "/billing-status" && req.method === "GET") {
1404
1719
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1405
1720
  return void text(res, 403, "bad nonce");
1406
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1721
+ if (requiresReportConsent && !reportConsentGranted) {
1407
1722
  return void json(res, 403, {
1408
1723
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1409
1724
  message: "Allow local history access before continuing setup.",
1410
1725
  });
1411
1726
  }
1412
- const token = new KeyStore().getToken();
1727
+ const token = activeDeviceToken || new KeyStore().getToken();
1413
1728
  const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1414
1729
  if (!token) {
1415
1730
  json(res, 200, { plan: "free", paid: false, trialAvailable: true, trialUsed: false, pricingUrl });
@@ -1445,12 +1760,84 @@ export function startCallbackServer(opts = {}) {
1445
1760
  }
1446
1761
  return;
1447
1762
  }
1763
+ if ((route === "/billing-checkout" || route === "/billing-portal") && req.method === "POST") {
1764
+ let body;
1765
+ try {
1766
+ body = await readJsonBody(req);
1767
+ }
1768
+ catch {
1769
+ text(res, 400, "bad json");
1770
+ return;
1771
+ }
1772
+ if (!checkNonce(asString(body.nonce)))
1773
+ return void text(res, 403, "bad nonce");
1774
+ if (requiresReportConsent && !reportConsentGranted) {
1775
+ return void json(res, 403, {
1776
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1777
+ message: "Allow local history access before managing an onboarding plan.",
1778
+ });
1779
+ }
1780
+ const token = activeDeviceToken || new KeyStore().getToken();
1781
+ if (!connected || !token) {
1782
+ return void json(res, 401, {
1783
+ error: "ECHOMEM_LOGIN_REQUIRED",
1784
+ message: "Connect your EchoMem account before continuing to billing.",
1785
+ });
1786
+ }
1787
+ const isCheckout = route === "/billing-checkout";
1788
+ const requestBody = { source: "mcp_onboarding" };
1789
+ if (isCheckout) {
1790
+ const plan = asString(body.plan)?.toLowerCase();
1791
+ if (plan !== "pro" && plan !== "power") {
1792
+ return void json(res, 400, {
1793
+ error: "INVALID_PLAN",
1794
+ message: "Choose Pro or Power to continue to checkout.",
1795
+ });
1796
+ }
1797
+ requestBody.plan = plan;
1798
+ requestBody.trial = body.trial === true;
1799
+ }
1800
+ else {
1801
+ const plan = asString(body.plan)?.toLowerCase();
1802
+ if (plan) {
1803
+ if (plan !== "pro" && plan !== "power") {
1804
+ return void json(res, 400, {
1805
+ error: "INVALID_PLAN",
1806
+ message: "Choose Pro or Power to change plans.",
1807
+ });
1808
+ }
1809
+ requestBody.plan = plan;
1810
+ }
1811
+ }
1812
+ try {
1813
+ const response = await axios.post(hostedBillingEndpoint(isCheckout ? "/api/billing/checkout" : "/api/billing/portal"), requestBody, {
1814
+ timeout: 15_000,
1815
+ headers: {
1816
+ "Content-Type": "application/json",
1817
+ Authorization: `Bearer ${token}`,
1818
+ },
1819
+ });
1820
+ const hostedUrl = response.data?.url;
1821
+ if (!isExpectedStripeUrl(hostedUrl, isCheckout ? "checkout" : "portal")) {
1822
+ return void json(res, 502, {
1823
+ error: "INVALID_BILLING_DESTINATION",
1824
+ message: "Echo billing returned an unexpected destination.",
1825
+ });
1826
+ }
1827
+ json(res, 200, { url: hostedUrl });
1828
+ }
1829
+ catch (error) {
1830
+ const detail = publicAxiosError(error, isCheckout ? "Could not start secure checkout." : "Could not open secure plan management.");
1831
+ json(res, detail.status, { error: "BILLING_REQUEST_FAILED", message: detail.message });
1832
+ }
1833
+ return;
1834
+ }
1448
1835
  if (route === "/report" && req.method === "GET") {
1449
1836
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1450
1837
  res.setHeader("Cache-Control", "no-store");
1451
1838
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1452
1839
  return void text(res, 403, "bad nonce");
1453
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1840
+ if (requiresReportConsent && !reportConsentGranted) {
1454
1841
  return void json(res, 403, {
1455
1842
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1456
1843
  message: "Allow local history access before starting the local scan.",
@@ -1551,7 +1938,7 @@ export function startCallbackServer(opts = {}) {
1551
1938
  if (route === "/progress" && req.method === "GET") {
1552
1939
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1553
1940
  return void text(res, 403, "bad nonce");
1554
- if (opts.requireReportConsent === true && !reportConsentGranted) {
1941
+ if (requiresReportConsent && !reportConsentGranted) {
1555
1942
  return void json(res, 403, {
1556
1943
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1557
1944
  message: "Allow local history access before continuing setup.",
@@ -1599,10 +1986,18 @@ export function startCallbackServer(opts = {}) {
1599
1986
  /* already logged out locally */
1600
1987
  }
1601
1988
  connected = false;
1989
+ activeDeviceToken = "";
1990
+ const revokedPendingCredential = await revokePendingLocalAuth();
1602
1991
  stats = null;
1603
1992
  migrateStarted = false;
1604
1993
  progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1605
- json(res, 200, { ok: true, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
1994
+ json(res, 200, {
1995
+ ok: true,
1996
+ authUrl,
1997
+ switchAccountUrl: switchAccountUrl || authUrl,
1998
+ localOnly: true,
1999
+ revokedPendingCredential,
2000
+ });
1606
2001
  Promise.resolve()
1607
2002
  .then(() => logoutHandler?.())
1608
2003
  .catch((e) => {
@@ -1621,7 +2016,7 @@ export function startCallbackServer(opts = {}) {
1621
2016
  }
1622
2017
  if (!checkNonce(asString(body.nonce)))
1623
2018
  return void text(res, 403, "bad nonce");
1624
- if (opts.requireReportConsent === true && !reportConsentGranted) {
2019
+ if (requiresReportConsent && !reportConsentGranted) {
1625
2020
  return void json(res, 403, {
1626
2021
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1627
2022
  message: "Allow local history access before starting extraction.",
@@ -1682,6 +2077,8 @@ export function startCallbackServer(opts = {}) {
1682
2077
  sockets.add(socket);
1683
2078
  socket.on("close", () => sockets.delete(socket));
1684
2079
  });
2080
+ if (opts.initialToken)
2081
+ onToken.resolve(opts.initialToken);
1685
2082
  armTimeout();
1686
2083
  server.on("error", (e) => rejectOuter(e));
1687
2084
  server.listen(opts.port ?? 0, "127.0.0.1", () => {
@@ -1726,6 +2123,34 @@ function authedAxios(token) {
1726
2123
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
1727
2124
  });
1728
2125
  }
2126
+ function apiAxios() {
2127
+ return axios.create({
2128
+ baseURL: API_BASE_URL,
2129
+ headers: { "Content-Type": "application/json" },
2130
+ });
2131
+ }
2132
+ function normalizeEmail(value) {
2133
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
2134
+ }
2135
+ function validEmail(value) {
2136
+ return value.length > 0 && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
2137
+ }
2138
+ function acceptedLocalTerms(body) {
2139
+ return body.acceptedTerms === true && body.ageConfirmed === true;
2140
+ }
2141
+ function publicAxiosError(error, fallback) {
2142
+ if (axios.isAxiosError(error)) {
2143
+ const data = error.response?.data;
2144
+ const status = typeof error.response?.status === "number" ? error.response.status : 500;
2145
+ const message = typeof data?.error === "string"
2146
+ ? data.error
2147
+ : typeof data?.message === "string"
2148
+ ? data.message
2149
+ : fallback;
2150
+ return { message, status };
2151
+ }
2152
+ return { message: error instanceof Error ? error.message : fallback, status: 500 };
2153
+ }
1729
2154
  function formatVerificationError(error) {
1730
2155
  if (axios.isAxiosError(error)) {
1731
2156
  const status = typeof error.response?.status === "number" ? error.response.status : null;
@@ -1888,9 +2313,9 @@ async function cmdSetup(flags) {
1888
2313
  /**
1889
2314
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
1890
2315
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
1891
- * Codex skills, writes the AGENTS.md memory guidance, logs in via the browser, and launches the
1892
- * context HUD the whole product in a single command. `setup`/`update` remain the granular
1893
- * primitives; init just picks the "do everything" defaults and frames the result.
2316
+ * Codex skills, writes the AGENTS.md memory guidance, and launches the context HUD. One browser
2317
+ * bridge then runs permission report login plan if needed extraction in that order.
2318
+ * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
1894
2319
  */
1895
2320
  async function cmdInit(flags) {
1896
2321
  console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
@@ -1899,10 +2324,10 @@ async function cmdInit(flags) {
1899
2324
  // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
1900
2325
  if (!flags["no-hud"])
1901
2326
  await cmdSetupHud(flags);
1902
- // 3. Start onboarding opens the browser dashboard (scan connect extraction) and waits there.
2327
+ // 3. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
1903
2328
  console.log("");
1904
- if (!flags["skip-login"] && !flags["no-login"] && !await cmdLogin(flags)) {
1905
- console.log("\nEchoMem is configured, but this device was not connected. Restart EchoMem and use the new page it opens.");
2329
+ if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2330
+ console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
1906
2331
  return;
1907
2332
  }
1908
2333
  console.log("");
@@ -1963,6 +2388,8 @@ function writeCodexSkillsForTargets(targets) {
1963
2388
  }
1964
2389
  }
1965
2390
  async function cmdUpdate(flags) {
2391
+ if (!flags.dev)
2392
+ installDurableGlobalUpdate();
1966
2393
  await cmdSetup({ ...flags, "skip-login": true });
1967
2394
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
1968
2395
  }
@@ -2027,6 +2454,19 @@ function parseHudClient(value) {
2027
2454
  ? value
2028
2455
  : "auto";
2029
2456
  }
2457
+ function localBridgeOptions(flags) {
2458
+ const port = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2459
+ if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) {
2460
+ throw new Error("--dev-port must be an integer between 1024 and 65535");
2461
+ }
2462
+ const requestedNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
2463
+ if (requestedNonce && port === undefined)
2464
+ throw new Error("--dev-nonce requires --dev-port");
2465
+ if (requestedNonce && !/^[A-Za-z0-9-]{16,128}$/.test(requestedNonce)) {
2466
+ throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
2467
+ }
2468
+ return { port, nonce: requestedNonce || randomUUID() };
2469
+ }
2030
2470
  async function cmdLogin(flags) {
2031
2471
  // Manual path (also the headless path): secrets supplied as flags.
2032
2472
  if (typeof flags.token === "string") {
@@ -2039,20 +2479,51 @@ async function cmdLogin(flags) {
2039
2479
  process.exitCode = 1;
2040
2480
  return ok;
2041
2481
  }
2042
- // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
2043
- // after the web page has delivered the token+key to the callback. The nonce gates every local route.
2044
- console.log("Opening your browser to approve this device…");
2045
- const devPortRaw = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2046
- if (devPortRaw !== undefined && (!Number.isInteger(devPortRaw) || devPortRaw < 1024 || devPortRaw > 65535)) {
2047
- throw new Error("--dev-port must be an integer between 1024 and 65535");
2482
+ const store = new KeyStore();
2483
+ if (!flags.force && store.getToken() && store.getKey()) {
2484
+ console.log("This device is already connected. Run `echomem-mcp login --force` to sign in with a different account.");
2485
+ return true;
2048
2486
  }
2049
- const devNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
2050
- if (devNonce && devPortRaw === undefined)
2051
- throw new Error("--dev-nonce requires --dev-port");
2052
- if (devNonce && !/^[A-Za-z0-9-]{16,128}$/.test(devNonce)) {
2053
- throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
2487
+ // Browser path: this bridge does only account/device authentication. It intentionally exposes
2488
+ // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2489
+ console.log("Opening your browser to connect this device locally…");
2490
+ const { port, nonce } = localBridgeOptions(flags);
2491
+ const srv = await startCallbackServer({ port, nonce, flow: "login" });
2492
+ const localLoginUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2493
+ openBrowser(localLoginUrl);
2494
+ console.log(`If it didn't open, visit:\n ${localLoginUrl}\n`);
2495
+ console.log("Waiting for local login for up to 15 minutes…");
2496
+ try {
2497
+ const credentials = await srv.onToken;
2498
+ const ok = await verifyAndPrint(credentials);
2499
+ srv.close();
2500
+ if (!ok) {
2501
+ process.exitCode = 1;
2502
+ return false;
2503
+ }
2504
+ console.log("✅ This device is connected. Run `echomem-mcp init` to begin local-history onboarding.");
2505
+ return true;
2054
2506
  }
2055
- const nonce = devNonce || randomUUID();
2507
+ catch (error) {
2508
+ srv.close();
2509
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}. Run \`echomem-mcp login\` to retry.`);
2510
+ process.exitCode = 1;
2511
+ return false;
2512
+ }
2513
+ }
2514
+ /**
2515
+ * The local-history onboarding flow. Existing device credentials are reused when available; a
2516
+ * fresh device stays in this same bridge and asks for login only after permission and report.
2517
+ */
2518
+ async function cmdOnboarding(flags) {
2519
+ const store = new KeyStore();
2520
+ const savedToken = store.getToken();
2521
+ const savedKey = store.getKey();
2522
+ const initialToken = savedToken && savedKey
2523
+ ? { token: savedToken, key: savedKey }
2524
+ : undefined;
2525
+ console.log("Opening your browser for EchoMem onboarding…");
2526
+ const { port, nonce } = localBridgeOptions(flags);
2056
2527
  let stats = null;
2057
2528
  let forensicReport = null;
2058
2529
  let forensicConsent = "pending";
@@ -2071,8 +2542,10 @@ async function cmdLogin(flags) {
2071
2542
  updatedAt: forensicStartedAt,
2072
2543
  };
2073
2544
  const srv = await startCallbackServer({
2074
- port: devPortRaw,
2545
+ port,
2075
2546
  nonce,
2547
+ flow: "onboarding",
2548
+ initialToken,
2076
2549
  requireReportConsent: true,
2077
2550
  getStats: () => stats,
2078
2551
  getReport: () => forensicReport,
@@ -2113,18 +2586,10 @@ async function cmdLogin(flags) {
2113
2586
  startForensicScan();
2114
2587
  },
2115
2588
  });
2116
- const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
2117
2589
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2118
- const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
2119
- const switchAccountUrl = new URL(connectUrl);
2120
- // The hosted connect-device page should clear its own Supabase/browser session before minting
2121
- // the localhost token when this hint is present. Localhost cannot safely clear yeahecho.com auth.
2122
- switchAccountUrl.searchParams.set("force_signout", "1");
2123
- switchAccountUrl.searchParams.set("prompt", "login");
2124
- srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
2125
2590
  openBrowser(localSetupUrl);
2126
2591
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
2127
- console.log("Waiting for browser approval for up to 15 minutes…");
2592
+ console.log("Waiting for local-history onboarding for up to 15 minutes…");
2128
2593
  const startForensicScan = () => {
2129
2594
  if (forensicScanStarted || forensicConsent !== "allowed")
2130
2595
  return;
@@ -2872,15 +3337,16 @@ function cmdLogout() {
2872
3337
  const HELP = `EchoMem MCP — local memory bridge
2873
3338
 
2874
3339
  Usage:
2875
- echomem-mcp init One command: configure every installed agent + HUD + log in
3340
+ echomem-mcp init One command: configure agents + HUD + login + local-history onboarding
2876
3341
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
2877
- echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
3342
+ echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
2878
3343
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
2879
3344
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
2880
- echomem-mcp update --all Repoint detected clients to this installed bridge; no login/browser
3345
+ echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
2881
3346
  echomem-mcp update --client X Repoint one MCP client; no login/browser
2882
3347
  echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
2883
- echomem-mcp login Approve this device in the browser (or --token/--passphrase)
3348
+ echomem-mcp login Connect this device only; never scans or imports local history
3349
+ echomem-mcp login --force Reconnect this device with a different account
2884
3350
  echomem-mcp unlock Privately unlock the vault on this trusted device
2885
3351
  echomem-mcp lock Remove the local vault key while keeping the device login
2886
3352
  echomem-mcp status Show token/key/clients