Files
rootandClaude Opus 5.5 28974d1e1a feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
  into the camofox profile before the first tab opens; password accounts
  without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
  accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
  every 10 min of activity, and from refresh-login when the stored cookie is
  invalid (verified before saving). user_info.cookieOrigin records
  source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
  under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
  logged in (logout link present), 5 cookies read back.

Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:25:07 +08:00

99 lines
4.5 KiB
TypeScript

/**
* BR-2 真机核验:把站点账号存的 Cookie 注入一个全新的 camofox profile → 打开站点首页一次 → 判定是否登录态
* (地址不是登录页且页面有 a[href*=logout.php]),再经 umt-cookies 插件读回站点域名的 Cookie 个数。
* 只输出布尔值与个数:不截图、不输出页面内容与 Cookie 值;对真实站点只有一次导航。
* 用法(只读数据库;camofox 需带 umt-cookies 插件,见 docs/runbook/browser.md §9):
* CAMOFOX_URL=http://127.0.0.1:49391 CAMOFOX_ACCESS_KEY=... DATABASE_URL=postgres://... SECRET_MASTER_KEY=... \
* deno run -A scripts/browser-verify/cookie-real.ts <site key>
* 密钥只从环境变量读,不写进文件。
*/
import {
cookiesFromHeader,
createCamofoxBrowserProvider,
headerFromCookies,
parseCookieHeader,
staticBrowserSecrets,
} from "@umt/adapters";
import type { NavigationBudget } from "@umt/contracts";
import { createDb } from "@umt/db";
import { createSecretStore } from "../../apps/server/src/modules/auth-settings/provided.ts";
import { readAccountCookie } from "../../apps/server/src/modules/sources-identity/service/credentials.ts";
const env = (k: string) => {
const v = Deno.env.get(k);
if (!v) throw new Error(`缺少环境变量 ${k}`);
return v;
};
const siteKey = Deno.args[0] ?? "hhanclub";
const db = createDb({ connectionString: env("DATABASE_URL"), max: 2 });
const secrets = createSecretStore({ db, masterKey: env("SECRET_MASTER_KEY") });
const quiet = { info() {}, warn() {}, error() {}, debug() {} } as never;
const report: Record<string, unknown> = { siteKey, at: new Date().toISOString() };
try {
const site = await db.selectFrom("site").selectAll().where("key", "=", siteKey).executeTakeFirstOrThrow();
const acc = await db
.selectFrom("site_account")
.select(["id"])
.where("site_id", "=", site.id)
.where("enabled", "=", true)
.where("auth_kind", "in", ["cookie", "password"])
.orderBy("created_at")
.executeTakeFirstOrThrow();
const cred = await readAccountCookie({ db, secrets, logger: quiet }, acc.id);
if (!cred?.header) throw new Error("账号没有 Cookie");
report.source = cred.source;
report.storedCookieCount = parseCookieHeader(cred.header).size;
const PROVIDER = "00000000-0000-4000-8000-0000000000b2";
const key = env("CAMOFOX_ACCESS_KEY");
const p = createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: env("CAMOFOX_URL"),
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: key }),
});
const host = new URL(site.base_url).hostname;
const budget: NavigationBudget = { domainAllowlist: [host], timeoutMs: 45_000, maxRetries: 0, maxBytes: 512 * 1024 };
// 全新 profile:证明登录态来自注入,不是 profile 里原有的
const profileKey = `umt-br2-verify-${Date.now()}`;
const t0 = Date.now();
const opened = await p.openSession({
providerId: PROVIDER,
siteId: site.id,
accountId: acc.id,
profileKey,
initialUrl: site.base_url,
budget,
cookies: cookiesFromHeader(cred.header, site.base_url),
});
report.inject = opened.cookies;
report.openMs = Date.now() - t0;
report.httpStatus = opened.navigation?.httpStatus ?? null;
const info = p.sessionInfo(opened.session.id);
report.loginPage = info?.loginPage ?? null;
report.landedPath = opened.session.currentUrl ? new URL(opened.session.currentUrl).pathname : null;
// 登录态标记:与站点定义 umt.loginSuccess(a[href='logout.php'])一致;只取布尔值
const res = await fetch(`${env("CAMOFOX_URL")}/tabs/${encodeURIComponent(opened.tab.upstreamTabId)}/evaluate`, {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({
userId: profileKey,
expression: `!!document.querySelector("a[href*='logout.php']")`,
}),
});
report.logoutLinkPresent = res.ok
? ((await res.json()) as { result?: unknown }).result === true
: `HTTP ${res.status}`;
report.loggedIn = report.logoutLinkPresent === true && report.loginPage === false;
const back = await p.getCookies(profileKey, [site.base_url]);
report.readBackCookieCount = back.length;
report.readBackDomains = [...new Set(back.map((c) => c.domain))];
const header = headerFromCookies(back, site.base_url);
report.readBackContainsInjected = header
? [...parseCookieHeader(cred.header).keys()].every((k) => parseCookieHeader(header).has(k))
: false;
await p.closeSession(opened.session.id, "verify_done");
} finally {
await db.destroy();
}
console.log(JSON.stringify(report, null, 2));