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

+26 -2
View File
@@ -1,4 +1,4 @@
import type { BrowserController } from "@umt/contracts";
import type { BrowserController, BrowserCredentialState } from "@umt/contracts";
/** 截图上的点击位置 → 浏览器视口像素坐标(按图片实际像素与显示尺寸的比例换算,四舍五入并夹在画面内) */
export function toViewport(
@@ -28,7 +28,7 @@ const WAITING_TEXT: Record<string, string> = {
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "接管超时未交还",
agent_request: "Agent 请求人工处理",
human_released_waiting: "交还时选择继续等待",
@@ -44,3 +44,27 @@ export function holderName(holder: string): string {
const i = holder.indexOf(":");
return i >= 0 ? holder.slice(i + 1) : holder;
}
const COOKIE_SOURCE: Record<string, string> = { manual: "手填", login: "自动登录", browser: "浏览器回写" };
/** 会话登录态徽标(BR-2):来源(手填 / 自动登录 / 浏览器回写)与时间;没有账号或不适用时不显示 */
export function credentialBadge(
c: BrowserCredentialState | null | undefined,
): { tone: "success" | "warning" | "neutral"; text: string; title: string | undefined } | null {
if (!c || c.status === "not_applicable") return null;
const when = c.sourceAt ? new Date(c.sourceAt).toLocaleString() : null;
const src = c.source ? `${COOKIE_SOURCE[c.source] ?? c.source}${when ? ` · ${when}` : ""}` : null;
const title = c.message ?? undefined;
switch (c.status) {
case "injected":
return { tone: "success", text: `登录态:${src ?? "已注入"}`, title };
case "expired":
return { tone: "warning", text: "登录态已过期:在画面里登录一次即可全站生效", title };
case "none":
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次即可全站生效", title };
case "unsupported":
return { tone: "warning", text: "浏览器服务不支持注入登录态", title };
default:
return { tone: "warning", text: "登录态注入失败", title };
}
}
@@ -9,7 +9,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { apiData, errorMessage } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { CONTROLLER_LABEL, holderName, waitingText } from "./coords.ts";
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
import { SiteOriginView } from "./site-origin-view.tsx";
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
@@ -115,6 +115,7 @@ function DesktopView({
const mine = role === "controller";
const busy = control.acquire.isPending || control.release.isPending;
const waiting = waitingText(s.waitingReason);
const cred = credentialBadge(s.credential);
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
const conn: { tone: StatusTone; text: string } = loaded
? mine
@@ -131,6 +132,11 @@ function DesktopView({
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
{cred ? (
<span title={cred.title}>
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
</span>
) : null}
<div className="ml-auto flex flex-wrap items-center gap-2">
{human && mine ? (
<Button
@@ -34,7 +34,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { CONTROLLER_LABEL, holderName, toViewport, waitingText } from "./coords.ts";
import { CONTROLLER_LABEL, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
@@ -114,6 +114,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
? { tone: "success", text: "已连接" }
: { tone: "neutral", text: "连接中" };
const waiting = waitingText(s.waitingReason);
const cred = credentialBadge(s.credential);
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
const onScreenClick = (e: MouseEvent<HTMLImageElement>) => {
@@ -149,6 +150,11 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
{cred ? (
<span title={cred.title}>
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
</span>
) : null}
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
<div className="ml-auto flex flex-wrap items-center gap-2">