feat(browser): ship camofox v1.17.0 in compose, verified on a real instance

- compose: camofox service on by default (internal only, volume umt-camofox,
  healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with
  service_started/required=false and auto-registers camofox-env from
  BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED
- docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the
  upstream Dockerfile (missing postinstall.js before npm ci, sh -c not
  forwarding SIGTERM so logins were never persisted on restart)
- adapter fixes from the real container: coordinate click via evaluate +
  selector, focus typing with mode=keyboard, password values redacted from
  ARIA snapshots/extract, downloads field names, 410 after browser restart
  treated as lost tab; navigate reopens a tab in the same session
- verified-capability table keyed by upstream version (1.17.0 all ten);
  other versions stay unverified; probe reports auth failures
- settings: browser providers block (create/edit/delete/probe, capability
  table); API POST/PATCH/DELETE /browser/providers, POST .../probe
- scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items
- docs: runbook browser §1/§7 (real-instance record), deploy §2.1

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 00:05:21 +08:00
1 parent b4c9b1ead2
commit 40dd0e69e4
29 files changed
+1661 -73

No files matched your search

@@ -103,7 +103,9 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
);
const caps = provider.capabilities;
const unverified = !caps?.takeover.verified || !caps?.screenshot.verified;
// 上游缺端点 → 不可用;有端点但没有真机核验记录 → 未核验(REQ §4.7.4)
const unsupported = !!caps && (!caps.takeover.supported || !caps.screenshot.supported);
const unverified = !unsupported && (!caps?.takeover.verified || !caps?.screenshot.verified);
const conn: { tone: StatusTone; text: string } = paused
? { tone: "neutral", text: "已暂停刷新" }
: shot.isError
@@ -147,6 +149,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
<div className="ml-auto flex flex-wrap items-center gap-2">
{human ? (
@@ -0,0 +1,200 @@
import {
BROWSER_CAPABILITY_KEYS,
type BrowserCapabilityKey,
BrowserProviderCreate,
BrowserProviderUpdate,
type BrowserProviderView,
} from "@umt/contracts";
import {
useBrowserProviders,
useCreateBrowserProvider,
useDeleteBrowserProvider,
useProbeBrowserProvider,
useUpdateBrowserProvider,
} from "~/api/browser.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Health, TestButton } from "./instances.tsx";
import { type FormValues, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// 浏览器服务(camofox-browser):地址、访问凭据(只写,存凭证库)、启用、测试连通、能力核验状态。
// 部署自带的 camofox 由环境变量登记为 camofox-env,地址与凭据只能改部署配置。
const CAP_LABEL: Record<BrowserCapabilityKey, string> = {
sessions: "会话",
tabs: "标签页",
navigate: "导航",
snapshot: "页面快照",
screenshot: "截图",
interact: "点击与输入",
extract: "字段提取",
downloadCapture: "下载捕获",
takeover: "人工接管",
persistentProfile: "登录保持",
};
function Verified({ p }: { p: BrowserProviderView }) {
const caps = p.capabilities;
if (!caps) return <StatusBadge>未探测</StatusBadge>;
const keys = BROWSER_CAPABILITY_KEYS.filter((k) => caps[k].supported);
const ok = keys.filter((k) => caps[k].verified).length;
if (ok === 0) return <StatusBadge tone="warning">未核验</StatusBadge>;
return (
<StatusBadge tone={ok === keys.length ? "success" : "warning"}>
{ok === keys.length ? "已核验" : `已核验 ${ok}/${keys.length}`}
{caps.upstreamVersion ? ` · ${caps.upstreamVersion}` : ""}
</StatusBadge>
);
}
function CapabilityTable({ p }: { p: BrowserProviderView }) {
const caps = p.capabilities;
if (!caps) return <p className="mt-4 text-caption text-muted-foreground">还没有探测过。点"测试连通"读取能力。</p>;
return (
<div className="mt-5 space-y-2">
<h3 className="text-caption font-semibold">
能力
<span className="ml-2 font-normal text-muted-foreground">
上游版本 {caps.upstreamVersion ?? "未知"}
{p.verifiedAt ? ` · 真机核验 ${p.verifiedAt.slice(0, 10)}` : ""}
</span>
</h3>
<ul className="divide-y rounded-lg border">
{BROWSER_CAPABILITY_KEYS.map((k) => {
const f = caps[k];
return (
<li key={k} className="flex items-start justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium">{CAP_LABEL[k]}</p>
{f.note ? <p className="text-meta text-muted-foreground">{f.note}</p> : null}
</div>
{!f.supported ? (
<StatusBadge tone="destructive">不可用</StatusBadge>
) : f.verified ? (
<StatusBadge tone="success">已核验</StatusBadge>
) : (
<StatusBadge tone="warning">未核验</StatusBadge>
)}
</li>
);
})}
</ul>
<p className="text-meta text-muted-foreground">
"已核验"只来自对真实实例的逐项核验记录(见运行手册),探测只能判断接口在不在。未核验的能力在原站视图里标出。
</p>
</div>
);
}
export function BrowserProvidersBlock() {
const list = useBrowserProviders();
const create = useCreateBrowserProvider();
const update = useUpdateBrowserProvider();
const remove = useDeleteBrowserProvider();
const probe = useProbeBrowserProvider();
return (
<SettingsBlock
section="sites"
anchor="browser"
title="浏览器服务"
description="原站视图和 Agent 打开站点网页用的 camofox-browser。部署自带的一台由环境变量登记(camofox-env)。"
plain
>
<ResourceSection<BrowserProviderView>
hooks={{ list, create, update, remove } as never}
config={{
noun: "浏览器服务",
emptyHint: "没有浏览器服务时,原站视图不可用。",
deleteHint: "有未关闭会话时不能删除。环境变量登记的只能停用。",
drawerExtra: (r) => <CapabilityTable p={r} />,
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="flex items-center gap-2 font-medium">
{r.name}
{r.managedBy === "env" ? <StatusBadge>环境变量</StatusBadge> : null}
</p>
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
</div>
),
},
{
key: "key",
header: "访问凭据",
hideBelow: "md",
cell: (r) => (r.hasAccessKey ? "已设置" : "未设置"),
},
{
key: "enabled",
header: "状态",
hideBelow: "sm",
cell: (r) => (
<StatusBadge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "启用" : "停用"}</StatusBadge>
),
},
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
{ key: "verified", header: "核验", hideBelow: "lg", cell: (r) => <Verified p={r} /> },
],
rowActions: (r) => (
<TestButton
label="测试连通"
pending={probe.isPending}
run={async () => {
const v = await probe.mutateAsync(r.id);
const auth = (v.health?.detail as { error?: string } | undefined)?.error === "auth";
return {
ok: v.health?.status === "ok",
message: auth ? "访问凭据不对" : v.health?.status === "degraded" ? "浏览器服务正在恢复" : "连不上",
version: v.capabilities?.upstreamVersion ?? null,
};
}}
/>
),
fields: [
{
name: "name",
label: "名称",
kind: "text",
required: true,
createOnly: true,
placeholder: "例如:家里的 camofox",
},
{
name: "baseUrl",
label: "服务地址",
kind: "url",
required: true,
placeholder: "http://camofox:9377",
hint: "环境变量登记的服务改部署配置里的 BROWSER_PROVIDER_URL",
wide: true,
},
{
name: "accessKey",
label: "访问凭据",
kind: "password",
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
editHint: "留空不修改",
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", baseUrl: "", accessKey: "", enabled: true },
fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl ?? "", accessKey: "", enabled: r.enabled }),
toBody: (v: FormValues, mode) =>
mode === "create"
? {
name: str(v.name),
baseUrl: str(v.baseUrl),
accessKey: optStr(v.accessKey),
enabled: v.enabled === true,
}
: { baseUrl: str(v.baseUrl), accessKey: optStr(v.accessKey), enabled: v.enabled === true },
createSchema: BrowserProviderCreate,
updateSchema: BrowserProviderUpdate,
}}
/>
</SettingsBlock>
);
}
+3 -1
View File
@@ -2,10 +2,11 @@ import { ArrowRight } from "lucide-react";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { BrowserProvidersBlock } from "./browser-providers.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理。
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理;浏览器服务(BR-1)。
// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
const DEFAULTS_CARD: CardSpec = {
@@ -91,6 +92,7 @@ export function SitesSection() {
</Button>
</div>
<SettingsGate>{(s) => <SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />}</SettingsGate>
<BrowserProvidersBlock />
</div>
);
}