- RSS: split NexusPHP "[category]name[subtitle][size][uploader]" titles in the site adapter layer (category id/name/media type, clean title, subtitle, size); snapshot fields win on merge; lazily renormalize old RSS rows and re-emit release.observed; unknown list fields show an em dash - Detail: HHClub section layout (douban info block as description, screenshots section, IMDb/TMDB from MediaInfo lines, upload time field, on-demand viewfilelist.php once); site CDN screenshots get a Referer - Site request count now includes site-bound RSS fetches - Origin view shows "自动化" when idle and "Agent" only after recent automation actions (lastAutomationAt); agent step titles no longer get squeezed to a sliver by long notes Co-Authored-By: Claude Opus 5.5 <[email protected]>
37 KiB
浏览器(camofox)与原站视图运行手册
对应 REQ v0.7 §4.4、§4.7.1–4.7.4;合同与 camofox OpenAPI 对照见 docs/design/browser-provider.md。
代码:packages/adapters/src/browser/**(适配器)、apps/server/src/modules/browser/**(会话/接管/能力)、
apps/web/app/components/browser/**(原站视图)。
当前状态:已对真实 camofox-browser v1.17.0(jo-inc,commit
389c996)容器逐项核验(§7),十项能力全部通过; 探测到其它版本 / fork 时一律显示"未核验",需按 §6 重新核验。
1. 浏览器服务从哪来
1.1 部署自带(缺省)
docker compose up -d 同时起 camofox 服务(docker-compose.yml,只在内网、不映射端口,卷 umt-camofox)。
app 的 BROWSER_PROVIDER_URL 缺省 http://camofox:9377,启动后第一次用到浏览器时登记为 camofox-env
(browser_provider.config.managedBy = "env",访问凭据取 BROWSER_ACCESS_KEY / CAMOFOX_ACCESS_KEY,只在进程内,不入库),
并在后台探测一次能力。地址与凭据只能改部署配置;设置页只能改它的启用。
# .env(部署目录)
CAMOFOX_ACCESS_KEY=$(openssl rand -hex 24) # camofox 与 app 共用
docker compose --env-file .env.deploy up -d --build
docker compose ps camofox # healthy 即可
上游不发布镜像。compose 缺省用的是浏览器桌面镜像 umt-browser-desktop:1.17.0(docker/browser-desktop,§8),
它从纯截图镜像 camofox-browser:1.17.0 复制 camofox,所以先构建旧镜像:docker/camofox/Dockerfile 从 git tag v1.17.0
构建(修了上游 Dockerfile 的两个问题,见文件头)。首次构建要从 GitHub 拉源码与约 630 MB 的 Camoufox 浏览器包:
git clone --depth 1 --branch v1.17.0 https://github.com/jo-inc/camofox-browser.git /tmp/camofox-src
docker build -t camofox-browser:1.17.0 --build-arg APT_MIRROR=mirrors.ustc.edu.cn \
--build-context upstream=/tmp/camofox-src docker/camofox
# 走宿主代理时加 --network host --build-arg https_proxy=http://127.0.0.1:7890 --build-arg http_proxy=...
docker compose build camofox # 再构建桌面镜像(或 docker build -t umt-browser-desktop:1.17.0 docker/browser-desktop)
只要截图模式(不要桌面、省约 400 MB 常驻内存与 3 GB 磁盘):.env 里 CAMOFOX_IMAGE=camofox-browser:1.17.0,同一个卷,登录状态通用。
1.2 接自有实例
.env 里 BROWSER_PROVIDER_URL=http://<host>:9377、BROWSER_ACCESS_KEY=<它的 CAMOFOX_ACCESS_KEY>、CAMOFOX_REPLICAS=0
(不再起自带容器)。或者不设环境变量,在 设置 → 站点默认 → 浏览器服务 里"添加浏览器服务":名称、服务地址、访问凭据
(加密存凭证库,只写不回显)、启用。自有实例的版本不是 1.17.0 时能力一律显示"未核验"。
1.3 不用浏览器
CAMOFOX_REPLICAS=0 + BROWSER_ENABLED=false:camofox-env 停用,原站视图显示"没有启用的浏览器服务"。
1.4 设置页与 API
设置 → 站点默认 → 浏览器服务:列表(地址、来源、凭据是否设置、启用、健康、核验)、测试连通(探测 /health 与
/openapi.json,凭据错误显示"访问凭据不对")、编辑、删除(有未关闭会话时拒绝;camofox-env 只能停用);
点行打开的抽屉里是逐项能力表(不可用 / 未核验 / 已核验 + 说明)。
| 端点 | 说明 |
|---|---|
GET /browser/providers[?probe=true] |
列表;probe=true 探测全部启用的服务并写回 |
POST /browser/providers |
登记 {name, baseUrl, accessKey?, enabled?};camofox-env 名称保留 |
PATCH /browser/providers/{id} |
{baseUrl?, accessKey?(null 清除), enabled?} |
POST /browser/providers/{id}/probe |
测试连通(停用的也可以测) |
DELETE /browser/providers/{id} |
删除 |
browser_provider.config 可调项(都可省略):
| 键 | 缺省 | 作用 |
|---|---|---|
verified |
{} |
逐项覆盖核验结果,如 {"takeover": false};缺省取适配器内置的真机核验表(§7,按上游版本);探测永远不会把它置 true |
navigationTimeoutMs |
30000 | 单次导航超时 |
maxRetries |
2 | 导航只重试超时 / 网络 / 5xx / 503;401、白名单、控制权冲突不重试 |
snapshotMaxBytes |
524288 | 快照文本上限(超出截断并标 truncated) |
requestTimeoutMs |
30000 | 其它请求超时 |
站点域名白名单 = 站点 base_url 的主机(含子域)+ site.config.browserDomains(字符串数组,给跳转到独立下载域的站点用)。
站点限流与快照 / 实时搜索共用同一个限流器(导航、开标签页、历史导航取令牌)。
2. 会话与控制权
- 会话按站点 / 账号隔离:
profileKey = umt-site-<siteId>-acct-<accountId|anon>,同时作为 camofoxuserId(上游的登录状态隔离单元);browser_session.id作 camofoxsessionKey。同一 provider + profile 至多一个未关闭会话。 - 数据库为准:服务重启后按
browser_session/browser_tab恢复适配器内存,needsSnapshot置 true,旧元素引用全部作废。 上游回收的标签页(404)对账标lost,返回 409"标签页已被浏览器服务回收"。 - 控制者:
automation(Agent/任务)→human(人工接管)→automation或waiting;closed终态。
| 事件 | 结果 |
|---|---|
接管 POST /browser/sessions/{id}/control/acquire |
先把适配器控制者切到 human(新的自动化输入立即被拒),等该 profile 上进行中的请求收尾,再写 browser_control_lease(缺省 30 分钟) |
| 他人已接管 | 409 |
交还 .../control/release |
租约 released;会话回 automation(或 to: "waiting");needsSnapshot = true;待处理事项关闭 |
| 租约到期未交还 | 租约 expired,会话转 waiting(lease_expired),不自动从未知页面继续 |
| Agent 快照看到验证码 / 二次认证 / Cloudflare 挑战 | 会话转 waiting(captcha / challenge),生成待处理事项(kind other,subject browser_session) |
Agent browser.interact action=request_human |
同上,原因 agent_request |
| 站点停用 / 浏览器服务停用 | 下一次访问时会话关闭(site_disabled / provider_disabled) |
HTTP 输入类端点(navigate / click / type / scroll / select / 开关标签页 / 带 target 的 download)只接受持有自己人工租约的调用者;
快照、截图、会话详情随时可读。Agent 看截图走 browser.screenshot(§3.1),敏感截图默认不进模型。
3. Agent 能力
| 能力 | 类别 | 说明 |
|---|---|---|
browser.snapshot |
read | 结构化快照(已脱敏);sessionId 或 siteId;清掉 needsSnapshot |
browser.navigate |
execute(admin) | 站点域名内;没有会话时按站点打开 |
browser.interact |
execute(admin) | click / type / select / scroll / press / request_human;登录字段用 credential 由服务端注入 |
browser.download |
execute(organize.execute) | 捕获下载 → 暂存 → 解析种子、匹配本地资源;不提交,提交用 download.plan / download.submit |
execute 类需要持续授权(automation_grant)或审批。元素引用只来自最近一次快照:导航、点击、按键提交、控制权交接、
服务重启之后,旧的 eN 报 409 元素引用已失效…先重新读取页面。
3.1 视觉自动化(VIS-1)
截图作为图片交给视觉模型 → 模型给出截图上的坐标 → 服务端换算到页面并操作 → 再截图,循环。用于登录表单、勾选框 / 滑块 /
点选类挑战等快照引用够不着的页面;对应 REQ v0.7 §4.4、§10。代码:apps/server/src/modules/browser/{service,capabilities}.ts
(截图、坐标、证据)、apps/server/src/modules/agent/challenge.ts(有界子流程)、packages/adapters/src/browser/image.ts(缩放编码)。
| 能力 | 类别 | 说明 |
|---|---|---|
browser.screenshot |
read | 截图缩到 ≤1280 像素宽(缺省 JPEG 质量 70),以 image content 交给模型;同时登记"模型坐标系"并写证据缩略图 |
browser.click_at |
execute | {sessionId, x, y, button?}:坐标 = 最近一张截图的像素;只支持左键;返回点击后的新截图 |
browser.type_at |
execute | {sessionId, x?, y?, text, pressEnter?}:先点再输(不给坐标 = 输入到当前焦点);账号密码仍用 browser.interact 的 credential |
browser.scroll |
execute | {sessionId, dx, dy} 像素,返回新截图 |
browser.wait |
read | {sessionId, ms ≤ 5000},返回等待后的新截图 |
browser.solve_challenge |
execute | {sessionId, goal, success, maxSteps ≤ 12, budget}:有界视觉子流程,见下 |
- 上游必须支持图片输入:按上游模型的
input是否含image判定(内置 provider 的目录自带;自定义 openai-compatible 端点目前按纯文本处理)。 不支持时截图不发送,能力改给已脱敏的文字快照并提示;solve_challenge直接判定需人工。fake 上游按支持图片处理。 - 坐标系:服务端对每次截图读页面视口与 DPR(
evaluate),页面 CSS 像素 = 截图像素 × 视口宽 / 截图宽; 输出的coordinates记录截图尺寸、原始尺寸、视口、DPR(取不到时assumed = true,按原图、DPR 1)。动作之后旧坐标系作废, 必须用新截图的坐标。点击沿用 1.17.0 的做法:elementFromPoint打标记再按选择器点,画布(canvas)内的位置、跨域 iframe 里的控件(如 Cloudflare Turnstile)点不到具体位置,这类挑战实际只能转人工。 - 动作前提:会话在自动化控制下(人工接管 / 等待处理中一律拒绝),当前页面主机在站点域名白名单内。
- 敏感截图:登录页地址、页面上有密码框、地址带凭据 →
sensitive = true,默认不交给模型(不做像素遮蔽),改给已脱敏的文字快照; 要交给模型需要automation_grant.action = browser.sensitive_screenshot(或*)的持续授权,且调用时传allowSensitive: true。 - 二次验证 / 短信 / 本人设备确认:截图时读页面文字、快照时读快照文本,命中即把会话转等待处理(原因
two_factor), 之后的自动化动作全部被拒;系统提示也要求模型立即停止。视觉子流程不能接手two_factor等待。 - 证据:每次截图(含动作后的截图)写 480 像素宽的 JPEG 缩略图到
DATA_DIR/tmp/evidence/<id>.jpg(0600,7 天后清理); 能力输出只给引用evidence/<id>.jpg,随工具结果摘要进agent_run_event。图片数据不进 JSON 文本、不进操作账本。 - 授权:
click_at / type_at / scroll / solve_challenge由browser.interact的持续授权覆盖(也可单独授权); 父会话白名单里有browser.solve_challenge即覆盖子流程用到的截图与坐标能力;授权同理,solve_challenge 通过授权后,子流程(spawn_reason = browser.solve_challenge)内的坐标动作不再单独要持续授权。坐标动作按调用区分幂等键,崩溃恢复不重放。 - 交给模型的截图参数可在
browser_provider.config调:screenshotMaxWidth(≤1280)、screenshotFormat(jpeg/png)、screenshotQuality(20–95)。
browser.solve_challenge 流程:
- 会话须在自动化控制下,或因
captcha/challenge/challenge_unsolved在等待处理(收回自动化控制,原待处理事项保留); 其它等待原因(二次验证、租约过期、Agent 请求人工)与人工接管中一律拒绝。 - 在调用进程里起一个子
agent_run(父会话 = 调用它的 Agent 运行,spawn_reason = browser.solve_challenge; 上游与父会话相同;白名单只有截图 / 坐标点击 / 输入 / 滚动 / 等待 /browser.snapshot),一步 = 一次工具调用,maxSteps即工具调用上限;token / 费用 / 时长按budget(时长缺省 180 秒),用量计入父会话。 - 子流程结束后服务端按
success(urlContains/urlChanged/textPresent/textAbsent,全部满足)读页面核实, 不信模型自述。结果:solved(关闭原待处理事项)/needs_human(二次验证、模型最后一行写NEEDS_HUMAN: 原因、上游不支持图片)/exhausted(步数或预算用尽)/failed。 - 没成功:会话转等待处理(
two_factor或challenge_unsolved),待处理事项的evidence.challenge附最近 12 张证据截图引用与子运行 ID, 返回takeover.path = /console/sites/<siteId>?tab=origin(原站视图接管入口)。不自动重试;人可以再次调用或接管处理。
不承诺通过 Cloudflare 等挑战(REQ §4.4):上游浏览器指纹与点击方式都可能被识别,失败按上面第 4 步转人工。
4. 下载产物
- 暂存:
DATA_DIR/tmp/browser-downloads/<artifactId>.bin(目录 0700、文件 0600),超过 24 小时的在下一次捕获时清理。 合同里只出现引用(bytes.ref),来源地址去掉 passkey。 - 核验:bencode 解析、infohash v1/v2、私有标记;按
source_release.infohash_v1/v2,再按下载地址id参数匹配本站资源。 - 提交:HTTP
POST /browser/sessions/{id}/download带submit时转调POST /acquisition-plans(同一鉴权、计划、去重、 缓存阈值、授权检查);被拒时产物照常返回,note写拒绝原因。同一会话同一 sha256 第二次捕获标duplicate,不再提交。 - 浏览器没有媒体库写权限;提交链路下载的是站点适配器取到的种子,暂存文件只作核验证据。
5. 排障
| 现象 | 查 |
|---|---|
| 原站视图"没有启用的浏览器服务" | browser_provider 是否有 enabled = true 且 kind = 'camofox' 的行 |
| 412"浏览器服务认证失败" / camofox 401 | 访问凭据与 CAMOFOX_ACCESS_KEY 是否一致;GET /browser/providers?probe=true |
| 502 浏览器服务不可用 | camofox /health(503 = 恢复中);容器日志 |
| 409 先接管会话再操作 | 界面点"接管";或租约已被他人持有 / 已过期 |
| "连接中断" | 截图请求失败;看 API 日志 component: browser 的方法 / 路径 / 状态(日志不含正文与凭据) |
| 测试连通提示"访问凭据不对" | /health 免认证所以能连上,但 /openapi.json 401:BROWSER_ACCESS_KEY 与 camofox 的 CAMOFOX_ACCESS_KEY 不一致 |
| 409"标签页已被浏览器服务回收" | camofox 重启(旧标签页返回 410)或会话空闲回收(404);Agent 再次 browser.navigate 会在同一会话开新标签页 |
| 重启 camofox 后要重新登录 | 容器是被 SIGKILL / OOM 杀掉的(docker inspect 看 OOMKilled、退出码 137):只有 SIGTERM 正常停止、会话空闲回收、关闭会话时才落盘登录状态;加大 CAMOFOX_MEM_LIMIT |
| 设置页显示"未核验" | 探测到的上游版本不在核验表里(不是 1.17.0,或 fork);按 §6 核验后在适配器 CAMOFOX_VERIFIED_RELEASES 加一行,或临时写 config.verified |
6. 真实实例核验清单(REQ §4.7.4)
先固定 camofox-browser 的仓库、版本(/openapi.json 的 info.version)、启动方式与认证配置,记在本节末尾。
通过的能力登记进适配器的真机核验表 CAMOFOX_VERIFIED_RELEASES(packages/adapters/src/browser/camofox/capabilities.ts,
按上游版本,只在探测到完全相同的版本时生效,并把 verified_at 写成核验日期);个别部署要撤销某项写
browser_provider.config.verified = {"<键>": false}。不通过的保持 false,界面继续标"未核验"。
不得用另一个浏览器窗口代替后宣称通过。
自动化核验脚本:scripts/browser-verify/run.sh(临时栈 umtbr:postgres 127.0.0.1:55447、camofox 127.0.0.1:49377、
compose 网络里的 mock NexusPHP 站 mocknexus:8080;服务层在宿主进程里装配,与生产同一套代码;
阶段之间重启 / SIGKILL camofox;结果写 .browser-verify-out/results-*.json 与截图)。跑完 docker compose -p umtbr down -v。
| # | 项目 | 做法 | 通过标准 | 对应 verified 键 |
|---|---|---|---|---|
| 1 | 登录保持 | 观众 / 馒头 / HHClub / CHD 各一账号:原站视图接管登录;闲置超过 SESSION_TIMEOUT_MS 后再 browser.snapshot |
不需重新登录;过期时落到登录页、loginRequired = true,而不是空列表 |
sessions、persistentProfile |
| 2 | 截图 / 结构化快照 | 列表页、详情页、登录页各取 screenshot 与 snapshot |
截图格式(JSON base64 还是 image/*)、尺寸与坐标一致;快照 eN 引用可点;密码框值、Cookie、passkey 不出现;大页面 offset 分页正确 |
screenshot、snapshot |
| 3 | 列表 / 详情提取 | 快照 + POST /extract(属性带 x-ref)与现有 HTTP 适配器结果对比 |
标题、分类、副标题、促销、做种一致,原始字段不丢 | extract |
| 4 | 文件下载捕获 | 详情页点"下载"后 POST /browser/sessions/{id}/download |
GET /tabs/{id}/downloads 的字段形状(filename / data / size / status)与适配器兼容;sha256 与直接 HTTP 下载一致;进暂存核验,不直写媒体库 |
downloadCapture |
| 5 | 重启后会话 | 分别触发 BROWSER_IDLE_TIMEOUT_MS 回收与强制重启 camofox 容器 |
持久化 profile 恢复登录;旧 tabId 识别为 lost,不重放旧引用 |
persistentProfile、tabs |
| 6 | 人与 Agent 同标签页连续操作 | Agent 打开详情页 → 原站视图接管,在同一标签页点击、输入 → 交还 → Agent 重新 snapshot |
人看到的是同一会话同一页面;交还后 Agent 读到人工操作后的状态;下载不重复提交 | takeover、interact |
核验记录:
| 日期 | 仓库 / fork | 版本 | 启动方式 / 认证 | 通过项 | 备注 |
|---|---|---|---|---|---|
| 2026-09-28 | jo-inc/camofox-browser | v1.17.0(389c996),Camoufox 152.0.4-beta.28 |
docker/camofox/Dockerfile + compose;CAMOFOX_ACCESS_KEY Bearer |
十项全部 | mock NexusPHP 站 + example.com;四个真实站点未测,见 §7 |
7. 真机核验(版本 1.17.0)
环境:camofox-browser:1.17.0 容器(本机构建,镜像 3.6 GB,空闲内存约 200 MB,打开 2 个会话后约 390 MB),
CAMOFOX_ACCESS_KEY 已设置,SESSION_TIMEOUT_MS=60000、BROWSER_IDLE_TIMEOUT_MS=30000(空闲回收阶段用短超时);
目标页:testkit mock NexusPHP(观众风格,mocknexus:8080,与 camofox 同一 compose 网络)与公开静态页 https://example.com/。
浏览器服务经环境变量登记为 camofox-env。每项的原始输出见 scripts/browser-verify/run.sh 的结果文件。
| # | 项目 | 结果 | 实际观测 |
|---|---|---|---|
| 0 | 认证 / 探测 | 通过 | 无 Bearer 请求 401;/openapi.json info.version = 1.17.0,十项能力的端点都在 |
| 1 | 登录保持 | 通过 | 新 profile 打开 torrents.php → 302 到 login.php?returnto=…,loginRequired = true;人工接管登录后 Agent 直接打开受保护列表页,不再登录(站点 takelogin 只收到 1 次);Agent 用服务端凭证注入登录第二个账号也通过,快照与返回值里没有密码 |
| 2 | 截图 / 结构化快照 | 通过(修了遮蔽) | 截图直接返回 image/png 二进制(不是 OpenAPI 写的 JSON base64),视口随指纹变化(实测 1433×1145、1691×867),截图像素 = 点击坐标;登录页 modelSafe = false。快照是 Playwright ARIA 格式(- textbox [e2]: 值,引用写作 [eN]),密码框与普通文本框同形且带明文值,原有按标签名的遮蔽规则抓不到 → 已修(见下);列表页 45 个引用、8.6 KB,未触发分页 |
| 3 | 列表 / 详情提取 | 通过 | 列表页快照含 fixture 的标题、副标题、大小、做种数(5 条中 4 条标题完整出现,1 条是站点截断显示);POST /extract 按 x-ref 取回标题;详情页取回下载链接文字。与 HTTP 适配器同源 fixture 对照,未逐字段比对促销标记 |
| 4 | 下载捕获 | 通过(修了字段名) | Agent 点详情页 .torrent 链接 → downloads 返回 {id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64};sha256 与直接 HTTP 下载一致(9fcf61f7…),infohash 与站点一致并匹配到本地资源,约 2 秒;consume=true 后不再返回同一文件 |
| 5 | 重启后会话 | 通过(修了 3 处),有已知限制 | SIGTERM 重启:旧标签页返回 410 browser_restarted(不是 404)→ 已按 lost 处理;登录状态恢复,Agent 再次导航在同一会话开新标签页(约 1.3 秒,含浏览器冷启动)。会话空闲回收(60 秒 + 上游 60 秒扫描周期):旧标签页 404 → lost,重新打开仍登录(约 0.4 秒)。SIGKILL:上次落盘之后的新登录丢失(需重新登录),这是上游落盘时机决定的(只在会话空闲回收、关闭、SIGTERM 停止时写),界面照常识别为登录页 |
| 6 | 人与 Agent 同标签页 | 通过(修了坐标点击与焦点输入) | Agent 打开登录页(上游 tabId T)→ 接管:自动化输入被拒(409)、只读快照允许 → 截图坐标点击用户名框 → 焦点输入 → 点密码框 → 输入 → 回车 → 交还;交还前的旧引用 e1 被拒("先重新读取页面");Agent 重新读取,仍是 tabId T,看到人工登录后的页面。后退 / 前进 / 刷新 / 滚动也在同一标签页通过。核验走原站视图调用的同一组 HTTP 端点,没有在浏览器里点原站视图界面 |
对接中发现并已修的差异:
- 截图格式:1.17.0 直接返回
image/png;适配器原本两种都兼容,无需改。 - 坐标点击:
POST /click只认ref/selector,不接受{x, y}→ 适配器先POST /evaluate用elementFromPoint给该点元素打data-umt-pt标记,再按属性选择器点击(仍走上游的完整鼠标事件序列与下载守卫)。坐标处没有元素报 400。 - 焦点输入:
POST /type缺省mode=fill必须带ref/selector→ 无目标时发mode=keyboard;回车用/press。 - 密码框遮蔽:快照里出现带值的输入框时,经
evaluate取页面上input[type=password]的当前值按字面量遮蔽;evaluate失败时遮蔽全部输入框的值。extract结果同样处理。 - 下载字段:
suggestedFilename、bytes(大小)、dataBase64、dataSkipped(超maxBytes)、failure。 - 410:浏览器重启后 5 分钟内旧标签页返回 410,与 404 同样按"标签页已不存在"对账为 lost;Agent 下一次导航在同一会话开新标签页。
- 上游镜像:① v1.17.0 的 Dockerfile 在
npm ci前没复制postinstall.js,构建必然失败;②CMD sh -c "node …"不转发 SIGTERM,docker stop/restart等满宽限期后 SIGKILL,关机落盘不执行,重启后登录全丢(修前实测)。docker/camofox/Dockerfile两处都改了(exec node)。 - 其它:关掉上游缺省开启的崩溃报告外发(
CAMOFOX_CRASH_REPORT_ENABLED=false)与每次启动从 addons.mozilla.org 下载 uBlock(CAMOFOX_DISABLE_DEFAULT_ADDONS=true,内网下载失败会拖慢启动)。
userId(umt-site-<uuid>-acct-<uuid>,约 90 字符)上游原样使用(持久化目录按 SHA-256 取名),无字符限制问题;
DELETE /tabs/{id} 的 userId query / body 都认;DELETE /sessions/{userId} 关闭该 profile 全部标签页并落盘。
视觉自动化(VIS-1,2026-09-29,scripts/browser-verify/vision-real.ts,只读使用现有 umt-camofox-1):打开 https://example.com/,
截图 image/png 1920×992(本次指纹的视口 1920×992、DPR 1;此前两次观测到 1600×819、1433×1145,视口随指纹变化),
缩成 1280×661 JPEG 交给模型;在缩放图上取"Learn more"链接中心 (640, 343),按服务端换算得页面 (960, 515),
与 getBoundingClientRect 的中心 (960.0, 514.8) 一致;点击后跳到 https://www.iana.org/help/example-domains,通过。
另:1.17.0 的 POST /click 在 OpenAPI 里写了 coordinates: {x, y},实测仍报 ref or selector required,坐标点击继续走 elementFromPoint。
没有核验的:四个真实站点(观众 / 馒头 / HHClub / CHD)的登录保持、Cloudflare / 验证码挑战、真实站点页面上的提取对比; 并发 1 / 2 / 4 会话的性能与资源;原站视图界面本身在浏览器里的点击体验(1.5 秒截图刷新);VNC 插件(镜像已装,未启用、未接入)。
8. 原站视图桌面模式(Selkies 远程桌面,WT-1)
方案与取舍见 docs/design/webtop-remote-browser.md。桌面镜像 = linuxserver baseimage-selkies:debiantrixie(Webtop 4.0 的底座,
pixelflux + labwc Wayland 桌面 + Selkies 2.0 Web 客户端)+ camofox 1.17.0 有头运行(CAMOFOX_INTERACTIVE=desktop,
MOZ_ENABLE_WAYLAND=1)。原站视图里看到的窗口就是 Agent 经 camofox API 驱动的同一个 Firefox 进程:Agent 导航,画面跟着变;
人在画面里点,Agent 下一次快照读到的就是点过之后的页面。
8.1 组成
| 部件 | 位置 | 说明 |
|---|---|---|
| 镜像 | docker/browser-desktop/Dockerfile |
底座按 digest 锁定;从 camofox-browser:1.17.0 复制 node、/app、Camoufox;补 fonts-noto-cjk-extra;可选 fcitx5(拼音 + mozc,--build-arg WITH_FCITX5=0 不装) |
| 启动 | root/etc/s6-overlay/s6-rc.d/svc-camofox |
s6 服务,等 labwc 的 wayland-0 出现后以 abc 身份 exec node server.js;依赖 svc-de 与 svc-dbus(Firefox 连着 D-Bus,dbus 先停会把浏览器带走,关机落盘失败) |
| 初始化 | init-umt-desktop |
由 CAMOFOX_ACCESS_KEY 派生 Selkies 控制 / 只读口令;覆盖 labwc autostart;把 /root/.camofox 交给 abc |
| 反代 | apps/server/src/modules/desktop |
/api/v1/browser/desktop/{sessionId}/* → 容器 :3000(静态客户端 + api/websockets);只接受控制台会话 |
| 前端 | apps/web/app/components/browser/desktop-view.tsx |
原站视图 Tab:桌面可用且是安全上下文 → 桌面模式;否则截图模式并说明原因;可手动切换 |
| HTTPS | compose caddy(profile tls)+ docker/browser-desktop/Caddyfile |
可选;已有反代终结 TLS 时不用 |
窗口:无标题栏、最大化铺满 1280×900(BROWSER_DESKTOP_WIDTH/HEIGHT),HARDEN_DESKTOP 去掉终端 / sudo / xdg-open,
HARDEN_OPENBOX 去掉关闭按钮与右键 / 中键菜单、禁 Alt+F4 等。音频、麦克风、摄像头、手柄、文件传输、打印、命令、共享全部关闭并锁定,
剪贴板只允许文本。
8.2 鉴权与输入权限
- Selkies 端口(3000/8082)不映射到宿主,唯一入口是 app 的反代;外部 API Key 与 DEV key 一律 403。
- 口令不经浏览器:app 按请求者身份在上游请求上注入
Authorization: Basic。持有该会话 active 人工租约的本人 → 控制口令; 其他人(包括未接管的本人)→ 只读口令,Selkies 服务端丢弃只读连接的键鼠与剪贴板输入。 - 口令 =
HMAC-SHA256(CAMOFOX_ACCESS_KEY, "umt-desktop/control" | "umt-desktop/view")前 48 位 hex,app 与容器各算一遍; 没设CAMOFOX_ACCESS_KEY时容器用随机口令,桌面模式不可用(界面回退截图模式)。 - WebSocket 每 3 秒复查一次角色:交还 / 租约过期 / 被他人接管 / 控制台会话失效时服务端主动断开,Selkies 客户端自动重连并按新口令认证
(实测交还后 5 秒内点击已无效)。WebSocket 还要求
Origin与Host同源(cookie 会话 + WS 无 CORS)。 - 反代响应带
Content-Security-Policy: frame-ancestors 'self'与X-Frame-Options: SAMEORIGIN,只能被本站 iframe 嵌入; 上游的WWW-Authenticate不外露(否则浏览器弹 Basic 登录框)。 - 只支持环境变量登记的
camofox-env(compose 自带容器);桌面地址缺省由BROWSER_PROVIDER_URL推出(端口换 3000), 或显式BROWSER_DESKTOP_URL。
8.3 界面
原站视图 Tab 调 GET /api/v1/browser/desktop/{sessionId}/_status 决定模式:available(容器 3000 能连上且有访问凭据)
且 window.isSecureContext → 桌面模式(iframe);否则截图模式,顶部写原因:"桌面模式不可用:…" 或 "桌面模式需要通过 HTTPS 访问控制台"。
桌面模式顶栏:连接状态(已连接,只读 / 可操作)、当前控制者、接管 / 交还(沿用 control/acquire|release)、重新连接、截图模式、关闭会话。
「当前控制」(HH-2,两种模式共用 coords.ts controllerLabel):controller = automation 且 2 分钟内有过自动化动作(Agent 能力经
BrowserService.run 成功执行,写 recovery_state.lastAutomationAt,会话视图字段 lastAutomationAt)才显示「Agent」,说明写
「Agent 正在操作」;否则显示「自动化」,说明写「目前没有 Agent 在操作」。人工接管显示「人工接管(用户名)」,人工动作不刷新该时间。
接管后直接在画面里点和输入;中文、日文用本机输入法(Selkies 接收 composition 事件,按 Unicode 注入上屏文本);
复制粘贴用 Ctrl+C / Ctrl+V。
8.4 HTTPS
WebCodecs 视频解码与剪贴板 API 只在安全上下文(HTTPS 或 localhost)可用;用 IP / 局域网主机名经 HTTP 访问时只能用截图模式。
# 已有反代(nginx / Caddy / Traefik)终结 TLS:无需改动,WebSocket 需透传 Upgrade、读超时 ≥ 1 小时、不缓冲
# 没有反代:启用 compose 自带 Caddy
docker compose --profile tls up -d # 自签:Caddy 内部 CA 按访问的主机名现场签发,浏览器需放行或导入根证书
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./umt-root.crt # 导入到客户端受信任根
# 有公网域名:.env 里 TLS_DOMAIN=media.example.com、[email protected](ACME,80/443 需可达)
TLS_PORT(缺省 443)、TLS_BIND 可改;4380 仍可直连(截图模式)。
8.5 回退
- 桌面容器起不来 / 桌面异常:
.env设CAMOFOX_IMAGE=camofox-browser:1.17.0后docker compose up -d camofox,原站视图自动回到截图模式, Agent 能力不受影响(实测同一个卷,切换前后登录状态都在)。 - 只关桌面、保留镜像:不设
CAMOFOX_ACCESS_KEY即可(桌面口令随机,app 探测为不可用)。 - 容器内输入法(本机输入法不可用时的备选):
BROWSER_DESKTOP_IME=fcitx5,Ctrl+Space 切换;与本机输入法同时开会重复组字,缺省关闭。
8.6 原型核验(2026-09-29,临时栈 umtwt)
环境:本机 96 核、无 GPU(pixelflux 软件渲染 + CPU H.264);客户端 headless Chromium 145(Playwright),经 Caddy 自签 HTTPS;
上游 camofox 1.17.0 / Camoufox 152.0.4-beta.28,Selkies 2.0.0,labwc 0.8.3。脚本 apps/web/test/screenshots/webtop/{p0,verify}.mjs(截图不进 git)。
| # | 项 | 结果 | 观测 |
|---|---|---|---|
| 1 | 同会话 | 通过 | camofox API 导航 example.com → iana,桌面画面同步;在桌面里点链接,API 快照 URL 变为 iana.org/domains/reserved,tabId 不变 |
| 2 | 中日文显示 | 通过 | zh / ja 维基首页正文、标题栏、地址栏无豆腐块(Camoufox 自带 Noto CJK) |
| 3 | 本机输入法 | 通过 | CDP Input.imeSetComposition + insertText 模拟组字:搜索框得到「流浪地球」「進撃の巨人」(camofox evaluate 读回) |
| 4 | 剪贴板 | 通过 | 本机 → 远端:写本机剪贴板,焦点回到画面后 Ctrl+V 粘贴中日文;远端 → 本机:远端 Ctrl+C 后本机 readText 读回中文 |
| 5 | 只读口令 | 通过 | 未接管时在画面点链接,URL 不变;接管后同一坐标点击生效;API 交还后 5 秒内再点无效 |
| 6 | 重启保持 | 通过(修了 1 处) | docker compose restart camofox 后 httpbin cookie 仍在。修前 dbus 与 camofox 并行停止,Firefox 先退出导致关机落盘失败 |
| 7 | HTTP 回退 | 通过 | 经 HTTP 访问显示"桌面模式需要通过 HTTPS 访问控制台",截图模式正常;旧镜像下显示"浏览器服务没有桌面…" |
| 8 | 资源 | 见右 | 容器内存:1 会话无人观看约 570–670 MB,观看约 610 MB,滚动维基首页约 730 MB;CPU:无人观看 0.3%(不编码),观看静止页 1–7%,持续滚动约 75–100%(约 1 核) |
| 9 | 镜像 | — | umt-browser-desktop:1.17.0 解压 6.9 GB / 压缩 2.0 GB(旧镜像 3.6 GB / 1.1 GB) |
已知问题与未做:
- Firefox 工具栏里有 Playwright 的 "JUGGLER" 标识;用户可在画面里打开
about:addons、新标签页、输入任意网址——越界防护 (policies.json、context.route白名单、交还时核对 URL,方案 §6)未做。 - 接管期间 camofox 看不到 API 请求,
SESSION_TIMEOUT_MS(缺省 30 分钟)到期会回收会话窗口;接管心跳(方案 §5.5 插件)未做。 - 一个桌面承载所有站点会话:持有 A 站租约的人在画面里能看到 / 操作切到前台的其它窗口;目前同一时刻一般只有一个会话窗口。
- 真实输入法(Windows 微软拼音、macOS 日文)、Firefox / Safari 客户端、真实 PT 站点未测;容器内 fcitx5 只验证能启动,未验证组字。
- 画面坐标与 camofox 截图坐标不同(桌面含 56 px 工具栏,Camoufox 伪装了
screenX/outerHeight),Agent 视觉操作继续用 camofox 截图。
9. 凭据统一:站点账号登录态 ⇄ 浏览器(BR-2)
一处管理凭据:站点账号里的 Cookie(手填,或账密自动登录得到的会话 Cookie)在打开浏览器会话时注入该站点 / 账号的 profile;
人在浏览器里登录后,登录态读回站点账号,HTTP 抓取、RSS、快照下一次就用新的。截图模式与桌面模式是同一个 camofox 进程,共用。
代码:sources-identity/service/credentials.ts(单一读写入口)、modules/browser/service.ts(prepareCookies / syncCookies)、
packages/adapters/src/browser/{cookies.ts,camofox/client.ts}、camofox 插件 docker/browser-desktop/root/opt/camofox/plugins/umt-cookies。
9.1 注入做法
camofox 1.17.0 只有 POST /sessions/:userId/cookies(导入),而且要另一个口令 CAMOFOX_API_KEY;没有读取端点。
所以桌面镜像带一个最小插件 umt-cookies(Dockerfile 把它加进 camofox.config.json 的插件清单),在访问凭据
(CAMOFOX_ACCESS_KEY)之下提供:
| 端点 | 说明 |
|---|---|
POST /umt/sessions/{userId}/cookies {cookies} |
context.addCookies,并触发持久化插件立即落盘(与上游导入端点相同) |
GET /umt/sessions/{userId}/cookies?urls=a,b |
作用于这些地址的 Cookie:会话活着读 context.cookies(urls),否则读 profile 落盘文件 |
适配器先走插件;插件不在(404)再试上游导入端点(CAMOFOX_API_KEY 与访问凭据相同时可用,只能写不能读);都不行 → 会话照常打开,
credential.status = unsupported 并说明。profile 落盘在 /root/.camofox/profiles/<sha256(userId) 前 32 位>/storage-state.json
(Playwright storageState:cookies + localStorage;sessionStorage 不保存,IndexedDB 缺省不保存),桌面镜像里目录 0700、文件 0600
(svc-camofox 设 umask 077,init-umt-desktop 对旧卷 chmod -R go-rwx)。改了镜像,需重建 umt-browser-desktop 后生效
(docker compose build camofox && docker compose up -d camofox);纯截图镜像 camofox-browser:1.17.0 不带插件,注入 / 回写都显示不支持。
打开会话(POST /browser/sessions、Agent browser.navigate 首次打开)时:
- 匿名会话、API Key 类站点 / 账号(馒头:网页登录态与 API Key 无关)→ 不注入,
not_applicable; - 取账号 Cookie:用户填的 + 自动登录得到的会话 Cookie,同名以会话 Cookie 为准;账密账号还没有会话 Cookie → 先走一次 refresh-login;
- Cookie 头串转成站点主机的 host-only Cookie(路径
/,https 站点标 secure,期限 30 天——头串里没有期限,站点响应会按自己的期限覆盖), 在开第一个标签页之前写进 profile,再导航; - 注入了仍落在登录页 →
credential.status = expired;Cookie 账号(没有密码可自动登录)转等待处理login_required("登录已失效:接管后在画面里登录一次,交还后全站生效"),账密账号留给 Agent 用凭证注入登录。
会话详情 credential:status、source(manual 手填 / login 自动登录 / browser 浏览器回写)、sourceAt、count(个数,不含值)、
message、syncedAt;原站视图顶栏显示"登录态:来源 · 时间"。
9.2 回写
| 时机 | 做法 |
|---|---|
| 人工接管交还 | 先按上游 GET /tabs 同步当前地址(桌面里的导航不经 API),不在登录页才读回 |
| 关闭会话 | 关闭前读回一次(站点 / 浏览器服务停用导致的关闭不读) |
| 定时 | 会话有动作时,距上次读回(或创建)满 10 分钟,后台读回一次 |
| refresh-login 发现 Cookie 失效 | Cookie 账号:从浏览器 profile 读回(不要求会话开着,读落盘文件),与存的不同就拿它验证一次,有效才回写并标 active |
读回只取作用于站点主机的、未过期的 Cookie(统计 / CDN / 第三方域名一律丢弃),与存储比较(忽略顺序),有变化才写:
Cookie 账号替换 secret_ref,账密账号替换(或新建)会话 Cookie;site_account.user_info.cookieOrigin = {source: "browser", by: "browser", at},
expired / invalid 的账号回到 unknown。来源判定:cookieOrigin.at 不早于 secret 最近写入(rotated_at,留 5 秒)才算浏览器回写,
之后再手填或自动登录会让它变回 manual / login。
安全:Cookie 值只在服务端、secret 表与浏览器服务之间传;日志只记个数;会话详情 / 能力输出 / 模型上下文里没有值;快照遮蔽照旧(§3)。
9.3 真机核验(2026-09-29)
scripts/browser-verify/cookie-real.ts:临时容器 camofox-browser:1.17.0 + 挂载 umt-cookies 插件(与用户栈同一网络、同一出口代理),
读用户栈数据库里憨憨(hhanclub)账号的手填 Cookie(5 个),注入全新 profile 后打开首页一次:HTTP 200、落在 / 不是登录页、
页面有 a[href*=logout.php](站点定义的登录成功标记)→ 已登录;插件读回 5 个、全部是 hhanclub.net 域名、包含注入的全部名字。
对真实站点只有这一次导航,未截图。
没有核验的:桌面镜像重建后的端到端(接管 → 画面里登录 → 交还回写)只在 mock 上测过;其它三站;sessionStorage / IndexedDB 登录的站点
(Cookie 注入不覆盖这类登录态)。站点账号列表里显示登录态来源待前端接 userInfo.cookieOrigin。