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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:25:07 +08:00
1 parent 901b01d1a1
commit 28974d1e1a
58 files changed
+2843 -1112

No files matched your search

+111 -1
View File
@@ -18,6 +18,7 @@
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserCookie,
BrowserHealth,
BrowserSession,
BrowserSnapshot,
@@ -150,6 +151,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
private readonly sink: DownloadSink;
private readonly now: () => Date;
private readonly newId: () => string;
/** Cookie 接口:plugin = umt-cookies 插件(读写);upstream = 上游导入端点(只写,要求 CAMOFOX_API_KEY 与访问凭据相同);none = 都没有 */
private cookieApi: "plugin" | "upstream" | "none" | null = null;
constructor(private readonly cfg: CamofoxConfig) {
this.base = cfg.baseUrl.replace(/\/+$/, "");
@@ -368,6 +371,16 @@ export class CamofoxBrowserProvider implements BrowserProvider {
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
const sessionId = this.newId();
let cookies: OpenSessionResult["cookies"];
if (req.cookies?.length) {
try {
cookies = { status: "injected", count: (await this.putCookies(profileKey, req.cookies)).count };
} catch (e) {
if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 };
else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 };
else throw e;
}
}
const res = await this.request("POST", "/tabs", {
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
timeoutMs: req.budget.timeoutMs,
@@ -425,7 +438,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
};
this.assertLanded(rawUrl, req.budget);
}
return { session: { ...session }, tab: { ...tab }, navigation };
return { session: { ...session }, tab: { ...tab }, navigation, ...(cookies ? { cookies } : {}) };
});
}
@@ -442,6 +455,91 @@ export class CamofoxBrowserProvider implements BrowserProvider {
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
}
// ---------- Cookie(BR-2 凭据统一) ----------
/**
* 写 Cookie:先走 umt-cookies 插件 `POST /umt/sessions/{userId}/cookies`(docker/browser-desktop 自带,访问凭据即可);
* 插件不在(404)再试上游 `POST /sessions/{userId}/cookies`(1.17.0 要求 CAMOFOX_API_KEY,与访问凭据相同时可用);
* 都不行报 not_implemented。两条路上游都会立即把 profile 落盘。
*/
setCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
return this.withProfile(profileKey, () => this.putCookies(profileKey, cookies));
}
private async putCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
const list = cookies.slice(0, 500);
const body = { cookies: list } as unknown as Json;
const path = `/sessions/${encodeURIComponent(profileKey)}/cookies`;
if (this.cookieApi === null || this.cookieApi === "plugin") {
try {
const res = await this.json(
await this.request("POST", `/umt${path}`, { body }),
"/umt/sessions/{userId}/cookies",
);
this.cookieApi = "plugin";
return { count: int(res.count) ?? list.length };
} catch (e) {
if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e;
this.cookieApi = null;
}
}
if (this.cookieApi === null || this.cookieApi === "upstream") {
try {
const res = await this.json(await this.request("POST", path, { body }), "/sessions/{userId}/cookies");
this.cookieApi = "upstream";
return { count: int(res.count) ?? list.length };
} catch (e) {
// 403:上游导入口令(CAMOFOX_API_KEY)未设置或与访问凭据不同
if (!(e instanceof BrowserError && (e.httpStatus === 403 || e.httpStatus === 404))) throw e;
this.cookieApi = "none";
}
}
throw new BrowserError("not_implemented", "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件)");
}
/** 读 Cookie:只有 umt-cookies 插件提供(上游 1.17.0 没有读取端点);返回作用于这些地址的 Cookie */
async getCookies(profileKey: string, urls: string[]): Promise<BrowserCookie[]> {
if (this.cookieApi === "upstream" || this.cookieApi === "none")
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
let res: Json;
try {
res = await this.json(
await this.request("GET", `/umt/sessions/${encodeURIComponent(profileKey)}/cookies`, {
query: { urls: urls.join(",") },
}),
"/umt/sessions/{userId}/cookies",
);
} catch (e) {
if (e instanceof BrowserError && e.httpStatus === 404) {
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
}
throw e;
}
this.cookieApi = "plugin";
const list = Array.isArray(res.cookies) ? res.cookies : [];
const out: BrowserCookie[] = [];
for (const c of list) {
if (!c || typeof c !== "object") continue;
const o = c as Json;
const name = str(o.name);
const domain = str(o.domain);
const value = str(o.value);
if (!name || !domain || value === undefined) continue;
const sameSite = str(o.sameSite);
out.push({
name,
value,
domain,
path: str(o.path) ?? "/",
expires: typeof o.expires === "number" ? o.expires : -1,
httpOnly: o.httpOnly === true,
secure: o.secure === true,
...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None" ? { sameSite } : {}),
});
}
return out;
}
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
const s = this.state(sessionId);
@@ -567,6 +665,18 @@ export class CamofoxBrowserProvider implements BrowserProvider {
}
if (lost.length) s.refsValid = false;
if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? "";
// 桌面模式下人在画面里导航不经本适配器:按上游报告的当前地址同步(登录页判定、回写登录态前要用最新地址)
const cur = list.find((t) => (str(t.tabId) ?? str(t.targetId)) === s.currentTabId);
const curUrl = cur ? str(cur.url) : undefined;
if (curUrl && curUrl !== s.rawUrl && /^https?:/.test(curUrl)) {
s.refsValid = false;
this.touch(s, curUrl);
const tab = s.tabs.get(s.currentTabId);
if (tab) {
tab.url = s.session.currentUrl;
tab.updatedAt = at;
}
}
return { alive, lost };
});
}
@@ -0,0 +1,139 @@
import { createMockCamofox, type MockCamofox } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
BrowserError,
type BrowserLogger,
cookieDomainMatches,
cookiesFromHeader,
createCamofoxBrowserProvider,
headerFromCookies,
mergeCookieHeaders,
parseCookieHeader,
profileKeyFor,
sameCookieHeader,
staticBrowserSecrets,
} from "./index.ts";
// BR-2:Cookie 头串 ⇄ 浏览器 Cookie 互转,与 camofox Cookie 接口(umt-cookies 插件 / 上游导入端点 / 都没有)。
const ACCESS_KEY = "camofox-access-key-br2";
const PROVIDER = "11111111-1111-4111-8111-111111111111";
const SITE = "22222222-2222-4222-8222-222222222222";
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
const SECRET = "c0ffee-session-value-br2";
describe("Cookie 头串互转", () => {
it("解析 / 合并(后者优先)/ 等价比较", () => {
expect([...parseCookieHeader(" a=1; b = 2 ;;bad; c=x=y ")]).toEqual([
["a", "1"],
["b", "2"],
["c", "x=y"],
]);
expect(mergeCookieHeaders("a=1; b=2", "b=3; c=4")).toBe("a=1; b=3; c=4");
expect(mergeCookieHeaders(null, "")).toBeNull();
expect(sameCookieHeader("a=1; b=2", "b=2;a=1")).toBe(true);
expect(sameCookieHeader("a=1", "a=2")).toBe(false);
});
it("注入对象:站点主机 host-only、https → secure、30 天期限", () => {
const list = cookiesFromHeader("uid=1; pass=x", "https://hhanclub.net/", 1000);
expect(list).toEqual([
expect.objectContaining({
name: "uid",
domain: "hhanclub.net",
path: "/",
secure: true,
expires: 1000 + 30 * 86400,
}),
expect.objectContaining({ name: "pass", value: "x" }),
]);
});
it("读回只取站点域名、未过期的 Cookie;同名取更具体的", () => {
expect(cookieDomainMatches(".hhanclub.net", "www.hhanclub.net")).toBe(true);
expect(cookieDomainMatches("hhanclub.net", "evilhhanclub.net")).toBe(false);
const h = headerFromCookies(
[
{ name: "pass", value: "root", domain: ".hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
{ name: "pass", value: "host", domain: "hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
{ name: "old", value: "1", domain: "hhanclub.net", path: "/", expires: 10, httpOnly: false, secure: false },
{ name: "_ga", value: "t", domain: ".google.com", path: "/", expires: -1, httpOnly: false, secure: false },
],
"https://hhanclub.net/",
100,
);
expect(h).toBe("pass=host");
expect(headerFromCookies([], "https://hhanclub.net/")).toBeNull();
});
});
describe("camofox Cookie 接口", () => {
let mock: MockCamofox | undefined;
afterEach(async () => {
await mock?.close();
mock = undefined;
});
const provider = (m: MockCamofox, logs?: Parameters<BrowserLogger>[0][]) =>
createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: m.url,
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: ACCESS_KEY }),
...(logs ? { logger: (e) => void logs.push(e) } : {}),
});
const key = profileKeyFor(SITE, ACCOUNT);
const cookies = cookiesFromHeader(`c_secure_uid=1001; c_secure_pass=${SECRET}`, "https://audiences.example/");
it("umt-cookies 插件:写入(带访问凭据)与读回;日志不含 Cookie 值", async () => {
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
const logs: Parameters<BrowserLogger>[0][] = [];
const p = provider(mock, logs);
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
const call = mock.callsTo("/umt/sessions/")[0]!;
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
const back = await p.getCookies(key, ["https://audiences.example/"]);
expect(headerFromCookies(back, "https://audiences.example/")).toBe(`c_secure_pass=${SECRET}; c_secure_uid=1001`);
expect(await p.getCookies(key, ["https://other.example/"])).toEqual([]);
expect(JSON.stringify(logs)).not.toContain(SECRET);
});
it("openSession 带 cookies:先注入再开标签页", async () => {
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
const p = provider(mock);
const r = await p.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: ACCOUNT,
initialUrl: "https://audiences.example/",
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
cookies,
});
expect(r.cookies).toEqual({ status: "injected", count: 2 });
const paths = mock.calls.map((c) => c.path);
expect(paths.findIndex((x) => x.startsWith("/umt/"))).toBeLessThan(paths.indexOf("/tabs"));
});
it("没有插件:回退上游导入端点(CAMOFOX_API_KEY 与访问凭据相同时);读回报不支持", async () => {
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream", cookieImportKey: ACCESS_KEY });
const p = provider(mock);
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
expect(mock.cookieJars.get(key)).toHaveLength(2);
await expect(p.getCookies(key, ["https://audiences.example/"])).rejects.toMatchObject({ kind: "not_implemented" });
});
it("插件与上游导入都不可用:not_implemented;openSession 照常打开并标 unsupported", async () => {
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream" });
const p = provider(mock);
const e = await p.setCookies(key, cookies).catch((x: unknown) => x);
expect(e).toBeInstanceOf(BrowserError);
expect((e as BrowserError).kind).toBe("not_implemented");
expect((e as BrowserError).message).not.toContain(SECRET);
const r = await p.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: ACCOUNT,
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
cookies,
});
expect(r.cookies).toEqual({ status: "unsupported", count: 0 });
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { BrowserCookie } from "@umt/contracts";
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
const out = new Map<string, string>();
for (const part of (header ?? "").split(";")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const name = part.slice(0, i).trim();
if (!name || /[\s,;]/.test(name)) continue;
out.set(name, part.slice(i + 1).trim());
}
return out;
}
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
export function formatCookieHeader(jar: Map<string, string>): string {
return [...jar.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
const jar = new Map<string, string>();
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
return jar.size ? formatCookieHeader(jar) : null;
}
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
const x = parseCookieHeader(a);
const y = parseCookieHeader(b);
if (x.size !== y.size) return false;
for (const [k, v] of x) if (y.get(k) !== v) return false;
return true;
}
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
export function cookiesFromHeader(
header: string,
siteUrl: string,
nowSec = Math.floor(Date.now() / 1000),
): BrowserCookie[] {
const u = new URL(siteUrl);
const secure = u.protocol === "https:";
return [...parseCookieHeader(header)].map(([name, value]) => ({
name,
value,
domain: u.hostname,
path: "/",
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
httpOnly: false,
secure,
sameSite: "Lax" as const,
}));
}
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
const d = cookieDomain.toLowerCase().replace(/^\./, "");
const h = host.toLowerCase();
return h === d || h.endsWith(`.${d}`);
}
/**
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
*/
export function headerFromCookies(
cookies: readonly BrowserCookie[],
siteUrl: string,
nowSec = Math.floor(Date.now() / 1000),
): string | null {
const u = new URL(siteUrl);
const jar = new Map<string, { v: string; specific: number }>();
for (const c of cookies) {
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
const path = c.path || "/";
if (!u.pathname.startsWith(path)) continue;
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
const prev = jar.get(c.name);
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
}
if (!jar.size) return null;
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./budget.ts";
export * from "./camofox/index.ts";
export * from "./cookies.ts";
export * from "./errors.ts";
export * from "./image.ts";
export * from "./none.ts";
+17
View File
@@ -1,6 +1,7 @@
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserCookie,
BrowserHealth,
BrowserLeaseHolderKind,
BrowserProviderKind,
@@ -65,6 +66,18 @@ export interface BrowserProvider {
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
*/
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
// ── BR-2 凭据统一 ──
/**
* 把 Cookie 写进该 profile 的浏览器上下文(并落盘到 profile)。上游没有可用的 Cookie 接口时报
* BrowserError("not_implemented")。Cookie 值不进日志与错误信息。
*/
setCookies?(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }>;
/**
* 读该 profile 作用于这些地址的 Cookie(活动上下文优先,否则读 profile 落盘文件);没有时返回空数组。
* 上游没有接口时报 BrowserError("not_implemented")。
*/
getCookies?(profileKey: string, urls: string[]): Promise<BrowserCookie[]>;
}
export interface PageMetrics {
@@ -106,12 +119,16 @@ export interface OpenSessionRequest {
/** 可选初始地址,同样受 budget 约束 */
initialUrl?: string;
budget: NavigationBudget;
/** 打开前先注入的 Cookie(站点账号登录态,BR-2);注入失败不阻止打开,结果见 OpenSessionResult.cookies */
cookies?: BrowserCookie[];
}
export interface OpenSessionResult {
session: BrowserSession;
tab: BrowserTab;
navigation: NavigationResult | null;
/** 传了 cookies 时:injected = 注入个数;unsupported = 上游没有 Cookie 接口;failed = 请求失败 */
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
}
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */