@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/README.md +1 -1
- package/assets/hud/echo-pricing-free-sticker.png +0 -0
- package/assets/hud/echo-pricing-power-sticker.png +0 -0
- package/assets/hud/echo-pricing-pro-sticker.png +0 -0
- package/assets/hud/scan-loop/frame-01-ready.png +0 -0
- package/assets/hud/scan-loop/frame-02-open.png +0 -0
- package/assets/hud/scan-loop/frame-03-pull.png +0 -0
- package/assets/hud/scan-loop/frame-04-read.png +0 -0
- package/assets/hud/scan-loop/frame-05-file.png +0 -0
- package/assets/hud/scan-loop/frame-06-close.png +0 -0
- package/dist/crypto.js +4 -0
- package/dist/encryption.js +14 -2
- package/dist/index.js +52 -117
- package/dist/migrate.js +38 -1
- package/dist/setup-page/client-core.js +286 -26
- package/dist/setup-page/client-extraction.js +322 -101
- package/dist/setup-page/client-lifecycle.js +46 -44
- package/dist/setup-page/client-report-audit.js +1 -1
- package/dist/setup-page/client-report-city.js +35 -22
- package/dist/setup-page/document.js +1 -1
- package/dist/setup-page/styles-extraction.js +455 -0
- package/dist/setup-page/styles-mvp.js +976 -0
- package/dist/setup-page/styles-website-alignment.js +237 -5
- package/dist/setup-page/styles.js +2 -0
- package/dist/setup-page.js +1 -0
- package/dist/setup-preview.js +142 -37
- package/dist/setup.js +527 -61
- package/dist/v1-contract.js +1 -11
- package/package.json +6 -2
package/dist/setup.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `echomem-mcp init | setup | login | unlock | status | logout` —
|
|
2
|
+
* `echomem-mcp init | setup | login | unlock | status | logout` — local bridge lifecycle (spec §8).
|
|
3
3
|
*
|
|
4
4
|
* Design goals from the spec:
|
|
5
|
-
* -
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
37
|
-
//
|
|
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
|
|
1195
|
-
* serves local Wrapped stats, and holds the /migrate
|
|
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 =
|
|
1214
|
-
let
|
|
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(`
|
|
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 (
|
|
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()}]
|
|
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:
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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, {
|
|
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 (
|
|
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,
|
|
1892
|
-
*
|
|
1893
|
-
* primitives; init
|
|
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
|
|
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
|
|
1905
|
-
console.log("\nEchoMem is configured, but
|
|
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
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
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
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|