feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Persistent config (sync/local) vs session token.
|
||||
*/
|
||||
|
||||
const SYNC_KEYS = [
|
||||
"baseUrl",
|
||||
"rootUri",
|
||||
"rootPath",
|
||||
"userId",
|
||||
"email",
|
||||
"connectedAt",
|
||||
/** 上次保存选用的分组路径,扩展确认层默认选中 */
|
||||
"lastFolderPath",
|
||||
/**
|
||||
* 去重记忆(不跨浏览器配置同步,仅 local):
|
||||
* - savedSites: { [origin]: { [usernameKey]: { credentialId, passwordFp, savedAt, hasSession? } } }
|
||||
* - dismissedSites: { [origin]: { [usernameKey]: dismissedAt }
|
||||
* - neverAskOrigins: { [origin]: true }
|
||||
*/
|
||||
"savedSites",
|
||||
"dismissedSites",
|
||||
"neverAskOrigins",
|
||||
];
|
||||
const SESSION_KEYS = ["token", "tokenExpiresAt", "jti"];
|
||||
|
||||
/** Normalize username for dedupe keys. */
|
||||
export function normalizeUsernameKey(username) {
|
||||
return String(username || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight non-cryptographic fingerprint for password equality checks.
|
||||
* Not a security hash — only used to avoid re-prompting the same login.
|
||||
* @param {string} password
|
||||
*/
|
||||
export function passwordFingerprint(password) {
|
||||
const s = String(password || "");
|
||||
// FNV-1a 32-bit + length, enough for local dedupe.
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return `fp_${(h >>> 0).toString(16)}_${s.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pageUrl
|
||||
* @returns {string} origin or ""
|
||||
*/
|
||||
export function originFromUrl(pageUrl) {
|
||||
try {
|
||||
return new URL(String(pageUrl || "")).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{savedSites:object, dismissedSites:object, neverAskOrigins:object}>}
|
||||
*/
|
||||
export async function loadDedupeState() {
|
||||
const data = await chrome.storage.local.get([
|
||||
"savedSites",
|
||||
"dismissedSites",
|
||||
"neverAskOrigins",
|
||||
]);
|
||||
return {
|
||||
savedSites:
|
||||
data.savedSites && typeof data.savedSites === "object"
|
||||
? data.savedSites
|
||||
: {},
|
||||
dismissedSites:
|
||||
data.dismissedSites && typeof data.dismissedSites === "object"
|
||||
? data.dismissedSites
|
||||
: {},
|
||||
neverAskOrigins:
|
||||
data.neverAskOrigins && typeof data.neverAskOrigins === "object"
|
||||
? data.neverAskOrigins
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a successful save so the same site+user+password does not re-prompt.
|
||||
* @param {{ pageUrl: string, username?: string, password?: string, credentialId?: string, hasSession?: boolean }} info
|
||||
*/
|
||||
export async function rememberSavedSite(info) {
|
||||
const origin = originFromUrl(info.pageUrl);
|
||||
if (!origin) return;
|
||||
const userKey = normalizeUsernameKey(info.username) || "_";
|
||||
const state = await loadDedupeState();
|
||||
const byOrigin = { ...(state.savedSites[origin] || {}) };
|
||||
byOrigin[userKey] = {
|
||||
credentialId: info.credentialId || byOrigin[userKey]?.credentialId || "",
|
||||
passwordFp: passwordFingerprint(info.password || ""),
|
||||
savedAt: new Date().toISOString(),
|
||||
hasSession: Boolean(info.hasSession),
|
||||
};
|
||||
const savedSites = { ...state.savedSites, [origin]: byOrigin };
|
||||
// Clear dismiss for this user after explicit save.
|
||||
const dismissed = { ...(state.dismissedSites[origin] || {}) };
|
||||
delete dismissed[userKey];
|
||||
const dismissedSites = { ...state.dismissedSites };
|
||||
if (Object.keys(dismissed).length) dismissedSites[origin] = dismissed;
|
||||
else delete dismissedSites[origin];
|
||||
await chrome.storage.local.set({ savedSites, dismissedSites });
|
||||
}
|
||||
|
||||
/**
|
||||
* User cancelled save prompt for this site+username (suppress for a while).
|
||||
* @param {{ pageUrl: string, username?: string, forever?: boolean }} info
|
||||
*/
|
||||
export async function rememberDismissedSite(info) {
|
||||
const origin = originFromUrl(info.pageUrl);
|
||||
if (!origin) return;
|
||||
if (info.forever) {
|
||||
const state = await loadDedupeState();
|
||||
await chrome.storage.local.set({
|
||||
neverAskOrigins: { ...state.neverAskOrigins, [origin]: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const userKey = normalizeUsernameKey(info.username) || "_";
|
||||
const state = await loadDedupeState();
|
||||
const byOrigin = { ...(state.dismissedSites[origin] || {}) };
|
||||
byOrigin[userKey] = Date.now();
|
||||
await chrome.storage.local.set({
|
||||
dismissedSites: { ...state.dismissedSites, [origin]: byOrigin },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether auto-prompt should be suppressed for this login draft.
|
||||
* @param {{ pageUrl: string, username?: string, password?: string }} draft
|
||||
* @param {{ dismissTtlMs?: number }} [opts]
|
||||
* @returns {Promise<{ suppress: boolean, reason?: string, credentialId?: string, needSessionOnly?: boolean }>}
|
||||
*/
|
||||
export async function shouldSuppressPrompt(draft, opts = {}) {
|
||||
const origin = originFromUrl(draft.pageUrl);
|
||||
if (!origin) return { suppress: false };
|
||||
const userKey = normalizeUsernameKey(draft.username) || "_";
|
||||
const state = await loadDedupeState();
|
||||
if (state.neverAskOrigins[origin]) {
|
||||
return { suppress: true, reason: "never_ask_origin" };
|
||||
}
|
||||
const dismissTtlMs = opts.dismissTtlMs ?? 14 * 24 * 60 * 60 * 1000;
|
||||
const dismissedAt = state.dismissedSites?.[origin]?.[userKey];
|
||||
if (typeof dismissedAt === "number" && Date.now() - dismissedAt < dismissTtlMs) {
|
||||
return { suppress: true, reason: "recently_dismissed" };
|
||||
}
|
||||
const saved = state.savedSites?.[origin]?.[userKey];
|
||||
if (saved && draft.password) {
|
||||
const fp = passwordFingerprint(draft.password);
|
||||
if (saved.passwordFp === fp) {
|
||||
// Same account+password already saved: do not re-ask for credential.
|
||||
// If session was never captured, allow a silent session-only update path.
|
||||
return {
|
||||
suppress: true,
|
||||
reason: "already_saved_same_password",
|
||||
credentialId: saved.credentialId || "",
|
||||
needSessionOnly: !saved.hasSession,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { suppress: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{baseUrl?:string, rootUri?:string, userId?:string, email?:string, connectedAt?:string, lastFolderPath?:string}>}
|
||||
*/
|
||||
export async function loadPersistent() {
|
||||
return chrome.storage.local.get(SYNC_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
*/
|
||||
export async function savePersistent(data) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const patch = {};
|
||||
for (const k of SYNC_KEYS) {
|
||||
if (data[k] !== undefined) patch[k] = data[k];
|
||||
}
|
||||
await chrome.storage.local.set(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{token?:string, tokenExpiresAt?:string, jti?:string}>}
|
||||
*/
|
||||
export async function loadSession() {
|
||||
// Prefer session storage (cleared on browser restart) when available.
|
||||
if (chrome.storage.session) {
|
||||
return chrome.storage.session.get(SESSION_KEYS);
|
||||
}
|
||||
return chrome.storage.local.get(SESSION_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
*/
|
||||
export async function saveSession(data) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const patch = {};
|
||||
for (const k of SESSION_KEYS) {
|
||||
if (data[k] !== undefined) patch[k] = data[k];
|
||||
}
|
||||
if (chrome.storage.session) {
|
||||
await chrome.storage.session.set(patch);
|
||||
} else {
|
||||
await chrome.storage.local.set(patch);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSession() {
|
||||
if (chrome.storage.session) {
|
||||
await chrome.storage.session.remove(SESSION_KEYS);
|
||||
}
|
||||
await chrome.storage.local.remove(SESSION_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime config for API calls.
|
||||
* @returns {Promise<{baseUrl:string, rootUri:string, token?:string, userId?:string, email?:string, connected:boolean}>}
|
||||
*/
|
||||
export async function loadConfig() {
|
||||
const [p, s] = await Promise.all([loadPersistent(), loadSession()]);
|
||||
const baseUrl = (p.baseUrl || "").trim();
|
||||
const rootUri = (p.rootUri || "").trim();
|
||||
const token = (s.token || "").trim();
|
||||
const connected = Boolean(baseUrl && rootUri && token);
|
||||
return {
|
||||
baseUrl,
|
||||
rootUri,
|
||||
token: token || undefined,
|
||||
userId: p.userId,
|
||||
email: p.email,
|
||||
connected,
|
||||
tokenExpiresAt: s.tokenExpiresAt,
|
||||
jti: s.jti,
|
||||
lastFolderPath: p.lastFolderPath || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化逻辑分组路径(与 vault store normalize_folder_path 对齐)。
|
||||
* @param {string} [raw]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeFolderPath(raw) {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return "";
|
||||
const parts = s
|
||||
.split(/[/\\]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p && p !== "." && p !== "..");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住用户上次选用的分组。
|
||||
* @param {string} folderPath
|
||||
*/
|
||||
export async function saveLastFolderPath(folderPath) {
|
||||
const fp = normalizeFolderPath(folderPath);
|
||||
await savePersistent({ lastFolderPath: fp });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} connected
|
||||
* @param {string} [label]
|
||||
*/
|
||||
export async function setBadge(connected, label) {
|
||||
try {
|
||||
if (connected) {
|
||||
await chrome.action.setBadgeText({ text: "ON" });
|
||||
await chrome.action.setBadgeBackgroundColor({ color: "#0a7" });
|
||||
if (label) {
|
||||
await chrome.action.setTitle({ title: `MNote Vault · ${label}` });
|
||||
}
|
||||
} else {
|
||||
await chrome.action.setBadgeText({ text: "—" });
|
||||
await chrome.action.setBadgeBackgroundColor({ color: "#888" });
|
||||
await chrome.action.setTitle({ title: "MNote Vault · 未连接" });
|
||||
}
|
||||
} catch {
|
||||
// ignore in non-extension env
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user