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:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
+416
View File
@@ -0,0 +1,416 @@
/**
* mnote-web vault API client (background only).
* Token / secrets never enter content scripts.
*/
/**
* @typedef {object} VaultConfig
* @property {string} baseUrl
* @property {string} rootUri
* @property {string} [token]
* @property {string} [userId]
* @property {string} [email]
*/
/**
* @param {string} baseUrl
* @returns {string}
*/
export function normalizeBaseUrl(baseUrl) {
const raw = String(baseUrl || "").trim().replace(/\/+$/, "");
if (!raw) throw new Error("baseUrl 不能为空");
let u;
try {
u = new URL(raw);
} catch {
throw new Error("baseUrl 不是合法 URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error("baseUrl 仅支持 http/https");
}
return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""));
}
/**
* @param {string} baseUrl
* @param {string} path
* @param {object} [opts]
* @param {string} [opts.token]
* @param {string} [opts.method]
* @param {object} [opts.body]
* @param {boolean} [opts.credentials]
*/
export async function vaultFetch(baseUrl, path, opts = {}) {
const base = normalizeBaseUrl(baseUrl);
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
/** @type {Record<string, string>} */
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (opts.token) {
headers.Authorization = `Bearer ${opts.token}`;
}
const init = {
method: opts.method || (opts.body ? "POST" : "GET"),
headers,
credentials: opts.credentials ? "include" : "omit",
};
if (opts.body !== undefined) {
init.body = JSON.stringify(opts.body);
}
let res;
try {
res = await fetch(url, init);
} catch (e) {
const err = new Error(
`Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)`
);
err.cause = e;
err.code = "network_error";
throw err;
}
let data = null;
const text = await res.text();
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!res.ok) {
const code = data?.code || data?.error || `http_${res.status}`;
const msg =
data?.message || data?.error || text || `${res.status} ${res.statusText}`;
const err = new Error(String(msg));
err.code = code;
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
/**
* Auth sign-in (sets session cookies when credentials:include).
* @param {string} baseUrl
* @param {{ email: string, password: string }} creds
*/
export async function signIn(baseUrl, creds) {
const email = String(creds.email || "").trim();
const password = String(creds.password || "");
const name = email.includes("@") ? email.split("@")[0] : email;
// 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐
return vaultFetch(baseUrl, "/api/auth", {
method: "POST",
credentials: true,
body: {
action: "auth:signIn",
args: {
provider: "password",
params: {
password,
flow: "signIn",
account: email,
email,
name,
},
},
},
});
}
/**
* Issue E2 extension token (requires session cookie from signIn).
* @param {string} baseUrl
* @param {object} [opts]
*/
export async function issueExtensionToken(baseUrl, opts = {}) {
return vaultFetch(baseUrl, "/api/vault/extension/token", {
method: "POST",
credentials: true,
body: {
clientId: "chrome-extension",
extensionId: opts.extensionId || null,
ttlHours: opts.ttlHours ?? 168,
email: opts.email || null,
},
});
}
/**
* 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。
* POST /api/local-folder/workspaces/default — 不存在则创建。
* @param {string} baseUrl
* @param {{ token?: string, credentials?: boolean }} [opts]
* @returns {Promise<{ rootUri: string, rootPath?: string }>}
*/
export async function ensureDefaultWorkspace(baseUrl, opts = {}) {
const data = await vaultFetch(
baseUrl,
"/api/local-folder/workspaces/default",
{
method: "POST",
credentials: opts.credentials !== false && !opts.token,
token: opts.token,
body: {},
}
);
const ws = data?.workspace || data?.result?.workspace || data?.result || data;
const rootUri = String(ws?.rootUri || "").trim();
if (!rootUri.startsWith("file://")) {
throw new Error(
"无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)"
);
}
return {
rootUri,
rootPath: ws?.rootPath ? String(ws.rootPath) : undefined,
workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId,
};
}
/**
* @param {VaultConfig} cfg
*/
export async function whoami(cfg) {
return vaultFetch(cfg.baseUrl, "/api/auth/whoami", {
method: "GET",
token: cfg.token,
credentials: !cfg.token,
});
}
/**
* @param {VaultConfig} cfg
*/
export async function listVault(cfg) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, {
method: "GET",
token: cfg.token,
});
}
/**
* @param {VaultConfig} cfg
* @param {object} item
*/
export async function createItem(cfg, item) {
return vaultFetch(cfg.baseUrl, "/api/vault/items", {
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...item,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} patch
*/
export async function updateItem(cfg, id, patch) {
return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, {
method: "PATCH",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...patch,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
*/
export async function getItem(cfg, id) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}?${q}`,
{
method: "GET",
token: cfg.token,
}
);
}
/**
* Reveal a secret field (password etc.) for dedupe / autofill decisions.
* @param {VaultConfig} cfg
* @param {string} id
* @param {{ field?: string, accountId?: string, secretId?: string }} [opts]
*/
export async function revealField(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/reveal`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
field: opts.field || "password",
accountId: opts.accountId,
secretId: opts.secretId,
},
}
);
}
/**
* PUT session file for credential/account.
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} session
*/
export async function putSession(cfg, id, session) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/session`,
{
method: "PUT",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
source: "chrome_extension",
...session,
},
}
);
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} [opts]
*/
export async function shareToAi(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/share-to-ai`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...opts,
},
}
);
}
/**
* 从 list 条目收集唯一 folderPath(含父路径前缀)。
* @param {Array} items
* @returns {string[]}
*/
export function collectFolderPaths(items) {
/** @type {Record<string, true>} */
const set = {};
const list = Array.isArray(items) ? items : [];
for (const it of list) {
const fp = String(it?.folderPath || "").trim();
if (!fp) continue;
set[fp] = true;
const parts = fp.split("/").filter(Boolean);
for (let i = 1; i < parts.length; i++) {
set[parts.slice(0, i).join("/")] = true;
}
}
return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh"));
}
/**
* Match list items by origin (client-side filter).
* @param {Array} items
* @param {string} pageUrl
*/
export function matchItemsByUrl(items, pageUrl) {
let origin = "";
let hostname = "";
try {
const u = new URL(pageUrl);
origin = u.origin;
hostname = u.hostname || "";
} catch {
return [];
}
const list = Array.isArray(items) ? items : [];
const hostOf = (raw) => {
try {
return new URL(String(raw)).hostname || "";
} catch {
return "";
}
};
const sameSite = (a, b) => {
if (!a || !b) return false;
if (a === b) return true;
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
};
return list
.filter((it) => {
if (it && it.status && String(it.status).toLowerCase() === "deleted") {
return false;
}
const urls = [];
if (it.url) urls.push(String(it.url));
if (Array.isArray(it.urls)) urls.push(...it.urls.map(String));
return urls.some((u) => {
try {
const ou = new URL(u);
if (ou.origin === origin) return true;
return sameSite(ou.hostname, hostname);
} catch {
const bare = origin.replace(/^https?:\/\//, "");
return String(u).includes(bare) || sameSite(hostOf(u), hostname);
}
});
})
.sort((a, b) =>
String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))
);
}
/**
* Find a list item that matches page origin + username (best effort).
* @param {Array} items
* @param {string} pageUrl
* @param {string} [username]
*/
export function findMatchingCredential(items, pageUrl, username) {
const matched = matchItemsByUrl(items, pageUrl);
if (!matched.length) return null;
const want = String(username || "")
.trim()
.toLowerCase();
if (!want) return matched[0];
const byUser = matched.find((it) => {
const candidates = [
it.username,
it.email,
...(Array.isArray(it.accounts)
? it.accounts.flatMap((a) => [a?.username, a?.email])
: []),
]
.filter(Boolean)
.map((s) => String(s).trim().toLowerCase());
return candidates.includes(want);
});
return byUser || matched[0];
}
+291
View File
@@ -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
}
}