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,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];
|
||||
}
|
||||
Reference in New Issue
Block a user