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

+203 -2
View File
@@ -5,9 +5,12 @@ import {
type BrowserProvider,
type BrowserSecretResolver,
CAMOFOX_VERIFIED_RELEASES,
cookiesFromHeader,
createCamofoxBrowserProvider,
type DownloadSink,
type ElementTarget,
getSiteDefinition,
headerFromCookies,
hostAllowed,
imageDimensions,
type PageMetrics,
@@ -22,7 +25,9 @@ import type {
BrowserCapabilities,
BrowserControlLease,
BrowserController,
BrowserCookie,
BrowserCoordinateSpace,
BrowserCredentialState,
BrowserDownloadVerification,
BrowserEvidenceRef,
BrowserHealth,
@@ -42,6 +47,12 @@ import {
sql,
} from "@umt/db";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { refreshSiteAccount } from "../sources-identity/service/account.ts";
import {
readAccountCookie,
registerBrowserCookieReader,
writeBrowserCookie,
} from "../sources-identity/service/credentials.ts";
import type { ServerContext } from "../types.ts";
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
@@ -134,8 +145,13 @@ export interface RecoveryState {
waitingDecisionId?: string | null;
releasedAt?: string;
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
/** BR-2:打开时的凭据注入结果与最近一次回写检查 */
credential?: BrowserCredentialState;
}
/** 有活动的会话每隔多久从浏览器读回一次 Cookie */
export const COOKIE_SYNC_INTERVAL_MS = 10 * 60_000;
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
export const ENV_PROVIDER_NAME = "camofox-env";
const ENV_MANAGED = "env";
@@ -150,6 +166,8 @@ export class BrowserService {
readonly opts: BrowserModuleOptions = {},
) {
this.now = opts.now ?? (() => new Date());
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
}
// ---------- 浏览器服务 ----------
@@ -622,6 +640,7 @@ export class BrowserService {
lease: lease ? toLease(lease) : null,
needsSnapshot: rs.needsSnapshot === true,
waitingReason: row.controller === "waiting" || row.controller === "human" ? (rs.waitingReason ?? null) : null,
credential: rs.credential ?? null,
};
}
@@ -680,6 +699,7 @@ export class BrowserService {
// 数据库里已没有未关闭的会话:本进程内存里同 profile 还开着的是别的进程关掉的,标关闭,否则适配器拒绝新开
for (const m of await provider.listSessions())
if (m.profileKey === profileKey && m.controller !== "closed") provider.setController(m.id, "closed");
const prep = await this.prepareCookies(site, accountId);
await this.rateLimit(site);
let res: Awaited<ReturnType<BrowserProvider["openSession"]>>;
try {
@@ -690,11 +710,28 @@ export class BrowserService {
profileKey,
initialUrl: req.url ?? site.base_url,
budget,
...(prep.cookies.length ? { cookies: prep.cookies } : {}),
});
} catch (e) {
throw fromBrowserError(e);
}
const s = res.session;
const credential: BrowserCredentialState = { ...prep.state };
if (res.cookies) {
credential.status = res.cookies.status;
credential.count = res.cookies.count;
if (res.cookies.status === "unsupported")
credential.message = "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件),没有注入账号登录态";
else if (res.cookies.status === "failed") credential.message = "注入账号 Cookie 失败";
}
// 注入了 Cookie 仍落在登录页:Cookie 过期。Cookie 账号(没有密码可自动登录)转"需要人工登录":在画面里登录一次,
// 交还 / 关闭时回写,全站生效;账密账号留给 Agent 用凭证注入登录(browser.interact credential),只记状态
const loginPage = provider.sessionInfo(s.id)?.loginPage === true;
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
if (credential.status === "injected" && loginPage) {
credential.status = "expired";
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态";
}
await this.ctx.db.transaction().execute(async (trx) => {
await trx
.insertInto("browser_session")
@@ -708,7 +745,7 @@ export class BrowserService {
current_url: s.currentUrl,
last_activity_at: new Date(s.lastActivityAt),
upstream_ref: JSON.stringify(s.upstreamRef),
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true }),
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true, credential }),
})
.execute();
await trx
@@ -722,11 +759,160 @@ export class BrowserService {
})
.execute();
});
if (needHuman) await this.enterWaiting(await this.sessionRow(s.id), "login_required");
return s.id;
}
// ---------- 凭据统一(BR-2) ----------
/**
* 打开会话前准备要注入的 Cookie:匿名会话与 API Key 类站点(馒头:网页登录态与 API Key 无关)不注入;
* 账密账号还没有会话 Cookie 时先走一次 refresh-login(与站点中心"刷新登录"同一流程)再取。
*/
private async prepareCookies(
site: SiteRow,
accountId: string | null,
): Promise<{ cookies: BrowserCookie[]; state: BrowserCredentialState; authKind: string | null }> {
const state = (
status: BrowserCredentialState["status"],
message: string | null,
extra: Partial<BrowserCredentialState> = {},
): BrowserCredentialState => ({
status,
source: null,
sourceAt: null,
count: 0,
message,
syncedAt: null,
...extra,
});
if (!accountId)
return { cookies: [], authKind: null, state: state("not_applicable", "匿名会话,不注入账号登录态") };
const kind = (getSiteDefinition(site.key)?.umt as { kind?: string } | undefined)?.kind;
let acc = await readAccountCookie(this.ctx, accountId);
if (!acc?.applicable || kind === "mteam")
return {
cookies: [],
authKind: acc?.authKind ?? null,
state: state("not_applicable", "API Key 类站点 / 账号:网页登录态与 API Key 无关,不注入;需要时在桌面里登录"),
};
const rt = this.opts.runtime?.(this.ctx);
if (!acc.header && acc.authKind === "password" && rt) {
await refreshSiteAccount(rt, accountId, { mode: "login" }).catch(() => null);
acc = (await readAccountCookie(this.ctx, accountId)) ?? acc;
}
if (!acc.header)
return { cookies: [], authKind: acc.authKind, state: state("none", "账号还没有可用的登录态(Cookie)") };
const cookies = cookiesFromHeader(acc.header, site.base_url, Math.floor(this.now().getTime() / 1000));
return {
cookies,
authKind: acc.authKind,
state: state("injected", null, {
source: acc.source,
sourceAt: acc.sourceAt ? acc.sourceAt.toISOString() : null,
count: cookies.length,
}),
};
}
/** 从该站点 / 账号的浏览器 profile 读回站点域名的 Cookie 头串(没有启用的浏览器服务或插件时 null) */
async readProfileCookie(siteId: string, accountId: string): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
if (!prow) return null;
const provider = this.providerFor(prow);
if (!provider.getCookies) return null;
const site = await this.liveSite(siteId);
const list = await provider.getCookies(profileKeyFor(siteId, accountId), [site.base_url]);
return headerFromCookies(list, site.base_url, Math.floor(this.now().getTime() / 1000));
}
/**
* 回写:从会话的浏览器 profile 读回站点域名的 Cookie,与凭据存储比较,有变化则更新(来源 browser)。
* 当前页面是登录页时不回写(未登录的 Cookie 不能覆盖可能仍有效的登录态)。失败只记在会话状态里,不抛出。
*/
async syncCookies(id: string, why: "release" | "close" | "periodic"): Promise<{ changed: boolean } | null> {
try {
const row = await this.sessionRow(id);
if (!row.account_id) return null;
const provider = this.providers.get(row.provider_id)?.provider;
if (!provider?.getCookies) return null;
const rs = obj(row.recovery_state) as RecoveryState;
if (rs.credential?.status === "not_applicable") return null;
const at = this.now();
const patch = async (c: Partial<BrowserCredentialState>) => {
const cur = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
const base: BrowserCredentialState = cur.credential ?? {
status: "none",
source: null,
sourceAt: null,
count: 0,
message: null,
syncedAt: null,
};
await this.ctx.db
.updateTable("browser_session")
.set({
recovery_state: JSON.stringify({ ...cur, credential: { ...base, syncedAt: at.toISOString(), ...c } }),
})
.where("id", "=", id)
.execute();
};
// 先按上游同步当前地址(桌面里的导航不经 API),再判断是否停在登录页
if (provider.sessionInfo(id)) {
await provider.reconcileTabs(id).catch(() => null);
await this.persist(id, provider).catch(() => {});
}
if (provider.sessionInfo(id)?.loginPage === true) {
await patch({});
return { changed: false };
}
const site = await this.liveSite(row.site_id);
let header: string | null;
try {
header = headerFromCookies(
await provider.getCookies(row.profile_key, [site.base_url]),
site.base_url,
Math.floor(at.getTime() / 1000),
);
} catch (e) {
if (e instanceof BrowserError && e.kind === "not_implemented")
await patch({ message: "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件),浏览器里的登录不会回写" });
return null;
}
if (!header) {
await patch({});
return { changed: false };
}
const r = await writeBrowserCookie(this.ctx, row.account_id, header, at);
await patch(
r.changed
? {
source: "browser",
sourceAt: at.toISOString(),
...(rs.credential?.status === "expired" ? { status: "injected" as const, message: null } : {}),
}
: {},
);
if (r.changed)
this.ctx.logger.info(
{ component: "browser", sessionId: id, why, count: r.count },
"浏览器登录态已回写站点账号",
);
return { changed: r.changed };
} catch (e) {
this.ctx.logger.warn(
{ component: "browser", sessionId: id, why, err: String((e as Error)?.message ?? e) },
"浏览器 Cookie 回写失败",
);
return null;
}
}
private async closeRow(row: BrowserSessionRow, reason: string, prow?: BrowserProviderRow): Promise<void> {
const provider = prow ? this.providerFor(prow) : this.providers.get(row.provider_id)?.provider;
// 关闭前趁上下文还在读回一次登录态(站点 / 浏览器服务停用时不再访问)
if (provider?.sessionInfo(row.id) && reason !== "site_disabled" && reason !== "provider_disabled")
await this.syncCookies(row.id, "close");
if (provider?.sessionInfo(row.id)) await provider.closeSession(row.id, reason).catch(() => {});
const at = this.now();
await this.ctx.db.transaction().execute(async (trx) => {
@@ -949,6 +1135,8 @@ export class BrowserService {
});
if (opts.to === "automation") await this.resolveWaiting(row, lease.holder);
await this.persist(id, provider);
// 人工在桌面 / 截图模式里登录过:交还时读回登录态写进站点账号,全站(HTTP 抓取 / RSS / 快照)生效
await this.syncCookies(id, "release");
return toLease(released);
}
@@ -1006,9 +1194,22 @@ export class BrowserService {
throw fromBrowserError(e);
}
if (opts.persist !== false) await this.persist(id, loaded.provider, opts.patch?.(out) ?? {});
this.maybeSyncCookies(row);
return out;
}
/** 有活动时每 COOKIE_SYNC_INTERVAL_MS 读回一次登录态(后台,不拖慢动作;同一会话同时只跑一个) */
private readonly syncing = new Set<string>();
private maybeSyncCookies(row: BrowserSessionRow): void {
if (!row.account_id || this.syncing.has(row.id)) return;
const c = (obj(row.recovery_state) as RecoveryState).credential;
if (c?.status === "not_applicable") return;
const last = c?.syncedAt ? new Date(c.syncedAt).getTime() : new Date(row.created_at).getTime();
if (this.now().getTime() - last < COOKIE_SYNC_INTERVAL_MS) return;
this.syncing.add(row.id);
void this.syncCookies(row.id, "periodic").finally(() => this.syncing.delete(row.id));
}
/** 导航前按站点限流取令牌 */
async limited(site: SiteRow): Promise<void> {
await this.rateLimit(site);
@@ -1350,7 +1551,7 @@ const REASON_TEXT: Record<string, string> = {
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "人工接管超时未交还",
agent_request: "Agent 请求人工处理",
human_released_waiting: "人工交还时选择继续等待",