Author SHA1 Message Date
admin 791773b2d1 Add Gitea CI (host runner, manual clone, apk python)
ci / test (push) Failing after 15m5s
2026-11-04 15:17:12 +00:00
openqj 82d0e84371 Merge pull request #277 from openqj/feat-resident-p0-fixes
resident P0+P1: DNS SSRF pin, batch budget, caps/cooldown/retention, PG migration, deadlock isolation, retarget reset, proxy, metrics
2026-11-02 11:40:24 +08:00
host b391fe9320 resident P0+P1: DNS-level SSRF pin, batch budget, daily cap/cooldown/retention, PG schema/migration, dual-connection deadlock isolation, retarget baseline reset, proxy and structured failures, metrics
- DNS 级 SSRF pin:解析后 pin IP 建连,防 DNS 重绑定
- 批量总预算与每日上限/抖动冷却/保留期
- PG schema 与迁移清单
- 双连接死锁隔离
- 改目标重置基线
- 显式代理配置与结构化失败原因
- /metrics resident 口径
2026-11-02 03:30:57 +00:00
openqj 60997b138f Merge pull request #275 from openqj/feat-resident-website-loop-audit
合并网站常驻监听 PR #275:resident-watches + pause/resume/end、闭环实测、审核架构 10/10 与代码质量 10/10。CI host-verify 通过(6m52s),mergeable CLEAN。
2026-11-02 04:18:29 +08:00
host 6525bcafd0 audit: resident website loop architecture and code quality to 10/10
Fix F1 name race with partial unique index, F2 pause/end mid-poll race with guarded updates, F3 redirect/numeric-IP SSRF, F4 quota prefix check, F5 duplicate row_get, F6 audit ternary; S1 unused import, S2 backoff doc, S3 defensive tearDown. Directed 20 pass, full 1700 pass.
2026-11-01 19:58:03 +00:00
host 23725f92e6 docs: resident website loop verification summary 2026-11-01 19:43:04 +00:00
host 561b3891fb test: resident website loop end-to-end with real fetch 2026-11-01 19:42:40 +00:00
host 0722ca3fd8 feat: resident watch tasks with pause/resume/end
Long-running website monitoring: new cloud/resident.py layer on top of
the automations claim/quota model (no task state-machine change).
States running/paused/ended; pause preserves the content-hash cursor
and stops polling, resume re-checks from the cursor, end is terminal
(resume after end is 409, a new watch is required). Polling is a cheap
fetch + hash comparison; only a real change creates a queued model
task. Quota exhaustion pauses (quota_exceeded) instead of ending and
keeps the cursor. Driver is the worker run_loop round boundary;
server exposes create/list/get/update/pause/resume/end with audit.
Console UI intentionally not wired this round (see verification
summary for deviations).
2026-11-01 19:01:39 +00:00
openqj 622c51e7fb Merge pull request #274 from openqj/feat-searxng-freeze
websearch: 默认 auto 付费冻结,SearXNG 优先 + 显式解冻开关
2026-11-01 22:29:59 +08:00
host 3a56f83114 websearch: 默认 auto 付费冻结,SearXNG 优先 + 显式解冻开关
- 默认 auto 只走免费链 searxng -> duckduckgo;即使环境里有
  付费 key 也不调用 Perplexity/Exa/Tavily/Brave,SearXNG
  失败也不回落付费,默认使用零付费花费。
- 付费后端代码/测试/文档全部保留;显式 pin(tavily/exa/
  perplexity/brave)仍照常调用,是有意覆盖不是默认。
- 解冻开关 WEBSEARCH_ENABLE_PAID,仅精确值 "1" 才恢复旧链
  (perplexity/exa/tavily/brave/searxng/duckduckgo),默认关;
  开关经 worker search_env 透传进 MCP config env。
- HttpClient 超时/DNS 失败改报显式错误(timeout after Ns /
  URL error),绝不把超时写成“无结果”。
- SearXNG 默认参数核实:代码无 SEARXNG_URL 硬编码默认值;
  SEARXNG_ENGINES 默认 bing,360search 已在基线;WEBSEARCH_TIMEOUT
  默认 20 秒。主机 env 实测仅 SEARXNG_URL=http://searx.diante.me
  一行,故默认 pin 生效;文档 https 漂移改回 http(主机 nginx
  仅听 80、无 443 vhost,https 在 TLS 层即失败)。
- 旧测试更新原因:test_websearch_mcp 原断言默认 auto 为
  perplexity 优先,与新冻结语义直接冲突,故改为断言冻结后
  的 searxng -> duckduckgo 回退 + 不泄漏 key;旧 perplexity ->
  exa 回退断言移至解冻开关测试(WEBSEARCH_ENABLE_PAID=1 下)。
  未删断言、未加 skip、未放宽门禁。
- 文档另删 WEBSEARCH_PROVIDERS 说法:代码从未实现该变量。
- 未推、未建 PR、未合并、未部署、未改主机 env/nginx/filter/
  证书、未做付费后端 live 验证、未创建生产任务。
2026-11-01 14:15:54 +00:00
openqj 0602d06136 Merge pull request #273 from openqj/feat-continue-gapfill
feat: continue gap-fill for search + browser (2a569ac follow-up)
2026-11-01 17:21:17 +08:00
host e9fa43dd8b websearch: task-level allowed_hosts extension for scope=code + JS-heavy extract evaluation (mock-first)
- search(..., allowed_hosts=[...]) extends (never replaces) the 15-domain
  code list for that call only; scope=general + allowed_hosts raises,
  empty/illegal values raise before any HTTP, dot-boundary matching,
  extension values never in error strings or attempted entries.
- Native-filter parity: Tavily/Exa get the effective list, Perplexity
  skips its native filter past its 20-domain cap (post-filter stays
  authoritative), Brave/SearXNG/DuckDuckGo rely on post-filtering.
- MCP web_search gains allowed_hosts passthrough (only when supplied,
  so older search_fn fakes keep working).
- JS-heavy evaluation documented in docs/web-search-mcp.md, mock-first:
  direct fetch is shell-only by construction; provider extract can
  supply rendered text in mock only; live SearXNG in this VM env
  failed (remote closed connection) and paid-backend live effects are
  unvalidated blockers, not claimed.
- Tests: test_code_search_ext (9) + test_js_heavy_extract (5 + 1 live
  skipped); existing websearch/code-search suites still green.

(cherry picked from commit 9c26bbe848438e1f34bcb000700aeb4c284f4f23)
2026-11-01 09:06:28 +00:00
host 125b436ab3 browser: click-path allowlist, safe tenant script upgrade, allowlist activation plan
- click/click_ref: pre-click allowlist gate on resolvable anchor href
  (same check as navigate; miss refuses before the click executes);
  click/click_ref/click_at: post-click landing URL check.
  click_at has no pre-click gate by design (coordinates name no
  element); JS-driven navigation, evaluate, meta refresh and popups
  are documented as unintercepable with the Tinyproxy 403 backstop.
- Tenant script upgrade is now safe: only proven-unmodified scripts
  (manifest match, 2a569ac known-shipped SHA-256, missing, or
  current) auto-merge; tenant-modified scripts are never overwritten,
  are reported as skipped, and get one deduped audit record. The
  manifest never learns a tenant hash (else the next upgrade would
  mistake a modified file for untouched). Idempotent reruns.
- Activation candidate = deployed Tinyproxy programming set encoded
  with leading-dot entries (plain entries would regress
  docs.python.org); npmjs.org per the deployed filter, not npmjs.com.
  Host activation itself is NOT performed this round: only the plan,
  validation tests and rollback are delivered.
Tests: 28 new (click 13, upgrade 8, activation 7), all fake/no-network;
98 browser-related tests pass including prior suites.

(cherry picked from commit e4a93f5748ec815f163d0bc096ff4dc4b8c651f5)
2026-11-01 09:06:28 +00:00
host 1141d0d0f8 deploy: sync autoscaler unit with production host (seed-skills, imagegen-mcp, playwright-mcp)
仓库版 ExecStart 只带 --websearch-mcp,生产主机现行 unit 在此之外
还带 --seed-skills --imagegen-mcp --playwright-mcp;按仓库重装会静默
丢掉技能播种与两路 MCP。同步后仓库 ExecStart 与主机现行 ExecStart
逐 token 一致,并补 deploy/tests/test_autoscaler_unit.py(5 项)拦
后续漂移:四项齐全、无重复、均在 cloud/worker.py 有定义、不在
scaler 侧、--docker-runtime 仍为 runsc。

(cherry picked from commit bc3a23fc068cac189c0173b8e1c2befa7b832ed2)
2026-11-01 09:06:28 +00:00
openqj 2a569ac6e3 Merge pull request #272 from openqj/feat-platform-browser
feat: platform browser multi-tab, navigation allowlist, close cleanup, egress filter, allowlist worker wiring
2026-11-01 16:42:59 +08:00
openqj 847d1280c4 Merge pull request #271 from openqj/feat-code-search
websearch: scope=code programming search + provider extract chain
2026-11-01 16:38:31 +08:00
host da7a9950fb feat: platform browser multi-tab, navigation allowlist, close cleanup, egress filter, allowlist worker wiring
- Multi-tab: browser_new_page / browser_list_pages /
  browser_switch_page / browser_close_page in playwright_mcp.py;
  each new page gets a fresh Playwright context, so cookies and
  storage are isolated between pages. Single-page tools act on
  the current page; switching to a missing index is an error,
  not a crash; closing the last page is an error (use
  browser_close).
- Navigation allowlist: BROWSER_ALLOWED_HOSTS (comma list,
  leading-dot suffix match, case/port-insensitive) checked in
  _check_allowed_url before launching Chromium; unset (or empty)
  means no check, the old behaviour. file:// always allowed;
  localhost/127.0.0.1 have no special treatment.
- Close cleanup: browser_close closes all pages and best-effort
  removes cwd browser-live.png / browser-live.json; custom
  screenshot files are untouched. list_pages needs no launch.
- Egress filter: 10 programming-source Tinyproxy BASIC rules
  (python.org, nodejs.org, go.dev, pkg.go.dev, rust-lang.org,
  developer.mozilla.org, crates.io, stackoverflow.com,
  cve.mitre.org, nvd.nist.gov); already-present github.com /
  pypi.org / npmjs.org not duplicated; searx.diante.me stays
  exact-only, no diante.me wildcard.
- Allowlist worker wiring: _docker_task_context now passes a
  set, non-empty BROWSER_ALLOWED_HOSTS from the worker env into
  [mcp_servers.playwright.env] in the tenant config, so the MCP
  process actually sees it (container -e alone does not reach
  MCP subprocesses, 2026-10-30). Unset/empty writes nothing and
  never overwrites; the value never goes into logs; it is not
  added to the websearch env block or the container env.
  ensure_playwright_mcp_env now returns False without rewriting
  when the result would be identical (canonical join: one blank
  line before the block), so a second prep leaves an unchanged
  tenant config alone; other sections untouched.
- Skill + docs: browser-automation SKILL.md multi-tab/allowlist
  section; docs/platform-browser.md comparison doc (new file).
- Tests (mock/fake only, no network, no real Chromium, no real
  credentials): cloud.tests.test_playwright +
  cloud.tests.test_websearch_worker, 48 tests OK, including 6
  new wiring tests (switch off passes no variable, switch on
  reaches MCP env verbatim, empty never written, old-tenant
  merge idempotent + no-rewrite, ensure no-op when identical,
  allowlist enforces only after wiring).
- NOT done (see PR body): no persistent profile, no credential
  vault, no approval card, no CAPTCHA bypass, in-page link
  navigation does not pass the allowlist, no real-Chromium live
  verification, host tinyproxy filter not reloaded.
2026-11-01 08:34:13 +00:00
host 397a46f850 websearch: add scope=code programming search + provider extract chain
- search(..., scope=code) restricts to 15 programming domains
  (official docs, GitHub, registries, Stack Overflow, CVE/NVD):
  native include_domains/search_domain_filter where supported
  (Tavily, Exa, Perplexity), post-filtering for all providers
  (Brave, SearXNG, DuckDuckGo have no native filter).
- web_fetch now uses fetch_best: Tavily extract, then Exa
  contents, then direct fetch; both-fail errors name both
  failures, direct-fetch 403 under the egress whitelist is
  fail-closed, not bypassed. Default general scope and direct
  fetch_url behaviour unchanged.
- MCP web_search gains scope enum (general/code); docs updated.
- Tests all mock HTTP, no real keys.
2026-11-01 08:16:48 +00:00
openqj 27f48fe1ed Merge pull request #200 from openqj/feat-console-project-nav2
console: 项目导航移到左侧栏,工作台不再承担项目模块
2026-11-01 15:27:54 +08:00
host 08484302ac console: 项目导航移到左侧栏,工作台不再承担项目模块
左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色,
选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url)
+ 选中项目成员(邀请/改角色/新增移除);工作台删除
sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一
项目选择器,自动选第一个项目逻辑不变。
薄模块补齐(均为已核实后端路由):成员移除 DELETE、
自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进
任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额
POST。主线新功能(Todos、Browser、taskMeta 等)原样保留,
未做任何回退。console 标记单测本地 53 项通过。
2026-10-30 07:43:54 +00:00
50 changed files with 8851 additions and 210 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
name: ci
on: [push]
jobs:
test:
runs-on: host
steps:
- name: install python
run: apk add --no-cache python3 py3-pip ca-certificates git 2>&1 | tail -3; python3 --version; git --version
- name: clone repo
run: |
rm -rf src
git clone https://gitea.tose.top/admin/codex-app-server.git src
cd src && git rev-parse HEAD
- name: host tests
run: cd src && python3 -m unittest discover -s tests -v
- name: cloud tests
run: cd src && python3 -m unittest discover -s cloud/tests -v
- name: deploy tests
run: cd src && python3 -m unittest discover -s deploy/tests -v
+9 -6
View File
@@ -9,8 +9,7 @@ import ConversationSection from "./sections/ConversationSection";
import TaskDetailPanel from "./sections/TaskDetailPanel"; import TaskDetailPanel from "./sections/TaskDetailPanel";
import ApiKeySection from "./sections/ApiKeySection"; import ApiKeySection from "./sections/ApiKeySection";
import AdminSection from "./sections/AdminSection"; import AdminSection from "./sections/AdminSection";
import ProjectsSection from "./sections/ProjectsSection"; import ProjectNav from "./sections/ProjectNav";
import MembersPanel from "./sections/MembersPanel";
import EnvSection from "./sections/EnvSection"; import EnvSection from "./sections/EnvSection";
import EnvContextPanel from "./sections/EnvContextPanel"; import EnvContextPanel from "./sections/EnvContextPanel";
import WorkersSection from "./sections/WorkersSection"; import WorkersSection from "./sections/WorkersSection";
@@ -25,7 +24,10 @@ import TodosSection from "./sections/TodosSection";
// data-cards row on top of a long stacked page, no detail stack // data-cards row on top of a long stacked page, no detail stack
// hanging under the conversation. The new shell is an IDE-style // hanging under the conversation. The new shell is an IDE-style
// workspace that never scrolls as one page: // workspace that never scrolls as one page:
// 左侧栏:导航(按工作流分组)+ 数据卡片(紧凑版,始终可见) // 左侧栏:导航(按工作流分组)+ 项目模块(ProjectNav:项目列表 +
// 创建项目 + 成员,id nav-projects,是全站唯一的项目选择器;
// 因此不再有独立的“项目与成员”视图,旧 ProjectsSection 已删,
// 工作台也不再带项目选择器)+ 数据卡片(紧凑版,始终可见)
// 任务工作台:左 = 任务列表轨(搜索/筛选/分页),中 = 派发 + 对话, // 任务工作台:左 = 任务列表轨(搜索/筛选/分页),中 = 派发 + 对话,
// 右 = 任务详情检查器(概览/协作/评审与审批/线程四个内页签, // 右 = 任务详情检查器(概览/协作/评审与审批/线程四个内页签,
// 见 TaskDetailPanel) // 见 TaskDetailPanel)
@@ -36,11 +38,10 @@ import TodosSection from "./sections/TodosSection";
// unmounted with a view. The view is synced to location.hash // unmounted with a view. The view is synced to location.hash
// (#view-tasks etc.) so a reload or a shared link lands on the // (#view-tasks etc.) so a reload or a shared link lands on the
// same view; unknown hashes fall back to the task workspace. // same view; unknown hashes fall back to the task workspace.
type ViewId = "tasks" | "todos" | "projects" | "resources" | "automation" | "admin"; type ViewId = "tasks" | "todos" | "resources" | "automation" | "admin";
const VIEWS: Array<[ViewId, string, string]> = [ const VIEWS: Array<[ViewId, string, string]> = [
["tasks", "任务工作台", "任务"], ["tasks", "任务工作台", "任务"],
["todos", "项目待办", "任务"], ["todos", "项目待办", "任务"],
["projects", "项目与成员", "管理"],
["resources", "环境与 Workers", "管理"], ["resources", "环境与 Workers", "管理"],
["automation", "技能与自动化", "管理"], ["automation", "技能与自动化", "管理"],
["admin", "系统与 Key", "管理"], ["admin", "系统与 Key", "管理"],
@@ -67,6 +68,9 @@ function SideNav({ view, setView }: { view: ViewId; setView: (v: ViewId) => void
</div> </div>
))} ))}
</nav> </nav>
<div id="nav-projects" className="shell-project-nav">
<ProjectNav />
</div>
<div className="shell-stats"> <div className="shell-stats">
<div className="shell-stats-label">概览</div> <div className="shell-stats-label">概览</div>
<DataCards /> <DataCards />
@@ -138,7 +142,6 @@ export default function App() {
) : ( ) : (
<main className="page"> <main className="page">
{view === "todos" ? (<><TodosSection /></>) {view === "todos" ? (<><TodosSection /></>)
: view === "projects" ? (<><ProjectsSection /><MembersPanel /></>)
: view === "resources" ? (<><EnvSection /><EnvContextPanel /><WorkersSection /><HealthPanel /></>) : view === "resources" ? (<><EnvSection /><EnvContextPanel /><WorkersSection /><HealthPanel /></>)
: view === "automation" ? (<><SkillsSection /><SkillsToolsPanel /><AutomationsSection /><TriageSection /></>) : view === "automation" ? (<><SkillsSection /><SkillsToolsPanel /><AutomationsSection /><TriageSection /></>)
: (<><AdminSection /><ApiKeySection /></>)} : (<><AdminSection /><ApiKeySection /></>)}
+1
View File
@@ -93,6 +93,7 @@ export function memberErr(e: Error): string {
if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员"; if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框"; if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框";
if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner"; if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
if (m.includes("cannot remove the only owner")) return "不能移除唯一 owner:请先把另一位成员升为 owner";
if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确"; if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确";
if (m.includes("member not found")) return "该用户不是本项目成员"; if (m.includes("member not found")) return "该用户不是本项目成员";
if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner"; if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner";
+4 -2
View File
@@ -36,7 +36,9 @@ export interface Followup { mode?: string; status?: string; sender_task_id?: str
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null } export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null }
export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string } export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string } export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; created_at?: number; cancelled_at?: number; run_once?: boolean } export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; last_claimed_at?: number; created_by?: string; created_at?: number; cancelled_at?: number; first_run_at?: number; run_once?: boolean; project_id?: string }
export interface AutomationRun { id?: string; automation_id?: string; project_id?: string; task_id?: string; status?: string; detail?: string; created_at?: number }
export interface AutomationDetail extends Automation { runs?: AutomationRun[]; runs_note?: string; heartbeat_binding?: unknown }
export interface Member { user_id: string; username?: string; role?: string } export interface Member { user_id: string; username?: string; role?: string }
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string } export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string } export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
@@ -45,7 +47,7 @@ export interface Agent { kind?: string; name?: string; status?: string; prompt?:
export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number } export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number }
export interface Triage { export interface Triage {
counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number }; counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number };
automations_due?: Array<{ name?: string; interval_seconds?: number }>; automations_due?: Array<{ id?: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }>;
failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>; failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>;
pending_followups?: Array<{ task_id?: string; body?: string }>; pending_followups?: Array<{ task_id?: string; body?: string }>;
recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>; recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>;
+4 -4
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types"; import type { Automation, AutomationDetail, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
export interface ConsoleCtx { export interface ConsoleCtx {
keySaved: boolean; keyState: string; keySaved: boolean; keyState: string;
@@ -7,13 +7,13 @@ export interface ConsoleCtx {
quotaText: string; quotaText: string;
healthText: string; refreshHealth: () => void; healthText: string; refreshHealth: () => void;
adminGate: string; adminBody: boolean; adminResult: string; adminUserId: string; setAdminUserId: (v: string) => void; adminGate: string; adminBody: boolean; adminResult: string; adminUserId: string; setAdminUserId: (v: string) => void;
adminQuota: () => void; adminSweep: () => void; adminBackup: () => void; adminQuota: () => void; adminSetQuota: (uid: string, usdText: string) => void; adminSweep: () => void; adminBackup: () => void;
projects: Project[]; projectId: string; projectMsg: string; projects: Project[]; projectId: string; projectMsg: string;
selectProject: (id: string) => void; refreshProjects: () => void; selectProject: (id: string) => void; refreshProjects: () => void;
createProject: (name: string, repo: string) => Promise<void>; createProject: (name: string, repo: string) => Promise<void>;
githubRepos: Array<{ clone_url: string; repo_full_name: string }>; githubRepos: Array<{ clone_url: string; repo_full_name: string }>;
workers: Worker[] | null; workersSummary: string; workersError: string; refreshWorkers: () => void; workers: Worker[] | null; workersSummary: string; workersError: string; refreshWorkers: () => void;
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>; members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>; removeMember: (uid: string) => void;
skills: SkillItem[]; apps: SkillItem[]; mcpServers: McpServer[]; skillMsg: string; skillError: string; mcpMsg: string; mcpOauthMsg: string; mcpOauthBox: string; skills: SkillItem[]; apps: SkillItem[]; mcpServers: McpServer[]; skillMsg: string; skillError: string; mcpMsg: string; mcpOauthMsg: string; mcpOauthBox: string;
refreshSkills: () => void; reloadMcp: () => void; mcpOauth: (name: string) => Promise<void>; refreshSkills: () => void; reloadMcp: () => void; mcpOauth: (name: string) => Promise<void>;
envs: Env[]; envCount: string; envMsg: string; envRepoLine: string; envRepoBar: boolean; envRepoMsg: string; envSelect: string; setEnvSelect: (v: string) => void; envs: Env[]; envCount: string; envMsg: string; envRepoLine: string; envRepoBar: boolean; envRepoMsg: string; envSelect: string; setEnvSelect: (v: string) => void;
@@ -35,7 +35,7 @@ export interface ConsoleCtx {
followups: Followup[]; followups: Followup[];
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void; approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>; peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void; automations: Automation[]; autoMsg: string; autoDetail: AutomationDetail | null; autoDetailMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void; loadAutomationDetail: (id: string) => void; closeAutomationDetail: () => void;
triage: Triage | null; triageError: string; refreshTriage: () => void; triage: Triage | null; triageError: string; refreshTriage: () => void;
threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void; threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void;
dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string; dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string;
+30 -1
View File
@@ -253,6 +253,17 @@ export function useCatalog() {
} catch (e: any) { setMemberMsg(memberErr(e)); } } catch (e: any) { setMemberMsg(memberErr(e)); }
}, []); }, []);
const removeMember = useCallback(async (uid: string) => {
const pid = projectIdRef.current;
if (!pid) return;
if (!window.confirm("确定移除该成员吗?移除后其项目权限立即失效(唯一 owner 不可移除,服务端会拒绝)。")) return;
try {
await api(paths.projectMember(pid, uid), "DELETE");
setMemberMsg("成员已移除");
await refreshMembers(pid);
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, [refreshMembers]);
const reloadMcp = useCallback(async () => { const reloadMcp = useCallback(async () => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
if (!pid) { setMcpMsg("先选项目"); return; } if (!pid) { setMcpMsg("先选项目"); return; }
@@ -289,6 +300,24 @@ export function useCatalog() {
} catch (e: any) { setAdminResult(adminErr(e)); } } catch (e: any) { setAdminResult(adminErr(e)); }
}, [adminUserId]); }, [adminUserId]);
const adminSetQuota = useCallback(async (uid: string, usdText: string) => {
const target = uid.trim();
if (!target) { setAdminResult("请填要设置配额的用户 id"); return; }
// POST /admin/quotas {user_id, quota_usd_micros}: integer micros or
// null for unlimited, >= 0. The console takes dollars and converts.
let micros: number | null;
if (!usdText.trim()) micros = null;
else {
const usd = Number(usdText.trim());
if (!Number.isFinite(usd) || usd < 0) { setAdminResult("配额金额无效:请输入 >= 0 的美元数,留空表示不限额"); return; }
micros = Math.round(usd * 1000000);
}
try {
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotas, "POST", { user_id: target, quota_usd_micros: micros });
setAdminResult("配额已设置:用户 " + target.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
const adminSweep = useCallback(async () => { const adminSweep = useCallback(async () => {
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return; if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
try { try {
@@ -314,6 +343,6 @@ export function useCatalog() {
models, modelCustomVisible, models, modelCustomVisible,
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers, refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv, refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup, archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, removeMember, reloadMcp, mcpOauth, adminQuota, adminSetQuota, adminSweep, adminBackup,
}; };
} }
@@ -108,14 +108,14 @@ export function useConsoleStore(): ConsoleCtx {
healthText: catalog.healthText, refreshHealth: catalog.refreshHealth, healthText: catalog.healthText, refreshHealth: catalog.refreshHealth,
adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult, adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult,
adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId, adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId,
adminQuota: catalog.adminQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup, adminQuota: catalog.adminQuota, adminSetQuota: catalog.adminSetQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg, projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg,
selectProject, refreshProjects: catalog.refreshProjects, selectProject, refreshProjects: catalog.refreshProjects,
createProject: catalog.createProject, createProject: catalog.createProject,
githubRepos: catalog.githubRepos, githubRepos: catalog.githubRepos,
workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers, workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers,
members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError, members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError,
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole, refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole, removeMember: catalog.removeMember,
skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError, skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError,
mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox, mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox,
refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth, refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth,
@@ -144,7 +144,7 @@ export function useConsoleStore(): ConsoleCtx {
followups: detail.followups, followups: detail.followups,
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval, approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer, peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation, automations: tools.automations, autoMsg: tools.autoMsg, autoDetail: tools.autoDetail, autoDetailMsg: tools.autoDetailMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation, loadAutomationDetail: tools.loadAutomationDetail, closeAutomationDetail: tools.closeAutomationDetail,
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(), triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction, threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
dispatch: async (body, atts) => { dispatch: async (body, atts) => {
@@ -1,13 +1,15 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { paths } from "../api/paths"; import { paths } from "../api/paths";
import type { Automation, Triage } from "../api/types"; import type { Automation, AutomationDetail, Triage } from "../api/types";
// Project-scoped tools shown in the centre for their own views: // Project-scoped tools shown in the centre for their own views:
// automations (create/list/cancel) and the triage inbox (4 groups). // automations (create/list/cancel) and the triage inbox (4 groups).
export function useProjectTools(getProjectId: () => string) { export function useProjectTools(getProjectId: () => string) {
const [automations, setAutomations] = useState<Automation[]>([]); const [automations, setAutomations] = useState<Automation[]>([]);
const [autoMsg, setAutoMsg] = useState(""); const [autoMsg, setAutoMsg] = useState("");
const [autoDetail, setAutoDetail] = useState<AutomationDetail | null>(null);
const [autoDetailMsg, setAutoDetailMsg] = useState("");
const [triage, setTriage] = useState<Triage | null>(null); const [triage, setTriage] = useState<Triage | null>(null);
const [triageError, setTriageError] = useState(""); const [triageError, setTriageError] = useState("");
@@ -66,10 +68,26 @@ export function useProjectTools(getProjectId: () => string) {
const pid = getProjectId(); const pid = getProjectId();
if (!pid) return; if (!pid) return;
void api(paths.projectAutomationCancel(pid, id), "POST", {}) void api(paths.projectAutomationCancel(pid, id), "POST", {})
.then(() => { setAutoMsg("自动化已取消"); void refreshAutomations(pid); }) .then(() => { setAutoMsg("自动化已取消"); setAutoDetail((prev) => (prev && prev.id === id ? null : prev)); void refreshAutomations(pid); })
.catch((e: any) => setAutoMsg("取消失败:" + e.message)); .catch((e: any) => setAutoMsg("取消失败:" + e.message));
}, [getProjectId, refreshAutomations]); }, [getProjectId, refreshAutomations]);
// GET /projects/{pid}/automations/{aid}: full definition + recent
// runs (+ runs_note, heartbeat_binding). Verified against origin/main
// cloud/server.py _automation_get / cloud/automations.py get_automation.
const loadAutomationDetail = useCallback(async (id: string) => {
const pid = getProjectId();
if (!pid) { setAutoDetailMsg("先选项目"); return; }
try {
const d = await api<AutomationDetail>(paths.projectAutomation(pid, id));
setAutoDetail(d);
setAutoDetailMsg("");
} catch (e: any) { setAutoDetailMsg("详情加载失败:" + e.message); }
}, [getProjectId]);
const closeAutomationDetail = useCallback(() => { setAutoDetail(null); setAutoDetailMsg(""); }, []);
const refreshTriage = useCallback(async (pid?: string) => { const refreshTriage = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : getProjectId(); const id = pid !== undefined ? pid : getProjectId();
if (!id) { setTriage(null); return; } if (!id) { setTriage(null); return; }
@@ -80,5 +98,5 @@ export function useProjectTools(getProjectId: () => string) {
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); } } catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
}, [getProjectId]); }, [getProjectId]);
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, refreshTriage }; return { automations, autoMsg, autoDetail, autoDetailMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, loadAutomationDetail, closeAutomationDetail, refreshTriage };
} }
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useConsole } from "../context"; import { useConsole } from "../context";
import { Card, Bar } from "../components/ui"; import { Card, Bar } from "../components/ui";
export default function AdminSection() { export default function AdminSection() {
const ctx = useConsole(); const ctx = useConsole();
const [setUser, setSetUser] = useState("");
const [setUsd, setSetUsd] = useState("");
return ( return (
<Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}> <Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}>
<div className="muted">{ctx.adminGate}</div> <div className="muted">{ctx.adminGate}</div>
@@ -12,6 +15,11 @@ export default function AdminSection() {
<input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} /> <input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} />
<button onClick={ctx.adminQuota}>查该用户配额</button> <button onClick={ctx.adminQuota}>查该用户配额</button>
</Bar> </Bar>
<Bar>
<input type="text" placeholder="要设置配额的用户 id" value={setUser} onChange={(e) => setSetUser(e.target.value)} />
<input type="text" placeholder="美元配额(留空 = 不限额)" value={setUsd} onChange={(e) => setSetUsd(e.target.value)} />
<button className="primary" onClick={() => ctx.adminSetQuota(setUser, setUsd)}>设置配额</button>
</Bar>
<Bar> <Bar>
<button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button> <button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button>
<button onClick={ctx.adminBackup}>手动触发备份</button> <button onClick={ctx.adminBackup}>手动触发备份</button>
@@ -37,6 +37,7 @@ export default function MembersPanel() {
<select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}> <select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)} {ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
<button onClick={() => void ctx.removeMember(m.user_id)}>移除</button>
</li> </li>
); );
})} })}
@@ -0,0 +1,51 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { shortId } from "../api/client";
import MembersPanel from "./MembersPanel";
// Left sidebar: the one project selector for the whole console.
// Lists every project (name + short id + my role), click selects it;
// below the list is the create-project form (same fields as the old
// workbench ProjectsSection: name, GitHub repo select, repo_url) and
// the selected project's members. The workbench no longer carries any
// project-module UI; it only consumes ctx.projectId.
export default function ProjectNav() {
const ctx = useConsole();
const [name, setName] = useState("");
const [repoSelect, setRepoSelect] = useState("");
const [repo, setRepo] = useState("");
return (
<div className="project-nav">
<Card title="项目">
<Bar><button onClick={ctx.refreshProjects}>刷新项目</button></Bar>
{ctx.projects.length === 0 ? <div className="muted">暂无项目</div> : (
<ul className="project-nav-list">
{ctx.projects.map((p) => (
<li key={p.id} className={"project-nav-item" + (p.id === ctx.projectId ? " selected" : "")} onClick={() => ctx.selectProject(p.id)} title={p.id}>
<span className="project-nav-name">{p.name}</span>
<span className="project-nav-meta">{shortId(p.id)}{p.my_role ? " · " + p.my_role : ""}</span>
</li>
))}
</ul>
)}
<h3>创建项目</h3>
<Bar>
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
</Bar>
<Bar>
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
</Bar>
<Bar>
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
<button className="primary" onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>创建项目</button>
</Bar>
<span className="muted">{ctx.projectMsg}</span>
</Card>
<MembersPanel />
</div>
);
}
@@ -1,32 +0,0 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { shortId } from "../api/client";
export default function ProjectsSection() {
const ctx = useConsole();
const [name, setName] = useState("");
const [repoSelect, setRepoSelect] = useState("");
const [repo, setRepo] = useState("");
return (
<Card id="nav-projects" title="项目">
<Bar>
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
</select>
<button onClick={ctx.refreshProjects}>刷新</button>
</Bar>
<Bar>
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
<button onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>新建项目</button>
</Bar>
<span className="muted">{ctx.projectMsg}</span>
</Card>
);
}
@@ -5,9 +5,12 @@ import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts, // 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
// 与任务详情共用同一口径。 // 与任务详情共用同一口径。
// GitHub issues-style task list: search on top, the project selector // GitHub issues-style task list: search on top, a 主要聊天 header with
// (the auto-project fix — do not remove it), a 主要聊天 header with a // a + button, then hairline rows with a Primer status dot at the
// + button, then hairline rows with a Primer status dot at the left, // left. There is deliberately NO project selector here any more:
// the left sidebar (ProjectNav) is the one project selector, and the
// auto-select-first-project fix in the store keeps the list from
// ever starting empty.
// a 14px title and a 12px muted meta line. The search input IS the // a 14px title and a 12px muted meta line. The search input IS the
// existing task search (id/prompt); status and executor filters stay // existing task search (id/prompt); status and executor filters stay
// as a compact row below it. The selected row gets the light // as a compact row below it. The selected row gets the light
@@ -22,12 +25,6 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
</svg> </svg>
<input type="text" placeholder="搜索" aria-label="搜索任务" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} /> <input type="text" placeholder="搜索" aria-label="搜索任务" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
</div> </div>
<div className="bar">
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
</select>
</div>
<div className="list-section-head"> <div className="list-section-head">
<span>主要聊天</span> <span>主要聊天</span>
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button> <button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
@@ -1,22 +1,52 @@
import { useState } from "react";
import { useConsole } from "../context"; import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui"; import { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client"; import { workerAgeText, shortId } from "../api/client";
// Workers are read-only by design (GET /workers + worker-side
// heartbeat only — no start/stop/kill actions exist to surface), so
// production-grade here is list + status + refresh + frontend-only
// filtering of the already-fetched list. Non-admin users only get
// the online/offline aggregate from the backend (see _workers_list),
// so search/filter apply to the full list only and say so.
export default function WorkersSection() { export default function WorkersSection() {
const ctx = useConsole(); const ctx = useConsole();
const [query, setQuery] = useState("");
const [onlineFilter, setOnlineFilter] = useState<"all" | "online" | "offline">("all");
const isAlive = (w: { alive?: boolean | null; last_seen?: number; last_seen_seconds_ago?: number }) => {
const info = workerAgeText(w);
return w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
};
const q = query.trim().toLowerCase();
const filtered = (ctx.workers || []).filter((w) => {
if (q && !(((w.worker_id || w.id || "") + " " + (w.executor || "")).toLowerCase().includes(q))) return false;
if (onlineFilter === "online" && !isAlive(w)) return false;
if (onlineFilter === "offline" && isAlive(w)) return false;
return true;
});
return ( return (
<Card id="nav-workers" title="Workers"> <Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar> <Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
<Bar>
<input type="text" placeholder="搜索 worker_id / 执行器" aria-label="搜索 Worker" value={query} onChange={(e) => setQuery(e.target.value)} />
<select aria-label="在线状态筛选" value={onlineFilter} onChange={(e) => setOnlineFilter(e.target.value as "all" | "online" | "offline")}>
<option value="all">在线状态:全部</option>
<option value="online">仅在线</option>
<option value="offline">仅离线</option>
</select>
{ctx.workers ? <span className="muted">显示 {filtered.length} / 共 {ctx.workers.length} 个</span> : null}
</Bar>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div> {ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div> : ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)</div>
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div> : ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
: filtered.length === 0 ? <div className="muted">没有符合筛选条件的 worker</div>
: ( : (
<DataListRaw> <DataListRaw>
{ctx.workers.map((w, i) => { {filtered.map((w, i) => {
const run = w.running === undefined || w.running === null ? "?" : w.running; const run = w.running === undefined || w.running === null ? "?" : w.running;
const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity; const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity;
const info = workerAgeText(w); const info = workerAgeText(w);
const alive = w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120); const alive = isAlive(w);
const live = alive ? " / 在线" : " / 离线(占用为上次上报值)"; const live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
return ( return (
<li key={i} className={alive ? "is-online" : "is-offline"}> <li key={i} className={alive ? "is-online" : "is-offline"}>
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useConsole } from "../../context"; import { useConsole } from "../../context";
import { Bar, DataList } from "../../components/ui"; import { Bar, DataList } from "../../components/ui";
import { fmt, shortId } from "../../api/client"; import { fmt, shortId } from "../../api/client";
import type { Automation } from "../../api/types"; import type { Automation, AutomationDetail } from "../../api/types";
const PROMPT_PREVIEW = 160; const PROMPT_PREVIEW = 160;
@@ -14,6 +14,49 @@ function humanInterval(s?: number): string {
return ""; return "";
} }
// Automation detail (read-only display): backed by GET
// /projects/{pid}/automations/{aid} — full definition + runs[] +
// runs_note + heartbeat_binding. Editing is NOT duplicated here:
// the single edit form is main's inline edit in the list below
// (updateAutomation with the (a, name, prompt, interval, repo)
// signature). Detail display + runs + task buttons are our side's
// addition; cross-view jumps use location.hash because the IDE
// shell switches views on hashchange (no sec-conv / sec-automations
// element to scroll to).
function AutomationDetailBox({ detail }: { detail: AutomationDetail }) {
const ctx = useConsole();
const openTask = (id?: string) => {
if (!id) return;
ctx.showDetail(id);
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
};
return (
<div className="card">
<h2>自动化详情:{detail.name}</h2>
<div className="muted">
状态 {detail.status}{detail.due ? "|到期" : ""} | 每 {detail.interval_seconds} 秒 | 下次 {fmt(detail.next_run_at)}
| 上次执行 {fmt(detail.last_run_at)} | 创建人 {detail.created_by ? shortId(detail.created_by) : ""} | 创建于 {fmt(detail.created_at)}
{detail.cancelled_at ? " | 已取消于 " + fmt(detail.cancelled_at) : ""}
{detail.heartbeat_binding ? " | 心跳绑定:已绑定" : ""}
</div>
<div className="muted" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}>{detail.prompt || ""}</div>
{detail.repo_url ? <div className="muted">仓库 {detail.repo_url}</div> : null}
<Bar>
<button onClick={ctx.closeAutomationDetail}>关闭详情</button>
<span className="muted">编辑请在下面列表里点“编辑”。{ctx.autoDetailMsg}</span>
</Bar>
<div className="data-group"><b>最近产出({(detail.runs || []).length})</b>
<DataList empty="(无)" items={(detail.runs || []).map((r, i) => (
<span key={i} className="row-main">
{r.status}{r.created_at ? "|" + fmt(r.created_at) : ""}{r.task_id ? <> | 任务 <button onClick={() => openTask(r.task_id as string)}>{shortId(r.task_id)}</button></> : ""}{r.detail ? "|" + String(r.detail).slice(0, 120) : ""}
</span>
))} />
</div>
{detail.runs_note ? <div className="muted">{detail.runs_note}</div> : null}
</div>
);
}
export function AutomationPanel() { export function AutomationPanel() {
const ctx = useConsole(); const ctx = useConsole();
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -53,6 +96,7 @@ export function AutomationPanel() {
<div key={a.id}> <div key={a.id}>
<div className="row-main"> <div className="row-main">
[{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)} [{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)}
<button onClick={() => void ctx.loadAutomationDetail(a.id)}>详情</button>
{a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null} {a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
</div> </div>
{a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null} {a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null}
@@ -80,6 +124,8 @@ export function AutomationPanel() {
</div> </div>
); );
})} /> })} />
{ctx.autoDetailMsg && !ctx.autoDetail ? <div className="muted">{ctx.autoDetailMsg}</div> : null}
{ctx.autoDetail ? <AutomationDetailBox key={ctx.autoDetail.id} detail={ctx.autoDetail} /> : null}
</> </>
); );
} }
@@ -87,21 +133,45 @@ export function AutomationPanel() {
export function TriagePanel() { export function TriagePanel() {
const ctx = useConsole(); const ctx = useConsole();
const t = ctx.triage; const t = ctx.triage;
// Clicking an actionable item opens that task's detail (tasks view)
// or the automation's detail (automation view). Field names are
// exactly the triage payload's: failed_tasks[].id, pending_followups
// [].task_id, recent_runs[].task_id (nullable), automations_due[].id.
// Jumps use location.hash because the IDE shell switches views on
// hashchange; there is no sec-conv / sec-automations to scroll to.
const openTask = (id?: string) => {
if (!id) return;
ctx.showDetail(id);
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
};
const openAutomation = (id?: string) => {
if (!id) return;
void ctx.loadAutomationDetail(id);
try { window.location.hash = "#view-automation"; } catch { /* noop */ }
};
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>; if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
if (!t) return <div className="muted">(先选项目)</div>; if (!t) return <div className="muted">(先选项目)</div>;
return ( return (
<> <>
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b> <div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => <span key={i} className="row-main">{a.name}|每 {a.interval_seconds} 秒</span>)} /> <DataList empty="(无)" items={(t.automations_due || []).map((a, i) => (
<span key={i} className="row-main">{a.id ? <button onClick={() => openAutomation(a.id)}>{a.name}|每 {a.interval_seconds} 秒</button> : <>{a.name}|每 {a.interval_seconds} 秒</>}</span>
))} />
</div> </div>
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b> <div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => <span key={i} className="row-main">{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</span>)} /> <DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => (
<span key={i} className="row-main"><button onClick={() => openTask(x.id)}>{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</button></span>
))} />
</div> </div>
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b> <div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => <span key={i} className="row-main">任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</span>)} /> <DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => (
<span key={i} className="row-main"><button onClick={() => openTask(f.task_id)}>任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</button></span>
))} />
</div> </div>
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b> <div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => <span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? "|任务 " + shortId(r.task_id) : ""}{r.task_status ? "|" + r.task_status : ""}</span>)} /> <DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => (
<span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? <>|任务 <button onClick={() => openTask(r.task_id)}>{shortId(r.task_id)}</button></> : ""}{r.task_status ? "|" + r.task_status : ""}</span>
))} />
</div> </div>
</> </>
); );
+16 -1
View File
@@ -254,7 +254,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
which is still tight — just in a different order. */ which is still tight — just in a different order. */
.dash-col{display:contents} .dash-col{display:contents}
#sec-dispatch{order:1} #sec-tasks{order:2} #sec-conv{order:3} #sec-dispatch{order:1} #sec-tasks{order:2} #sec-conv{order:3}
#sec-projects{order:4} #sec-env{order:5} #sec-workers{order:6} #sec-env{order:5} #sec-workers{order:6}
#sec-skills{order:7} #sec-automations{order:8} #sec-triage{order:9} #sec-skills{order:7} #sec-automations{order:8} #sec-triage{order:9}
#sec-admin{order:10} #sec-key{order:11} #sec-admin{order:10} #sec-key{order:11}
.dash-cards{grid-template-columns:repeat(2,1fr)} .dash-cards{grid-template-columns:repeat(2,1fr)}
@@ -285,6 +285,20 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
.shell-nav button{text-align:left;border:0;box-shadow:none;background:transparent;padding:8px;border-radius:var(--radius);font-weight:500} .shell-nav button{text-align:left;border:0;box-shadow:none;background:transparent;padding:8px;border-radius:var(--radius);font-weight:500}
.shell-nav button:hover{background:#fff} .shell-nav button:hover{background:#fff}
.shell-nav button.active{background:var(--text);color:#fff} .shell-nav button.active{background:var(--text);color:#fff}
/* Project sidebar (ProjectNav): the one project selector for the whole
console — project list + create + members — living in the IDE
shell's left sidebar. Same Primer tokens as the rest of the shell;
the sidebar is 260px wide, so its bars stack full-width. */
.shell-project-nav{padding:0 12px 16px;min-width:0}
.shell-project-nav .project-nav{display:flex;flex-direction:column;gap:16px;min-width:0}
.shell-project-nav .project-nav-list{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.shell-project-nav .project-nav-item{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--line);background:#fff}
.shell-project-nav .project-nav-item:last-child{border-bottom:0}
.shell-project-nav .project-nav-item:hover{background:var(--subtle-bg)}
.shell-project-nav .project-nav-item.selected{background:var(--selected-bg)}
.shell-project-nav .project-nav-name{display:block;font-size:14px;font-weight:600;overflow-wrap:anywhere}
.shell-project-nav .project-nav-meta{display:block;font-size:12px;color:var(--muted)}
.shell-project-nav .bar > input[type="text"],.shell-project-nav .bar > select{flex:1 1 100%}
.shell-stats{margin-top:auto;border-top:1px solid var(--line);padding:16px 12px} .shell-stats{margin-top:auto;border-top:1px solid var(--line);padding:16px 12px}
.shell-stats-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 4px 8px} .shell-stats-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 4px 8px}
.shell-stats .dash-cards{display:flex;flex-direction:column;gap:8px} .shell-stats .dash-cards{display:flex;flex-direction:column;gap:8px}
@@ -316,6 +330,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
.shell-side{flex-direction:row;flex-wrap:wrap;align-items:flex-start;border-right:0;border-bottom:1px solid var(--line);overflow:visible} .shell-side{flex-direction:row;flex-wrap:wrap;align-items:flex-start;border-right:0;border-bottom:1px solid var(--line);overflow:visible}
.shell-nav{flex-direction:row;flex-wrap:wrap} .shell-nav{flex-direction:row;flex-wrap:wrap}
.shell-nav-group{flex-direction:row;align-items:center} .shell-nav-group{flex-direction:row;align-items:center}
.shell-project-nav{flex:1 1 100%}
.shell-stats{display:none} .shell-stats{display:none}
.shell-hide-sm{display:none} .shell-hide-sm{display:none}
.workspace{grid-template-columns:1fr} .workspace{grid-template-columns:1fr}
+31 -31
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title> <title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const C of g)if(C.type==="childList")for(const A of C.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(g){const C={};return g.integrity&&(C.integrity=g.integrity),g.referrerPolicy&&(C.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?C.credentials="include":g.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(g){if(g.ep)return;g.ep=!0;const C=h(g);fetch(g.href,C)}})();function Kh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var no={exports:{}},ui={};/** <script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const C of y)if(C.type==="childList")for(const M of C.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const C={};return y.integrity&&(C.integrity=y.integrity),y.referrerPolicy&&(C.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?C.credentials="include":y.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(y){if(y.ep)return;y.ep=!0;const C=h(y);fetch(y.href,C)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},si={};/**
* @license React * @license React
* react-jsx-runtime.production.js * react-jsx-runtime.production.js
* *
@@ -12,7 +12,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Rh;function ky(){if(Rh)return ui;Rh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,C){var A=null;if(C!==void 0&&(A=""+C),g.key!==void 0&&(A=""+g.key),"key"in g){C={};for(var S in g)S!=="key"&&(C[S]=g[S])}else C=g;return g=C.ref,{$$typeof:i,type:f,key:A,ref:g!==void 0?g:null,props:C}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Dh;function By(){return Dh||(Dh=1,no.exports=ky()),no.exports}var c=By(),uo={exports:{}},yt={};/** */var Dh;function Hy(){if(Dh)return si;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,C){var M=null;if(C!==void 0&&(M=""+C),y.key!==void 0&&(M=""+y.key),"key"in y){C={};for(var S in y)S!=="key"&&(C[S]=y[S])}else C=y;return y=C.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:C}}return si.Fragment=r,si.jsx=h,si.jsxs=h,si}var Uh;function By(){return Uh||(Uh=1,io.exports=Hy()),io.exports}var c=By(),co={exports:{}},yt={};/**
* @license React * @license React
* react.production.js * react.production.js
* *
@@ -20,7 +20,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Uh;function qy(){if(Uh)return yt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),k=Symbol.for("react.view_transition"),W=Symbol.iterator;function V(y){return y===null||typeof y!="object"?null:(y=W&&y[W]||y["@@iterator"],typeof y=="function"?y:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,ht={};function gt(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}gt.prototype.isReactComponent={},gt.prototype.setState=function(y,D){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,D,"setState")},gt.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function xt(){}xt.prototype=gt.prototype;function Z(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}var Tt=Z.prototype=new xt;Tt.constructor=Z,X(Tt,gt.prototype),Tt.isPureReactComponent=!0;var w=Array.isArray;function Q(){}var et={H:null,A:null,T:null,S:null},zt=Object.prototype.hasOwnProperty;function Bt(y,D,ct){var it=ct.ref;return{$$typeof:i,type:y,key:D,ref:it!==void 0?it:null,props:ct}}function vt(y,D){return Bt(y.type,D,y.props)}function wt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function q(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ct){return D[ct]})}var ot=/\/+/g;function rt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?q(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Q,Q):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function N(y,D,ct,it,Nt){var _t=typeof y;(_t==="undefined"||_t==="boolean")&&(y=null);var Rt=!1;if(y===null)Rt=!0;else switch(_t){case"bigint":case"string":case"number":Rt=!0;break;case"object":switch(y.$$typeof){case i:case r:Rt=!0;break;case Y:return Rt=y._init,N(Rt(y._payload),D,ct,it,Nt)}}if(Rt)return Nt=Nt(y),Rt=it===""?"."+rt(y,0):it,w(Nt)?(ct="",Rt!=null&&(ct=Rt.replace(ot,"$&/")+"/"),N(Nt,D,ct,"",function(ce){return ce})):Nt!=null&&(wt(Nt)&&(Nt=vt(Nt,ct+(Nt.key==null||y&&y.key===Nt.key?"":(""+Nt.key).replace(ot,"$&/")+"/")+Rt)),D.push(Nt)),1;Rt=0;var P=it===""?".":it+":";if(w(y))for(var dt=0;dt<y.length;dt++)it=y[dt],_t=P+rt(it,dt),Rt+=N(it,D,ct,_t,Nt);else if(dt=V(y),typeof dt=="function")for(y=dt.call(y),dt=0;!(it=y.next()).done;)it=it.value,_t=P+rt(it,dt++),Rt+=N(it,D,ct,_t,Nt);else if(_t==="object"){if(typeof y.then=="function")return N(G(y),D,ct,it,Nt);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Rt}function ut(y,D,ct){if(y==null)return y;var it=[],Nt=0;return N(y,it,"","",function(_t){return D.call(ct,_t,Nt++)}),it}function St(y){if(y._status===-1){var D=y._result,ct=D();ct.then(function(it){(y._status===0||y._status===-1)&&(y._status=1,y._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(y._status===0||y._status===-1)&&(y._status=2,y._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),y._status===-1&&(y._status=0,y._result=ct)}if(y._status===1)return y._result.default;throw y._result}var pt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ge(y){var D=et.T,ct={};ct.types=D!==null?D.types:null,et.T=ct;try{var it=y(),Nt=et.S;Nt!==null&&Nt(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(Q,pt)}catch(_t){pt(_t)}finally{D!==null&&ct.types!==null&&(D.types=ct.types),et.T=D}}function we(y){var D=et.T;if(D!==null){var ct=D.types;ct===null?D.types=[y]:ct.indexOf(y)===-1&&ct.push(y)}else ge(we.bind(null,y))}var Ce={map:ut,forEach:function(y,D,ct){ut(y,function(){D.apply(this,arguments)},ct)},count:function(y){var D=0;return ut(y,function(){D++}),D},toArLine truncated */var wh;function qy(){if(wh)return yt;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),M=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),O=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),L=Symbol.iterator;function tt(g){return g===null||typeof g!="object"?null:(g=L&&g[L]||g["@@iterator"],typeof g=="function"?g:null)}var dt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},J=Object.assign,gt={};function _t(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}_t.prototype.isReactComponent={},_t.prototype.setState=function(g,U){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,U,"setState")},_t.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function Dt(){}Dt.prototype=_t.prototype;function Q(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}var ft=Q.prototype=new Dt;ft.constructor=Q,J(ft,_t.prototype),ft.isPureReactComponent=!0;var D=Array.isArray;function X(){}var et={H:null,A:null,T:null,S:null},xt=Object.prototype.hasOwnProperty;function Mt(g,U,ct){var it=ct.ref;return{$$typeof:i,type:g,key:U,ref:it!==void 0?it:null,props:ct}}function mt(g,U){return Mt(g.type,U,g.props)}function Nt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function B(g){var U={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(ct){return U[ct]})}var nt=/\/+/g;function rt(g,U){return typeof g=="object"&&g!==null&&g.key!=null?B(""+g.key):U.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(X,X):(g.status="pending",g.then(function(U){g.status==="pending"&&(g.status="fulfilled",g.value=U)},function(U){g.status==="pending"&&(g.status="rejected",g.reason=U)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function A(g,U,ct,it,Ot){var Et=typeof g;(Et==="undefined"||Et==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Et){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Y:return kt=g._init,A(kt(g._payload),U,ct,it,Ot)}}if(kt)return Ot=Ot(g),kt=it===""?"."+rt(g,0):it,D(Ot)?(ct="",kt!=null&&(ct=kt.replace(nt,"$&/")+"/"),A(Ot,U,ct,"",function(se){return se})):Ot!=null&&(Nt(Ot)&&(Ot=mt(Ot,ct+(Ot.key==null||g&&g.key===Ot.key?"":(""+Ot.key).replace(nt,"$&/")+"/")+kt)),U.push(Ot)),1;kt=0;var P=it===""?".":it+":";if(D(g))for(var ht=0;ht<g.length;ht++)it=g[ht],Et=P+rt(it,ht),kt+=A(it,U,ct,Et,Ot);else if(ht=tt(g),typeof ht=="function")for(g=ht.call(g),ht=0;!(it=g.next()).done;)it=it.value,Et=P+rt(it,ht++),kt+=A(it,U,ct,Et,Ot);else if(Et==="object"){if(typeof g.then=="function")return A(G(g),U,ct,it,Ot);throw U=String(g),Error("Objects are not valid as a React child (found: "+(U==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":U)+"). If you meant to render a collection of children, use an array instead.")}return kt}function ut(g,U,ct){if(g==null)return g;var it=[],Ot=0;return A(g,it,"","",function(Et){return U.call(ct,Et,Ot++)}),it}function bt(g){if(g._status===-1){var U=g._result,ct=U();ct.then(function(it){(g._status===0||g._status===-1)&&(g._status=1,g._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(g._status===0||g._status===-1)&&(g._status=2,g._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),g._status===-1&&(g._status=0,g._result=ct)}if(g._status===1)return g._result.default;throw g._result}var pt=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var U=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(U))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function Se(g){var U=et.T,ct={};ct.types=U!==null?U.types:null,et.T=ct;try{var it=g(),Ot=et.S;Ot!==null&&Ot(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(X,pt)}catch(Et){pt(Et)}finally{U!==null&&ct.types!==null&&(U.types=ct.types),et.T=U}}function we(g){var U=et.T;if(U!==null){var ct=U.types;ct===null?U.types=[g]:ct.indexOf(g)===-1&&ct.push(g)}else Se(we.bind(null,g))}var Ne={map:ut,forEach:function(g,U,ct){ut(g,function(){U.apply(this,arguments)},ct)},count:function(g){var U=0;return ut(g,function(){U++}),U}Line truncated
* @license React * @license React
* scheduler.production.js * scheduler.production.js
* *
@@ -28,7 +28,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var wh;function Gy(){return wh||(wh=1,(function(i){function r(G,N){var ut=G.length;G.push(N);t:for(;0<ut;){var St=ut-1>>>1,pt=G[St];if(0<g(pt,N))G[St]=N,G[ut]=pt,ut=St;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var N=G[0],ut=G.pop();if(ut!==N){G[0]=ut;t:for(var St=0,pt=G.length,ge=pt>>>1;St<ge;){var we=2*(St+1)-1,Ce=G[we],y=we+1,D=G[y];if(0>g(Ce,ut))y<pt&&0>g(D,Ce)?(G[St]=D,G[y]=ut,St=y):(G[St]=Ce,G[we]=ut,St=we);else if(y<pt&&0>g(D,ut))G[St]=D,G[y]=ut,St=y;else break t}}return N}function g(G,N){var ut=G.sortIndex-N.sortIndex;return ut!==0?ut:G.id-N.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var H=[],R=[],Y=1,x=null,k=3,W=!1,V=!1,I=!1,X=!1,ht=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,xt=typeof setImmediate<"u"?setImmediate:null;function Z(G){for(var N=h(R);N!==null;){if(N.callback===null)f(R);else if(N.startTime<=G)f(R),N.sortIndex=N.expirationTime,r(H,N);else break;N=h(R)}}function Tt(G){if(I=!1,Z(G),!V)if(h(H)!==null)V=!0,w||(w=!0,wt());else{var N=h(R);N!==null&&rt(Tt,N.startTime-G)}}var w=!1,Q=-1,et=5,zt=-1;function Bt(){return X?!0:!(i.unstable_now()-zt<et)}function vt(){if(X=!1,w){var G=i.unstable_now();zt=G;var N=!0;try{t:{V=!1,I&&(I=!1,gt(Q),Q=-1),W=!0;var ut=k;try{e:{for(Z(G),x=h(H);x!==null&&!(x.expirationTime>G&&Bt());){var St=x.callback;if(typeof St=="function"){x.callback=null,k=x.priorityLevel;var pt=St(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Z(G),N=!0;break e}x===h(H)&&f(H),Z(G)}else f(H);x=h(H)}if(x!==null)N=!0;else{var ge=h(R);ge!==null&&rt(Tt,ge.startTime-G),N=!1}}break t}finally{x=null,k=ut,W=!1}N=void 0}}finally{N?wt():w=!1}}}var wt;if(typeof xt=="function")wt=function(){xt(vt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ot=q.port2;q.port1.onmessage=vt,wt=function(){ot.postMessage(null)}}else wt=function(){ht(vt,0)};function rt(G,N){Q=ht(function(){G(i.unstable_now())},N)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var N=3;break;default:N=k}var ut=k;k=N;try{return G()}finally{k=ut}},i.unstable_requestPaint=function(){X=!0},i.unstable_runWithPriority=function(G,N){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=k;k=G;try{return N()}finally{k=ut}},i.unstable_scheduleCallback=function(G,N,ut){var St=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?St+ut:St):ut=St,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:N,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>St?(G.sortIndex=ut,r(R,G),h(H)===null&&G===h(R)&&(I?(gt(Q),Q=-1):I=!0,rt(Tt,ut-St))):(G.sortIndex=pt,r(H,G),V||W||(V=!0,w||(w=!0,wt()))),G},i.unstable_shouldYield=Bt,i.unstable_wrapCallback=function(G){var N=k;return function(){var ut=k;k=N;try{return G.apply(this,arguments)}finally{k=ut}}}})(so)),so}var kh;function Qy(){return kh||(kh=1,co.exports=Gy()),co.exports}var ro={exports:{}},He={};/** */var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,A){var ut=G.length;G.push(A);t:for(;0<ut;){var bt=ut-1>>>1,pt=G[bt];if(0<y(pt,A))G[bt]=A,G[ut]=pt,ut=bt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var A=G[0],ut=G.pop();if(ut!==A){G[0]=ut;t:for(var bt=0,pt=G.length,Se=pt>>>1;bt<Se;){var we=2*(bt+1)-1,Ne=G[we],g=we+1,U=G[g];if(0>y(Ne,ut))g<pt&&0>y(U,Ne)?(G[bt]=U,G[g]=ut,bt=g):(G[bt]=Ne,G[we]=ut,bt=we);else if(g<pt&&0>y(U,ut))G[bt]=U,G[g]=ut,bt=g;else break t}}return A}function y(G,A){var ut=G.sortIndex-A.sortIndex;return ut!==0?ut:G.id-A.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var M=Date,S=M.now();i.unstable_now=function(){return M.now()-S}}var _=[],O=[],Y=1,x=null,H=3,L=!1,tt=!1,dt=!1,J=!1,gt=typeof setTimeout=="function"?setTimeout:null,_t=typeof clearTimeout=="function"?clearTimeout:null,Dt=typeof setImmediate<"u"?setImmediate:null;function Q(G){for(var A=h(O);A!==null;){if(A.callback===null)f(O);else if(A.startTime<=G)f(O),A.sortIndex=A.expirationTime,r(_,A);else break;A=h(O)}}function ft(G){if(dt=!1,Q(G),!tt)if(h(_)!==null)tt=!0,D||(D=!0,Nt());else{var A=h(O);A!==null&&rt(ft,A.startTime-G)}}var D=!1,X=-1,et=5,xt=-1;function Mt(){return J?!0:!(i.unstable_now()-xt<et)}function mt(){if(J=!1,D){var G=i.unstable_now();xt=G;var A=!0;try{t:{tt=!1,dt&&(dt=!1,_t(X),X=-1),L=!0;var ut=H;try{e:{for(Q(G),x=h(_);x!==null&&!(x.expirationTime>G&&Mt());){var bt=x.callback;if(typeof bt=="function"){x.callback=null,H=x.priorityLevel;var pt=bt(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Q(G),A=!0;break e}x===h(_)&&f(_),Q(G)}else f(_);x=h(_)}if(x!==null)A=!0;else{var Se=h(O);Se!==null&&rt(ft,Se.startTime-G),A=!1}}break t}finally{x=null,H=ut,L=!1}A=void 0}}finally{A?Nt():D=!1}}}var Nt;if(typeof Dt=="function")Nt=function(){Dt(mt)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,nt=B.port2;B.port1.onmessage=mt,Nt=function(){nt.postMessage(null)}}else Nt=function(){gt(mt,0)};function rt(G,A){X=gt(function(){G(i.unstable_now())},A)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var A=3;break;default:A=H}var ut=H;H=A;try{return G()}finally{H=ut}},i.unstable_requestPaint=function(){J=!0},i.unstable_runWithPriority=function(G,A){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=H;H=G;try{return A()}finally{H=ut}},i.unstable_scheduleCallback=function(G,A,ut){var bt=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?bt+ut:bt):ut=bt,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:A,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>bt?(G.sortIndex=ut,r(O,G),h(_)===null&&G===h(O)&&(dt?(_t(X),X=-1):dt=!0,rt(ft,ut-bt))):(G.sortIndex=pt,r(_,G),tt||L||(tt=!0,D||(D=!0,Nt()))),G},i.unstable_shouldYield=Mt,i.unstable_wrapCallback=function(G){var A=H;return function(){var ut=H;H=A;try{return G.apply(this,arguments)}finally{H=ut}}}})(oo)),oo}var Bh;function Qy(){return Bh||(Bh=1,ro.exports=Gy()),ro.exports}var fo={exports:{}},Ue={};/**
* @license React * @license React
* react-dom.production.js * react-dom.production.js
* *
@@ -36,7 +36,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Bh;function Xy(){if(Bh)return He;Bh=1;var i=yo();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var k=2;k<arguments.length;k++)x+="&args[]="+encodeURIComponent(arguments[k])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,k){var W=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:W==null?null:W===A?A:""+W,children:Y,containerInfo:x,implementation:k}}var H=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return He.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,He.browser=function(Y){return{$$typeof:C,_reason:Y}},He.createPortal=function(Y,x){var k=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,k)},He.flushSync=function(Y){var x=H.T,k=f.p;try{if(H.T=null,f.p=2,Y)return Y()}finally{H.T=x,f.p=k,f.d.f()}},He.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},He.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},He.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin),V=typeof x.integrity=="string"?x.integrity:void 0,I=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;k==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:W,integrity:V,fetchPriority:I}):k==="script"&&f.d.X(Y,{crossOrigin:W,integrity:V,fetchPriority:I,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},He.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var k=R(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},He.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin);f.d.L(Y,k,{crossOrigin:W,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},He.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var k=R(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},He.requestFormReset=function(Y){f.d.r(Y)},He.unstable_batchedUpdates=function(Y,x){return Y(x)},He.useFormState=function(Y,x,k){return H.H.useFormState(Y,x,k)},He.useFormStatus=function(){return H.H.useHostTransitionStatus()},He.version="19.3.1",He}var qh;function Ly(){if(qh)return ro.exports;qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),ro.exports=Xy(),ro.exports}/** */var qh;function Xy(){if(qh)return Ue;qh=1;var i=go();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)x+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function S(Y,x,H){var L=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:L==null?null:L===M?M:""+L,children:Y,containerInfo:x,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function O(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(Y){return{$$typeof:C,_reason:Y}},Ue.createPortal=function(Y,x){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,H)},Ue.flushSync=function(Y){var x=_.T,H=f.p;try{if(_.T=null,f.p=2,Y)return Y()}finally{_.T=x,f.p=H,f.d.f()}},Ue.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},Ue.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},Ue.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin),tt=typeof x.integrity=="string"?x.integrity:void 0,dt=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;H==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:L,integrity:tt,fetchPriority:dt}):H==="script"&&f.d.X(Y,{crossOrigin:L,integrity:tt,fetchPriority:dt,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},Ue.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var H=O(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},Ue.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin);f.d.L(Y,H,{crossOrigin:L,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},Ue.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var H=O(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},Ue.requestFormReset=function(Y){f.d.r(Y)},Ue.unstable_batchedUpdates=function(Y,x){return Y(x)},Ue.useFormState=function(Y,x,H){return _.H.useFormState(Y,x,H)},Ue.useFormStatus=function(){return _.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Yh;function Ly(){if(Yh)return fo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),fo.exports=Xy(),fo.exports}/**
* @license React * @license React
* react-dom-client.production.js * react-dom-client.production.js
* *
@@ -44,38 +44,38 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Yh;function Vy(){if(Yh)return ii;Yh=1;var i=Qy(),r=yo(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function H(t){if(C(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return H(n),t;if(u===a)return H(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function k(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function W(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function V(t){var e=[null,null],l=k(t);return l===null||I(e,t,l.child,{foundSelf:!1}),e}function I(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&I(t,e,l.child,a))return!0;l=l.sibling}return!1}function X(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var ht=null,gt=null;function xt(t,e,l){return t===l?!0:t===e?(ht=t,!0):!1}function Z(t,e,l){return t===l?(gt=t,!1):t===e?(gt!==null&&(ht=t),!0):!1}function Tt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function w(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var Q=Object.assign,et=Symbol.for("react.element"),zt=Symbol.for("react.transitional.element"),Bt=Symbol.for("react.portal"),vt=Symbol.for("react.fragment"),wt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),N=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),St=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ge=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ce=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Nt=Symbol.for("react.client.reference");function _t(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Nt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case vt:return"Fragment";case q:return"Profiler";case wt:return"StrictMode";case N:return"Suspense";case ut:return"SuspenseList";case ge:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Bt:return"Portal";case rt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case St:return e=t.displayName||null,e!==null?e:_t(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return _t(t(e))}catch{}}return null}var Rt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,dt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ce={pending:!1,data:null,method:null,action:nLine truncated */var Gh;function Vy(){if(Gh)return ri;Gh=1;var i=Qy(),r=go(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function M(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(C(t)!==t)throw Error(f(188))}function O(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function L(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function tt(t){var e=[null,null],l=H(t);return l===null||dt(e,t,l.child,{foundSelf:!1}),e}function dt(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&dt(t,e,l.child,a))return!0;l=l.sibling}return!1}function J(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var gt=null,_t=null;function Dt(t,e,l){return t===l?!0:t===e?(gt=t,!0):!1}function Q(t,e,l){return t===l?(_t=t,!1):t===e?(_t!==null&&(gt=t),!0):!1}function ft(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function D(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var X=Object.assign,et=Symbol.for("react.element"),xt=Symbol.for("react.transitional.element"),Mt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Nt=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),nt=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),A=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),bt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),U=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Ot=Symbol.for("react.client.reference");function Et(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ot?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case B:return"Profiler";case Nt:return"StrictMode";case A:return"Suspense";case ut:return"SuspenseList";case Se:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Mt:return"Portal";case rt:return t.displayName||"Context";case nt:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case bt:return e=t.displayName||null,e!==null?e:Et(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Et(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,se={pending:!1,data:null,method:null,actiLine truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return` at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Be+t+se}var rl=!1;function Fl(t,e){if(!t||rl)return"";rl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(L){var p=L}Reflect.construct(t,[],z)}else{try{z.call()}catch(L){p=L}z=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new t}finally{z&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(L){p=L}(z=t())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(L){if(L&&p&&typeof L.stack=="string")return[L.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(` `+He+t+re}var fl=!1;function $l(t,e){if(!t||fl)return"";fl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var R=function(){throw Error()};if(Object.defineProperty(R.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(R,[])}catch(V){var p=V}Reflect.construct(t,[],R)}else{try{R.call()}catch(V){p=V}R=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),R=!0,new t}finally{R&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(V){p=V}(R=t())&&typeof R.catch=="function"&&R.catch(function(){})}}catch(V){if(V&&p&&typeof V.stack=="string")return[V.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(` `),j=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var E=` `);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var N=`
`+m[a].replace(" at new "," at ");return t.displayName&&E.includes("<anonymous>")&&(E=E.replace("<anonymous>",t.displayName)),E}while(1<=a&&0<=n);break}}}finally{rl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?re(l):""}function ma(t,e){switch(t.tag){case 26:case 27:case 5:return re(t.type);case 16:return re("Lazy");case 13:return t.child!==e&&e!==null?re("Suspense Fallback"):re("Suspense");case 19:return re("SuspenseList");case 0:case 15:return Fl(t.type,!1);case 11:return Fl(t.type.render,!1);case 1:return Fl(t.type,!0);case 31:return re("Activity");case 30:return re("ViewTransition");default:return""}}function Dl(t){try{var e="",l=null;do e+=ma(t,l),l=t,t=t.return;while(t);return e}catch(a){return` `+m[a].replace(" at new "," at ");return t.displayName&&N.includes("<anonymous>")&&(N=N.replace("<anonymous>",t.displayName)),N}while(1<=a&&0<=n);break}}}finally{fl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?oe(l):""}function ya(t,e){switch(t.tag){case 26:case 27:case 5:return oe(t.type);case 16:return oe("Lazy");case 13:return t.child!==e&&e!==null?oe("Suspense Fallback"):oe("Suspense");case 19:return oe("SuspenseList");case 0:case 15:return $l(t.type,!1);case 11:return $l(t.type.render,!1);case 1:return $l(t.type,!0);case 31:return oe("Activity");case 30:return oe("ViewTransition");default:return""}}function kl(t){try{var e="",l=null;do e+=ya(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+` Error generating stack: `+a.message+`
`+a.stack}}var te=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,Wt=i.unstable_now,Ul=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Qa=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Xa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Ae=null;function fl(t){if(typeof su=="function"&&ru(t),Ae&&typeof Ae.setStrictMode=="function")try{Ae.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,U=262144,$=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function O(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function J(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function nt(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function qt(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Dt(){var t=$;return $<<=1,($&62914560)===0&&($=4194304),t}function xe(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function be(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dc(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),z=1<<E;o[E]=0,m[E]=-1;var p=j[E];if(p!==null)for(j[E]=null,E=0;E<p.length;E++){var _=p[E];_!==null&&(_.lane&=-536870913)}l&=~z}a!==0&&ci(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ci(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:Hl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Hl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=dt.p;return t!==0?t:(t=window.event,t===void 0?32:Eh(t.type))}function Ze(t,e){var l=dt.p;try{return dt.p=t,e()}finally{dt.p=l}}var ae=Math.random().toString(36).slice(2),ve="__reactFiber$"+ae,Ke="__reactProps$"+ae,vn="__reactContainer$"+ae,So="__reactEvents$"+ae,Ph="__reactListeners$"+ae,tv="__reactHandles$"+ae,xo="__reactResources$"+ae,vu="__reactMarker$"+ae,si="__reactLoad$"+ae;function ri(t){delete t[ve],delete t[Ke],delete t[Ph],delete t[tv]}function La(t){var e;if(e=t[ve])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ve]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=uh(t);t!==null;){if(l=t[ve])return l;t=uh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ve]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[xo];return e||(e=t[xo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[vu]=!0}function bo(t){t[si]=void 0}var jo=new Set,To={};function Va(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated `+a.stack}}var te=Object.prototype.hasOwnProperty,tl=i.unstable_scheduleCallback,Be=i.unstable_cancelCallback,Nl=i.unstable_shouldYield,dl=i.unstable_requestPaint,$t=i.unstable_now,Hl=i.unstable_getCurrentPriorityLevel,vn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Il=i.unstable_LowPriority,ga=i.unstable_IdlePriority,fu=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function ml(t){if(typeof fu=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var q=256,k=262144,I=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function qt(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function hl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function w(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function Z(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function K(){var t=I;return I<<=1,(I&62914560)===0&&(I=4194304),t}function Yt(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function St(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qe(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var N=31-Ae(l),R=1<<N;o[N]=0,m[N]=-1;var p=j[N];if(p!==null)for(j[N]=null,N=0;N<p.length;N++){var E=p[N];E!==null&&(E.lane&=-536870913)}l&=~R}a!==0&&Cl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function Cl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function yn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function ta(t,e){var l=e&-e;return l=(l&42)!==0?1:Bl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Bl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function yu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function gu(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function Ke(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var ne=Math.random().toString(36).slice(2),ye="__reactFiber$"+ne,Je="__reactProps$"+ne,gn="__reactContainer$"+ne,xo="__reactEvents$"+ne,Ph="__reactListeners$"+ne,tv="__reactHandles$"+ne,bo="__reactResources$"+ne,pu="__reactMarker$"+ne,fi="__reactLoad$"+ne;function di(t){delete t[ye],delete t[Je],delete t[Ph],delete t[tv]}function Za(t){var e;if(e=t[ye])return e;for(var l=t.parentNode;l;){if(e=l[gn]||l[ye]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[ye])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function pn(t){if(t=t[ye]||t[gn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function Su(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Sn(t){var e=t[bo];return e||(e=t[bo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[pu]=!0}function jo(t){t[fi]=void 0}var To=new Set,_o={};function Ka(t,e){xn(t,e),xn(t+"Capture",e)}function xn(t,eLine truncated
`).replace(G0,"")}function qm(t,e){return e=Bm(e),Bm(t)===e}function Vt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":fi(t,"class",a);break;case"tabIndex":fi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":fi(t,l,a);break;case"style":Do(t,a,u);return;case"data":if(e!=="object"){fi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Vt(t,e,"name",n.name,n,null),Vt(t,e,"formEncType",n.formEncType,n,null),Vt(t,e,"formMethod",n.formMethod,n,null),Vt(t,e,"formTarget",n.formTarget,n,null)):(Vt(t,e,"encType",n.encType,n,null),Vt(t,e,"method",n.method,n,null),Vt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=wl);return;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=di(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":At("beforetoggle",t),At("toggle",t),oi(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":oi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,oi(t,l,a);else return}kt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Do(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scroLine truncated `).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||jn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&jn(t,""+a);else return;break;case"className":hi(t,"class",a);break;case"tabIndex":hi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":hi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){hi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=ql);return;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=vi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Rt("beforetoggle",t),Rt("toggle",t),mi(t,"popover",a);break;case"xlinkActuate":ea(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ea(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ea(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ea(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ea(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ea(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ea(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ea(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ea(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":mi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,mi(t,l,a);else return}Bt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")jn(t,a);else if(typeof a=="number"||typeof a=="bigint")jn(t,""+a);else return;break;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scroLine truncated
`;qt&&(xe+=qt+` `;Yt&&(qe+=Yt+`
`),Dt&&(xe+=(Array.isArray(Dt)?Dt.join(` `),St&&(qe+=(Array.isArray(St)?St.join(`
`):String(Dt))+` `):String(St))+`
`),xe+=JSON.stringify(nt,null,2).slice(0,4e3),jl(xe),ce("已返回登录信息,见下方")}catch(nt){ce("发起失败:"+nt.message)}},[]),U=d.useCallback(async()=>{const O=V.trim();if(!O){W("请填要查配额的用户 id");return}try{const J=await tt(K.adminQuotaUser(O));W("用户 "+O.slice(0,8)+":本月用量 "+fo(J.used_usd_micros)+" / 配额 "+fo(J.quota_usd_micros)+(J.remaining_usd_micros!==null&&J.remaining_usd_micros!==void 0?"(剩余 "+fo(J.remaining_usd_micros)+")":"")+" | 月份 "+(J.month||""))}catch(J){W(oo(J))}},[V]),$=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const J=(await tt(K.adminSweep,"POST",{})).swept||[];W("sweep 完成,清理 "+J.length+" 个任务"+(J.length?":"+J.join("、"):""))}catch(O){W(oo(O))}},[]),at=d.useCallback(async()=>{try{const O=await tt(K.adminBackup,"POST",{});W("备份完成:backend="+O.backend+" | 路径 "+O.path+" | 大小 "+O.size_bytes+" 字节 | 任务 "+O.task_count+" 行")}catch(O){W(oo(O))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:A,adminGate:H,adminBody:Y,adminResult:k,adminUserId:V,setAdminUserId:I,projects:X,projectId:gt,setProjectId:xt,projectMsg:Z,githubRepos:w,workers:et,workersSummary:Bt,workersError:wt,members:ot,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:y,skillMsg:ct,skillError:Nt,mcpMsg:Rt,mcpOauthMsg:dt,mcpOauthBox:Ga,envs:Le,envCount:Gt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:se,setEnvSelect:re,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:Wt,loadGithubRepos:Ul,loadRelayModels:mn,refreshWorkers:Qa,refreshEnvs:Se,refreshMembers:Xa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Ae,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:U,adminSweep:$,adminBackup:at}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,C]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(0),[k,W]=d.useState(0),[V,I]=d.useState({}),X=d.useRef(0),ht=d.useRef({status:"",executor:"",q:""});ht.current={status:g,executor:A,q:H};const gt=d.useRef(""),xt=d.useCallback(async(ot,rt,G)=>{const N=ot!==void 0?ot:rt;if(!N){r([]),W(0),I({});return}gt.current=N;const ut=G!==void 0?G:X.current,St=ht.current;try{const pt=await tt(K.tasksPage(N,mo,ut*mo,St.status,St.executor,St.q));r(pt.tasks||[]),W(pt.total??(pt.tasks||[]).length),I(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Z=d.useCallback(ot=>{const rt=Math.max(0,ot);X.current=rt,x(rt)},[]),Tt=d.useCallback(ot=>{Z(ot),xt(gt.current||void 0,void 0,ot)},[xt,Z]);d.useEffect(()=>{if(!gt.current)return;const ot=setTimeout(()=>{xt(gt.current,void 0,0)},300);return()=>clearTimeout(ot)},[g,A,H]);const w=d.useCallback(ot=>{C(ot),Z(0)},[Z]),Q=d.useCallback(ot=>{S(ot),Z(0)},[Z]),et=d.useCallback(ot=>{R(ot),Z(0)},[Z]),zt=d.useCallback(async ot=>{Z(0),await xt(ot,void 0,0)},[xt,Z]),Bt=Math.max(1,Math.ceil(k/mo)),vt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(Y+1)+" / "+Bt+" 页",wt=i,q=d.useCallback(async(ot,rt)=>{const G={...ot};rt.length&&(G.upload_id=await tg(rt));const N=await tt(K.tasksCreate,"POST",G);return"任务已建:"+Ne(N.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:w,executorFilter:A,setExecutorFilter:Q,searchText:H,setSearchText:et,filteredTasks:wt,taskCountText:vt,refreshTasks:xt,refreshTasksFirstPage:zt,page:Y,totalPages:Bt,taskTotal:k,taskStatusCounts:V,goToPage:Tt,dispatch:q}}function ng(i,r){const[h,f]=d.useState(null),[g,C]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(""),[k,W]=d.useState(void 0),[V,I]=d.useState(!1),[X,ht]=d.useState(""),[gt,xt]=d.useState([]),[Z,Tt]=d.useState([]),[w,Q]=d.useState(""),[et,zt]=d.useState([]),[Bt,vt]=d.useState(""),[wt,q]=d.useState(""),[ot,rt]=d.useState([]),[G,N]=d.useState(""),[ut,St]=d.useState(""),[pt,ge]=d.useState(""),[we,Ce]=d.useState(""),[y,D]=d.useState([]),[ct,it]=d.useState([]),[Nt,_t]=d.useState(""),[Rt,P]=d.useState([]),[dt,ce]=d.useState(""),[Ga,jl]=d.useState(""),[Le,le]=d.useState(""),[Gt,he]=d.useState(""),[Jl,pe]=d.useState([]),[da,Ve]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),se=d.useRef(null);se.current=h;const re=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const U=await tt(K.taskAgents(B));Tt(U.agents||[]),Q(U.note||"")}catch(U){Tt([]),Q("子代理链路加载失败:"+U.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){xt([]);return}try{const U=await tt(K.tasksByParent(B.project_id||"",B.id));xt(U.tasks||[])}catch{xt([])}},[]),Dl=d.useCallback(async B=>{try{Line truncated `),qe+=JSON.stringify(K,null,2).slice(0,4e3),_l(qe),se("已返回登录信息,见下方")}catch(K){se("发起失败:"+K.message)}},[]),I=d.useCallback(async()=>{const w=tt.trim();if(!w){L("请填要查配额的用户 id");return}try{const Z=await W(F.adminQuotaUser(w));L("用户 "+w.slice(0,8)+":本月用量 "+iu(Z.used_usd_micros)+" / 配额 "+iu(Z.quota_usd_micros)+(Z.remaining_usd_micros!==null&&Z.remaining_usd_micros!==void 0?"(剩余 "+iu(Z.remaining_usd_micros)+")":"")+" | 月份 "+(Z.month||""))}catch(Z){L(Dc(Z))}},[tt]),at=d.useCallback(async(w,Z)=>{const K=w.trim();if(!K){L("请填要设置配额的用户 id");return}let Yt;if(!Z.trim())Yt=null;else{const St=Number(Z.trim());if(!Number.isFinite(St)||St<0){L("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Yt=Math.round(St*1e6)}try{const St=await W(F.adminQuotas,"POST",{user_id:K,quota_usd_micros:Yt});L("配额已设置:用户 "+K.slice(0,8)+":本月用量 "+iu(St.used_usd_micros)+" / 配额 "+iu(St.quota_usd_micros)+(St.remaining_usd_micros!==null&&St.remaining_usd_micros!==void 0?"(剩余 "+iu(St.remaining_usd_micros)+")":"")+" | 月份 "+(St.month||""))}catch(St){L(Dc(St))}},[]),qt=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const Z=(await W(F.adminSweep,"POST",{})).swept||[];L("sweep 完成,清理 "+Z.length+" 个任务"+(Z.length?":"+Z.join("、"):""))}catch(w){L(Dc(w))}},[]),hl=d.useCallback(async()=>{try{const w=await W(F.adminBackup,"POST",{});L("备份完成:backend="+w.backend+" | 路径 "+w.path+" | 大小 "+w.size_bytes+" 字节 | 任务 "+w.task_count+" 行")}catch(w){L(Dc(w))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Y,adminResult:H,adminUserId:tt,setAdminUserId:dt,projects:J,projectId:_t,setProjectId:Dt,projectMsg:Q,githubRepos:D,workers:et,workersSummary:Mt,workersError:Nt,members:nt,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:g,skillMsg:ct,skillError:Ot,mcpMsg:kt,mcpOauthMsg:ht,mcpOauthBox:La,envs:Ve,envCount:Qt,envMsg:Wl,envRepoLine:va,envRepoBar:Ul,envRepoMsg:El,envSelect:re,setEnvSelect:oe,models:fl,modelCustomVisible:ya,refreshHealth:Be,loadQuota:Nl,probeAdmin:dl,refreshProjects:$t,loadGithubRepos:Hl,loadRelayModels:vn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Il,refreshSkills:ga,saveKeyInput:fu,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:ml,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:q,mcpOauth:k,adminQuota:I,adminSetQuota:at,adminSweep:qt,adminBackup:hl}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(0),[H,L]=d.useState(0),[tt,dt]=d.useState({}),J=d.useRef(0),gt=d.useRef({status:"",executor:"",q:""});gt.current={status:y,executor:M,q:_};const _t=d.useRef(""),Dt=d.useCallback(async(nt,rt,G)=>{const A=nt!==void 0?nt:rt;if(!A){r([]),L(0),dt({});return}_t.current=A;const ut=G!==void 0?G:J.current,bt=gt.current;try{const pt=await W(F.tasksPage(A,mo,ut*mo,bt.status,bt.executor,bt.q));r(pt.tasks||[]),L(pt.total??(pt.tasks||[]).length),dt(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Q=d.useCallback(nt=>{const rt=Math.max(0,nt);J.current=rt,x(rt)},[]),ft=d.useCallback(nt=>{Q(nt),Dt(_t.current||void 0,void 0,nt)},[Dt,Q]);d.useEffect(()=>{if(!_t.current)return;const nt=setTimeout(()=>{Dt(_t.current,void 0,0)},300);return()=>clearTimeout(nt)},[y,M,_]);const D=d.useCallback(nt=>{C(nt),Q(0)},[Q]),X=d.useCallback(nt=>{S(nt),Q(0)},[Q]),et=d.useCallback(nt=>{O(nt),Q(0)},[Q]),xt=d.useCallback(async nt=>{Q(0),await Dt(nt,void 0,0)},[Dt,Q]),Mt=Math.max(1,Math.ceil(H/mo)),mt=H===0?"共 0 个任务":"共 "+H+" 个任务,第 "+(Y+1)+" / "+Mt+" 页",Nt=i,B=d.useCallback(async(nt,rt)=>{const G={...nt};rt.length&&(G.upload_id=await tg(rt));const A=await W(F.tasksCreate,"POST",G);return"任务已建:"+pe(A.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:D,executorFilter:M,setExecutorFilter:X,searchText:_,setSearchText:et,filteredTasks:Nt,taskCountText:mt,refreshTasks:Dt,refreshTasksFirstPage:xt,page:Y,totalPages:Mt,taskTotal:H,taskStatusCounts:tt,goToPage:ft,dispatch:B}}function ng(i,r){const[h,f]=d.useState(null),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(""),[H,L]=d.useState(void 0),[tt,dt]=d.useState(!1),[J,gt]=d.useState(""),[_t,Dt]=d.useState([]),[Q,ft]=d.useState([]),[D,X]=d.useState(""),[et,xt]=d.useState([]),[Mt,mt]=d.useState(""),[Nt,B]=d.useState(""),[nt,rt]=d.useState([]),[G,A]=d.useState(""),[ut,bt]=d.useState(""),[pt,Se]=d.useState(""),[we,Ne]=d.useState(""),[g,U]=d.useState([]),[ct,it]=d.useState([]),[Ot,Et]=d.useState(""),[kt,P]=d.useState([]),[ht,se]=d.useState(""),[La,_l]=d.useState(""),[Ve,ae]=d.useState(""),[Qt,ve]=d.useState(""),[Wl,xe]=d.useState([]),[va,Ze]=d.useState(""),Line truncated
`);hn=Hl.pop()||"";for(const mu of Hl){const hu=mu.split(` `);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
`).filter(Ze=>Ze.indexOf("data: ")===0).map(Ze=>Ze.slice(6)).join(` `).filter(Ke=>Ke.indexOf("data: ")===0).map(Ke=>Ke.slice(6)).join(`
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")at=Ze.entries||[],Object.keys(O).forEach(ae=>delete O[ae]),at.forEach((ae,ve)=>{ae.id&&(O[ae.id]=ve)}),J=Ze.note||"",rt([...at]),N(J),Fl(B);else if(Ze.type==="entry"){const ae=Ze.entry;ae.id&&ae.id in O?at[O[ae.id]]=ae:(ae.id&&(O[ae.id]=at.length),at.push(ae)),rt([...at]),Fl(B)}else Ze.type==="done"&&(nt=!0,qt=Ze.status,St(qt))}catch{}}if(nt)break}}catch(Dt){if(Dt&&Dt.name==="AbortError")return;N("链路流中断:"+Dt.message);return}nt&&r()},[re,Fl,r]),Se=d.useCallback(async B=>{re();try{const U=await tt(K.task(B));f(U);let $="";U.result_text&&($+=`=== result === `);if(gu)try{const Ke=JSON.parse(gu);if(Ke.type==="snapshot")at=Ke.entries||[],Object.keys(qt).forEach(ne=>delete qt[ne]),at.forEach((ne,ye)=>{ne.id&&(qt[ne.id]=ye)}),hl=Ke.note||"",rt([...at]),A(hl),$l(q);else if(Ke.type==="entry"){const ne=Ke.entry;ne.id&&ne.id in qt?at[qt[ne.id]]=ne:(ne.id&&(qt[ne.id]=at.length),at.push(ne)),rt([...at]),$l(q)}else Ke.type==="done"&&(w=!0,Z=Ke.status,bt(Z))}catch{}}if(w)break}}catch(K){if(K&&K.name==="AbortError")return;A("链路流中断:"+K.message);return}w&&r()},[oe,$l,r]),be=d.useCallback(async q=>{oe();try{const k=await W(F.task(q));f(k);let I="";k.result_text&&(I+=`=== result ===
`+U.result_text.slice(0,8e3)+` `+k.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error === `),k.error_text&&(I+=`=== error ===
`+U.error_text.slice(0,8e3)+` `+k.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),C($),x(""),W(void 0),Dl(B),I(!1),ht(""),ce(""),he(""),mn(U.status);const at=i(U.project_id),O=at==="maintainer"||at==="owner",J=at==="contributor"||O,nt=uu.includes(U.status),qt=[];["failed","interrupted","merge-failed"].includes(U.status)&&O||qt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(O?"":",且仅 maintainer 可重试")),!nt&&J||qt.push("取消:不可用——"+(nt?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&O||qt.push("合并:不可用——仅 completed 可合并"+(O?"":",且仅 maintainer 可合并")),U.status==="merged"&&O||qt.push("提 PR:不可用——仅 merged 可提 PR"+(O?"":",且仅 maintainer 可提 PR")),R(qt.join(";")),S(""),Qa(B,U.status),rl(B),ma(U),Pe(B),te(B),ol(B),Wt(B),Ul(B),qe(B),_l(B)}catch(U){f(null),C("加载失败:"+U.message)}},[re,Qa,rl,ma,Pe,te,ol,Wt,Ul,qe,_l,Dl,i]),Xa=d.useCallback(async()=>{const B=se.current;if(B){try{const U=await tt(K.task(B.id));f(U);let $="";U.result_text&&($+=`=== result === `),I||(I="(暂无 result/error 输出)"),C(I),x(""),L(void 0),kl(q),dt(!1),gt(""),se(""),ve(""),vn(k.status);const at=i(k.project_id),qt=at==="maintainer"||at==="owner",hl=at==="contributor"||qt,w=su.includes(k.status),Z=[];["failed","interrupted","merge-failed"].includes(k.status)&&qt||Z.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(qt?"":",且仅 maintainer 可重试")),!w&&hl||Z.push("取消:不可用——"+(w?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),k.status==="completed"&&qt||Z.push("合并:不可用——仅 completed 可合并"+(qt?"":",且仅 maintainer 可合并")),k.status==="merged"&&qt||Z.push("提 PR:不可用——仅 merged 可提 PR"+(qt?"":",且仅 maintainer 可提 PR")),O(Z.join(";")),S(""),Va(q,k.status),fl(q),ya(k),tl(q),te(q),dl(q),$t(q),Hl(q),Be(q),Nl(q)}catch(k){f(null),C("加载失败:"+k.message)}},[oe,Va,fl,ya,tl,te,dl,$t,Hl,Be,Nl,kl,i]),Il=d.useCallback(async()=>{const q=re.current;if(q){try{const k=await W(F.task(q.id));f(k);let I="";k.result_text&&(I+=`=== result ===
`+U.result_text.slice(0,8e3)+` `+k.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error === `),k.error_text&&(I+=`=== error ===
`+U.error_text.slice(0,8e3)+` `+k.error_text.slice(0,8e3)+`
`),C($||"(暂无 result/error 输出)")}catch{}te(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),Wt(B.id),Ul(B.id),Dl(B.id)}},[te,Pe,qe,_l,ma,rl,ol,Wt,Ul,Dl]),ha=d.useCallback(async B=>{const U=se.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const at=await tt(K.taskAction(U.id,B),"POST",{});S(B+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await Se(U.id)}catch(at){S(B+" 失败:"+at.message)}},[Se,r]),su=d.useCallback(async()=>{const B=se.current;if(B)try{const U=await tt(K.taskCost(B.id));x(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){x("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=se.current;if(U){if(!B.trim()){ht("分支消息必填");return}if(!uu.includes(U.status)){ht("仅终态任务可分支");return}try{const $=await tt(K.taskFork(U.id),"POST",{message:B.trim()});ht("分支任务已建:"+$.id.slice(0,8)),r()}catch($){ht("分支失败:"+$.message)}}},[r]),Wl=d.useCallback(async B=>{const U=se.current;if(U)try{await tt(K.taskReviews(U.id),"POST",B),vt("评审已登记(requested)"),await qe(U.id)}catch($){vt("登记评审需求失败:"+$.message)}},[qe]),Ae=d.useCallback(B=>{const U=se.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&tt(K.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void qe(U.id)).catch(at=>vt("写 findings 失败:"+at.message))},[qe]),fl=d.useCallback((B,U)=>{const $=se.current;if(!$)return;const at=U==="approved"?"approve":"deny";tt(K.taskApprovalDecision($.id,B,at),"POST",{}).then(()=>{_t("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l($.id)}).catch(O=>_t("决定失败:"+O.message))},[_l]),Oe=d.useCallback(async(B,U,$)=>{const at=se.current;if(at){if(!U.trim()){ce("目标任务 id 不能为空");return}if(!$.trim()){ce("消息正文不能为空");return}try{const O=await tt(K.taskMessagesTo(U.trim()),"POST",{from_task_id:at.id,body:$.trim(),mode:B});ce("已发送("+O.delivery+")"),await ol(at.id)}catch(O){ce("发送失败:"+O.message)}}},[ol]),ou=d.useCallback((B,U)=>{const $=se.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(B==="rename"){if(!U.trim()){he("新线程名不能为空");return}at.name=U.trim()}tt(K.taskThreadAction($.id,B),"POST",at).then(O=>{he(B+" 已提交:"+JSON.stringify(O).slice(0,300)),Wt($.id),Ul($.id)}).catch(O=>he(B+" 失败:"+O.message))},[Wt,Ul]),fu=d.useCallback(async B=>{const U=se.current;if(!U)return;const $=B.trim();if(!$){Ve("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await tt(K.taskContinue(U.id),"POST",{message:$}),Ve("已重新排队,同一会话继续"),r(),await Se(U.id)):["queued","running"].includes(U.status)?(await tt(K.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Ve("已直接发送"),await te(U.id),await Pe(U.id)):Ve("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ve("发送失败:"+at.message)}},[Se,r,te,Pe]),du=d.useCallback(async()=>{const B=se.current;if(!B){Ce("先选一个任务");return}try{const U=await tt(K.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL($),at.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ce("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){Ce("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>re(),[re]),{currentTask:h,detailBody:g,actionMsg:A,actionReasons:H,costBox:Y,costSummary:k,forkVisible:V,setForkVisible:I,forkMsg:X,coordChildren:gt,agents:Z,agentsNote:w,reviews:et,reviewMsg:Bt,reviewError:wt,traceEntries:ot,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:ge,traceMsg:we,followups:y,approvals:ct,approvalMsg:Nt,peerMessages:Rt,peerMsg:dt,threadText:Ga,threadGateNote:Le,threadMsg:Gt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:Se,refreshDetailLists:Xa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Ae,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function ug(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[C,A]=d.useState(null),[S,H]=d.useState(""),R=d.useCallback(async V=>{const I=V!==void 0?V:i();if(!I){h([]);return}try{const X=await tt(K.projectAutomations(I));h(X.automations||[])}catch(X){g("自动化加载失败:"+X.message)}}Line truncated `),C(I||"(暂无 result/error 输出)")}catch{}te(q.id),tl(q.id),Be(q.id),Nl(q.id),ya(q),fl(q.id),dl(q.id),$t(q.id),Hl(q.id),kl(q.id)}},[te,tl,Be,Nl,ya,fl,dl,$t,Hl,kl]),ga=d.useCallback(async q=>{const k=re.current;if(!k)return;const I={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(I[q]&&!window.confirm(I[q])))try{const at=await W(F.taskAction(k.id,q),"POST",{});S(q+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await be(k.id)}catch(at){S(q+" 失败:"+at.message)}},[be,r]),fu=d.useCallback(async()=>{const q=re.current;if(q)try{const k=await W(F.taskCost(q.id));x(JSON.stringify(k,null,2).slice(0,4e3))}catch(k){x("查成本失败:"+k.message)}},[]),du=d.useCallback(async q=>{const k=re.current;if(k){if(!q.trim()){gt("分支消息必填");return}if(!su.includes(k.status)){gt("仅终态任务可分支");return}try{const I=await W(F.taskFork(k.id),"POST",{message:q.trim()});gt("分支任务已建:"+I.id.slice(0,8)),r()}catch(I){gt("分支失败:"+I.message)}}},[r]),Pl=d.useCallback(async q=>{const k=re.current;if(k)try{await W(F.taskReviews(k.id),"POST",q),mt("评审已登记(requested)"),await Be(k.id)}catch(I){mt("登记评审需求失败:"+I.message)}},[Be]),Ce=d.useCallback(q=>{const k=re.current;if(!k)return;const I=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");I!==null&&W(F.taskReviewFindings(k.id,q),"POST",{findings:I}).then(()=>void Be(k.id)).catch(at=>mt("写 findings 失败:"+at.message))},[Be]),ml=d.useCallback((q,k)=>{const I=re.current;if(!I)return;const at=k==="approved"?"approve":"deny";W(F.taskApprovalDecision(I.id,q,at),"POST",{}).then(()=>{Et("已记录"+(k==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Nl(I.id)}).catch(qt=>Et("决定失败:"+qt.message))},[Nl]),Ae=d.useCallback(async(q,k,I)=>{const at=re.current;if(at){if(!k.trim()){se("目标任务 id 不能为空");return}if(!I.trim()){se("消息正文不能为空");return}try{const qt=await W(F.taskMessagesTo(k.trim()),"POST",{from_task_id:at.id,body:I.trim(),mode:q});se("已发送("+qt.delivery+")"),await dl(at.id)}catch(qt){se("发送失败:"+qt.message)}}},[dl]),mu=d.useCallback((q,k)=>{const I=re.current;if(!I)return;if(!["completed","failed","interrupted"].includes(I.status)){ve("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(q==="rename"){if(!k.trim()){ve("新线程名不能为空");return}at.name=k.trim()}W(F.taskThreadAction(I.id,q),"POST",at).then(qt=>{ve(q+" 已提交:"+JSON.stringify(qt).slice(0,300)),$t(I.id),Hl(I.id)}).catch(qt=>ve(q+" 失败:"+qt.message))},[$t,Hl]),hu=d.useCallback(async q=>{const k=re.current;if(!k)return;const I=q.trim();if(!I){Ze("消息不能为空");return}try{su.includes(k.status)&&k.status!=="merged"?(await W(F.taskContinue(k.id),"POST",{message:I}),Ze("已重新排队,同一会话继续"),r(),await be(k.id)):["queued","running"].includes(k.status)?(await W(F.taskFollowups(k.id),"POST",{body:I,mode:"direct"}),Ze("已直接发送"),await te(k.id),await tl(k.id)):Ze("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ze("发送失败:"+at.message)}},[be,r,te,tl]),vu=d.useCallback(async()=>{const q=re.current;if(!q){Ne("先选一个任务");return}try{const k=await W(F.taskTrace(q.id)),I=new Blob([JSON.stringify({task_id:q.id,status:k.status,note:k.note||"",count:(k.entries||[]).length,entries:k.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL(I),at.download="trace-"+q.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ne("已下载完整链路 "+(k.entries||[]).length+" 条")}catch(k){Ne("下载失败:"+k.message)}},[]);return d.useEffect(()=>()=>oe(),[oe]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Y,costSummary:H,forkVisible:tt,setForkVisible:dt,forkMsg:J,coordChildren:_t,agents:Q,agentsNote:D,reviews:et,reviewMsg:Mt,reviewError:Nt,traceEntries:nt,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:Se,traceMsg:we,followups:g,approvals:ct,approvalMsg:Ot,peerMessages:kt,peerMsg:ht,threadText:La,threadGateNote:Ve,threadMsg:Qt,threadList:Wl,chatMsg:va,chatHistory:Ul,showDetail:be,refreshDetailLists:Il,doAction:ga,taskCost:fu,forkTask:du,postReview:Pl,submitFindings:Ce,decideApproval:ml,postPeer:Ae,threadAction:mu,sendChatMessage:hu,downloadTrace:vu,refreshReviews:()=>void Be(),refreshApprovals:()=>void Nl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[C,M]=d.useState(null),[S,_]=d.useState(""),[O,Y]=d.useState(null),[x,H]=d.useState(""),L=d.useCallback(async Q=>{const ft=Q!==void 0?Q:i();if(!ft){h([]);return}try{const D=await W(F.projectAutomations(ft));h(D.automations||[])}catLine truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:vo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:vo(i)}}function pg(){const i=Zt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let H;if(i.chatHistory){H=i.chatHistory.messages.map(V=>({id:V.id,role:V.role,text:V.text,label:V.label,kind:V.kind}));const k=new Set(H.map(V=>V.id)),W=i.traceEntries.filter(V=>V.id&&!k.has(V.id)&&!String(V.id).startsWith("fu-")).map((V,I)=>Xh(V,I));H=[...H,...W]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],W=i.traceEntries.filter(I=>!String(I.id||"").startsWith("fu-")).map((I,X)=>Xh(I,X)),V=i.followups.map((I,X)=>({id:"followup-"+X,role:"user",text:String(I.body||""),label:"后续消息"}));H=[...k,...W,...V]}const R=i.traceKind,Y=R?H.filter(k=>k.role==="user"||!k.kind||k.kind===R):H,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),g=["queued","running"].includes(r.status),C=f||g,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:H=>i.setTraceKind(H.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(H=>c.jsx("option",{value:H,children:H==="exec"?"命令":H==="file"?"文件":H==="call"?"调用":H==="tool"?"工具":H==="message"?"消息":H==="reasoning"?"思考":H==="plan"?"计划":"子代理"},H))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:H=>void i.sendChatMessage(H)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Zt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=uu.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated (链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:yo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:yo(i)}}function pg(){const i=Lt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(tt=>({id:tt.id,role:tt.role,text:tt.text,label:tt.label,kind:tt.kind}));const H=new Set(_.map(tt=>tt.id)),L=i.traceEntries.filter(tt=>tt.id&&!H.has(tt.id)&&!String(tt.id).startsWith("fu-")).map((tt,dt)=>Lh(tt,dt));_=[..._,...L]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],L=i.traceEntries.filter(dt=>!String(dt.id||"").startsWith("fu-")).map((dt,J)=>Lh(dt,J)),tt=i.followups.map((dt,J)=>({id:"followup-"+J,role:"user",text:String(dt.body||""),label:"后续消息"}));_=[...H,...L,...tt]}const O=i.traceKind,Y=O?_.filter(H=>H.role==="user"||!H.kind||H.kind===O):_,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=su.includes(r.status),y=["queued","running"].includes(r.status),C=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Lt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=su.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),childrLine truncated
`);for(;ot>=0;){const rt=vt.slice(0,ot);vt=vt.slice(ot+2);for(const G of rt.split(` `);for(;nt>=0;){const rt=mt.slice(0,nt);mt=mt.slice(nt+2);for(const G of rt.split(`
`))if(G.startsWith("data: "))try{const N=JSON.parse(G.slice(6));N.waiting?S(N.task_status==="interrupted"?"任务已中断,等待续跑…":N.task_status==="paused"?"任务已暂停…":"等待会话…"):N.image&&(f("data:image/jpeg;base64,"+N.image),S("直播中"));const ut={};N.holder&&(ut.holder=N.holder),N.url&&(ut.url=N.url),N.title!==void 0&&(ut.title=N.title),Object.keys(ut).length>0&&C(St=>({...St||{},...ut}))}catch{}ot=vt.indexOf(` `))if(G.startsWith("data: "))try{const A=JSON.parse(G.slice(6));A.waiting?S(A.task_status==="interrupted"?"任务已中断,等待续跑…":A.task_status==="paused"?"任务已暂停…":"等待会话…"):A.image&&(f("data:image/jpeg;base64,"+A.image),S("直播中"));const ut={};A.holder&&(ut.holder=A.holder),A.url&&(ut.url=A.url),A.title!==void 0&&(ut.title=A.title),Object.keys(ut).length>0&&C(bt=>({...bt||{},...ut}))}catch{}nt=mt.indexOf(`
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;R(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),X=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),x(!1),S("已停止")},[]),ht=d.useCallback(async w=>{if(r)try{const Q=await tt(K.taskBrowserControl(r.id),"POST",{holder:w});C(Q),R("")}catch(Q){R(Q instanceof Error?Q.message:String(Q))}},[r]),gt=d.useCallback(async w=>{if(!r||(g==null?void 0:g.holder)!=="user"||!W.current)return;const Q=W.current.getBoundingClientRect(),et=Math.round((w.clientX-Q.left)/Q.width*1280),zt=Math.round((w.clientY-Q.top)/Q.height*800);try{await tt(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:zt})}catch(Bt){R(Bt instanceof Error?Bt.message:String(Bt))}},[r,g]),xt=d.useCallback(async w=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){w.preventDefault();try{w.key.length===1?await tt(K.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await tt(K.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(Q){R(Q instanceof Error?Q.message:String(Q))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Z=(g==null?void 0:g.holder)||"—",Tt=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Tt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Z]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:X,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void I(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),Z!=="user"?c.jsx("button",{type:"button",onClick:()=>void ht("user"),disabled:!Tt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void ht("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,Z==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:W,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void gt(w),onKeyDown:w=>void xt(w),style:{width:"100%",maxWidth:1280,cursor:Z==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Tt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[C,A]=d.useState(""),[S,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>g(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:R=>A(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:R=>H(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let Y=R.target_type||"";return R.branch&&(Y+=":"+R.branch),R.sha&&(Y+=":"+String(R.sha).slice(0,12)),R.instructions&&(Y+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(R.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated `)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;O(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),J=d.useCallback(()=>{var D;(D=H.current)==null||D.abort(),x(!1),S("已停止")},[]),gt=d.useCallback(async D=>{if(r)try{const X=await W(F.taskBrowserControl(r.id),"POST",{holder:D});C(X),O("")}catch(X){O(X instanceof Error?X.message:String(X))}},[r]),_t=d.useCallback(async D=>{if(!r||(y==null?void 0:y.holder)!=="user"||!L.current)return;const X=L.current.getBoundingClientRect(),et=Math.round((D.clientX-X.left)/X.width*1280),xt=Math.round((D.clientY-X.top)/X.height*800);try{await W(F.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:xt})}catch(Mt){O(Mt instanceof Error?Mt.message:String(Mt))}},[r,y]),Dt=d.useCallback(async D=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){D.preventDefault();try{D.key.length===1?await W(F.taskBrowserInput(r.id),"POST",{type:"type",text:D.key}):await W(F.taskBrowserInput(r.id),"POST",{type:"key",key:D.key})}catch(X){O(X instanceof Error?X.message:String(X))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(y==null?void 0:y.holder)||"—",ft=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:M})]}),c.jsxs("span",{className:"muted",children:["会话 ",ft?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:J,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void dt(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void tt(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void gt("user"),disabled:!ft,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void gt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:L,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:D=>void _t(D),onKeyDown:D=>void Dt(D),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ft?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Lt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[C,M]=d.useState(""),[S,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:O=>y(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>M(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:O=>_(O.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(ou,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",pe(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.sLine truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated <style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+43
View File
@@ -1044,6 +1044,49 @@ POSTGRES_SCHEMA = (
"status TEXT, " "status TEXT, "
"requested_at DOUBLE PRECISION, " "requested_at DOUBLE PRECISION, "
"done_at DOUBLE PRECISION)", "done_at DOUBLE PRECISION)",
# 常驻监听(P0 S3:此前只由 resident.ensure_resident_tables
# 运行时懒建,POSTGRES_SCHEMA 与迁移清单里没有;本批正式
# 写进 schema,与 ensure 的 CREATE 语句列一一对应,含
# P1 新增的 resident_polls.failure_reason 列;TEXT 主键/
# 复合部分唯一索引,无身份列,不需要序列拨动)。
"CREATE TABLE IF NOT EXISTS resident_watches("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"name TEXT, "
"target_url TEXT, "
"prompt TEXT, "
"interval_seconds BIGINT, "
"fetch_timeout_seconds BIGINT, "
"status TEXT, "
"last_poll_at DOUBLE PRECISION, "
"last_claimed_at DOUBLE PRECISION, "
"next_poll_at DOUBLE PRECISION, "
"last_content_hash TEXT, "
"last_change_at DOUBLE PRECISION, "
"last_change_text TEXT, "
"consecutive_failures BIGINT, "
"paused_reason TEXT, "
"ended_at DOUBLE PRECISION, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION)",
"CREATE INDEX IF NOT EXISTS idx_resident_watches_project"
" ON resident_watches(project_id, status)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_resident_watches_name"
" ON resident_watches(project_id, name)"
" WHERE status != 'ended'",
"CREATE TABLE IF NOT EXISTS resident_polls("
"id TEXT PRIMARY KEY, "
"watch_id TEXT, "
"project_id TEXT, "
"status TEXT, "
"observed_hash TEXT, "
"detail TEXT, "
"task_id TEXT, "
"created_at DOUBLE PRECISION, "
"failure_reason TEXT)",
"CREATE INDEX IF NOT EXISTS idx_resident_polls_watch"
" ON resident_polls(watch_id, created_at)",
) )
+5
View File
@@ -82,6 +82,11 @@ MIGRATION_TABLES = (
# 老库没有时迁移记 0,不中断其余表)。 # 老库没有时迁移记 0,不中断其余表)。
"task_checkpoints", "task_checkpoints",
"task_steps", "task_steps",
# 常驻监听(P0 S3:与 POSTGRES_SCHEMA 同款两表,TEXT 主键,
# 无身份列;sqlite 侧由 resident.ensure 建出,老库没有时
# 记 0,不中断其余表;failure_reason 列两端 schema 一致)。
"resident_watches",
"resident_polls",
) )
_SKIP_TABLES = frozenset({"kb_fts", "sqlite_sequence"}) _SKIP_TABLES = frozenset({"kb_fts", "sqlite_sequence"})
+513 -34
View File
@@ -1,7 +1,10 @@
"""Headless Chromium automation for Codex tasks, via Playwright. """Headless Chromium automation for Codex tasks, via Playwright.
One Browser object owns one page for the lifetime of an MCP One Browser object owns a list of pages for the lifetime of an
process. It is deliberately small: navigate, snapshot (page text), MCP process (multi-tab: new/list/switch/close tools; the
single-page tools act on the current page, and each new_page
gets a fresh Playwright context, so cookies are isolated
between pages). It is deliberately small: navigate, snapshot (page text),
click, fill, evaluate, screenshot, close. Anything fancier the click, fill, evaluate, screenshot, close. Anything fancier the
agent can do with the shell tool, which has the same sandbox agent can do with the shell tool, which has the same sandbox
privileges as this browser (tenant Docker container, egress privileges as this browser (tenant Docker container, egress
@@ -48,9 +51,82 @@ def _validate_basename(name, what):
return name return name
def _allowed_hosts():
"""BROWSER_ALLOWED_HOSTS, or None when unset/empty.
Comma list, case-insensitive. An entry with a leading dot
(e.g. .python.org) matches the bare domain and any subdomain;
a plain entry matches exactly. localhost/127.0.0.1 are NOT
special-cased: they are allowed only when listed explicitly.
Unset (or empty) means no allowlist check at all -- the old
behaviour, where only the egress proxy whitelist applies.
Read on every navigate so tests and env changes take effect
without recreating the Browser."""
raw = os.environ.get("BROWSER_ALLOWED_HOSTS")
if raw is None:
return None
hosts = [entry.strip().lower() for entry in raw.split(",")]
hosts = [entry for entry in hosts if entry]
return hosts or None
def _check_allowed_url(url):
"""Raise BrowserError before launch when the allowlist is set
and the URL is not allowed. file:// is always allowed; every
other scheme except http(s) is blocked when the allowlist is
set, because it has no host to match."""
allowed = _allowed_hosts()
if allowed is None:
return
from urllib.parse import urlparse
parsed = urlparse(str(url))
scheme = (parsed.scheme or "").lower()
if scheme == "file":
return
if scheme not in ("http", "https"):
raise BrowserError(
"host allowlist blocks non-http(s) URL %r when "
"BROWSER_ALLOWED_HOSTS is set" % (str(url),))
host = (parsed.hostname or "").lower()
if not host:
raise BrowserError(
"host allowlist blocks URL with no host %r" % (str(url),))
for entry in allowed:
if entry.startswith("."):
if host == entry[1:] or host.endswith(entry):
return
elif host == entry:
return
raise BrowserError(
"host %r is not in BROWSER_ALLOWED_HOSTS (%s); "
"add it (or a leading-dot suffix entry) to allow this "
"domain" % (host, ", ".join(allowed)))
def _check_navigated_url(url):
"""Post-navigation / click-target check: same allowlist as
_check_allowed_url, but only for http(s) URLs. Anything else
is not a page navigation (file:// is always allowed anyway,
about:blank / data: / mailto: / javascript: never navigate
the page to a host), so it is skipped here instead of
raising a misleading non-http(s) error. Unset allowlist
means no check at all, same as _check_allowed_url."""
if _allowed_hosts() is None:
return
from urllib.parse import urlparse
scheme = (urlparse(str(url)).scheme or "").lower()
if scheme not in ("http", "https"):
return
_check_allowed_url(url)
class Browser: class Browser:
"""A single Chromium page. ``browser_factory`` is injectable: """Chromium pages (multi-tab). ``browser_factory`` is
tests pass a fake returning a fake sync_playwright object.""" injectable: tests pass a fake returning a fake
sync_playwright object. Single-page methods act on the
current page; new_page/switch_page/close_page manage the
page list, and each new_page gets a fresh Playwright
context, so cookies are isolated between pages."""
def __init__(self, cwd=None, proxy=None, browser_factory=None): def __init__(self, cwd=None, proxy=None, browser_factory=None):
self.cwd = os.path.abspath(cwd or os.getcwd()) self.cwd = os.path.abspath(cwd or os.getcwd())
@@ -60,6 +136,8 @@ class Browser:
self._pw = None self._pw = None
self._browser = None self._browser = None
self._page = None self._page = None
self._pages = []
self._current_index = 0
self._current_url = "" self._current_url = ""
self._last_title = "" self._last_title = ""
# ref snapshot state (playwright-mcp style): ref -> css path # ref snapshot state (playwright-mcp style): ref -> css path
@@ -110,6 +188,8 @@ class Browser:
self._pw = pw self._pw = pw
self._browser = browser self._browser = browser
self._page = page self._page = page
self._pages = [page]
self._current_index = 0
def _info(self): def _info(self):
try: try:
@@ -128,6 +208,9 @@ class Browser:
def navigate(self, url): def navigate(self, url):
if not url or not str(url).strip(): if not url or not str(url).strip():
raise BrowserError("url is required") raise BrowserError("url is required")
# Before _ensure on purpose: a blocked host must fail
# without launching Chromium at all.
_check_allowed_url(url)
self._ensure() self._ensure()
try: try:
self._page.goto(str(url), timeout=NAV_TIMEOUT_MS, self._page.goto(str(url), timeout=NAV_TIMEOUT_MS,
@@ -141,6 +224,146 @@ class Browser:
result["ok"] = True result["ok"] = True
return result return result
def _parse_page_index(self, index):
"""Shared switch/close index validation. Raises
BrowserError with the page count, never IndexError."""
if isinstance(index, bool):
raise BrowserError(
"page index must be an integer, got %r" % (index,))
try:
idx = int(index)
except (TypeError, ValueError):
raise BrowserError(
"page index must be an integer, got %r" % (index,))
if isinstance(index, float) and idx != index:
raise BrowserError(
"page index must be an integer, got %r" % (index,))
if not self._pages:
raise BrowserError(
"no pages open: call browser_new_page or navigate "
"first")
if not 0 <= idx < len(self._pages):
raise BrowserError(
"no such page: index %d (%d page(s) open, valid "
"0-%d)" % (idx, len(self._pages),
len(self._pages) - 1))
return idx
def _stop_screencast(self):
if self._cdp is not None:
try:
self._cdp.send("Page.stopScreencast")
except Exception: # noqa: BLE001 - best-effort
pass
self._cdp = None
self._cdp_page = None
def _sync_current_url(self):
try:
self._current_url = self._page.url or ""
except Exception: # noqa: BLE001 - url is best-effort
self._current_url = ""
def new_page(self):
"""Open a new page and make it current. Every new_page
gets a fresh Playwright context (browser.new_page()),
so cookies and storage are isolated from other pages.
Single-page tools then act on this page until a switch."""
self._ensure()
try:
page = self._browser.new_page(
viewport={"width": 1280, "height": 800})
except Exception as exc: # noqa: BLE001
raise BrowserError("new_page failed: %s" % exc)
self._stop_screencast()
self._pages.append(page)
self._current_index = len(self._pages) - 1
self._page = page
self._ref_paths = {}
self._sync_current_url()
result = self._info()
result["ok"] = True
result["index"] = self._current_index
result["count"] = len(self._pages)
return result
def list_pages(self):
"""List all open pages without launching a browser when
none is open. Titles/URLs are best-effort per page."""
if not self._pages:
return {"pages": [], "current": None, "count": 0}
pages = []
for i, page in enumerate(self._pages):
try:
url = page.url or ""
except Exception: # noqa: BLE001 - best-effort
url = ""
try:
title = page.title() or ""
except Exception: # noqa: BLE001 - best-effort
title = ""
pages.append({"index": i, "url": url, "title": title,
"current": i == self._current_index})
return {"pages": pages, "current": self._current_index,
"count": len(pages)}
def switch_page(self, index):
"""Make page ``index`` current. Single-page tools then
act on it; refs from the previous page are dropped,
because they map to that page's DOM."""
idx = self._parse_page_index(index)
if idx != self._current_index:
self._stop_screencast()
self._current_index = idx
self._page = self._pages[idx]
self._ref_paths = {}
self._sync_current_url()
result = self._info()
result["ok"] = True
result["index"] = self._current_index
result["count"] = len(self._pages)
return result
def close_page(self, index=None):
"""Close one page. Closing the last page is an error --
use browser_close for the whole browser. Closing the
current page switches to the previous page (index-1, or
0 when the first page was closed); closing a page before
the current one keeps the same page current (its index
shifts down by one)."""
if not self._pages:
raise BrowserError(
"no pages open: call browser_new_page or navigate "
"first")
if len(self._pages) == 1:
raise BrowserError(
"cannot close the last page (index 0): use "
"browser_close to close the whole browser")
idx = (self._current_index if index is None
else self._parse_page_index(index))
page = self._pages[idx]
try:
page.close()
except Exception as exc: # noqa: BLE001
raise BrowserError("close_page failed: %s" % exc)
del self._pages[idx]
if self._cdp_page is page:
self._cdp = None
self._cdp_page = None
if idx < self._current_index:
self._current_index -= 1
elif idx == self._current_index:
self._current_index = idx - 1 if idx > 0 else 0
self._page = self._pages[self._current_index]
self._ref_paths = {}
self._sync_current_url()
result = self._info()
result["ok"] = True
result["closed_index"] = idx
result["index"] = self._current_index
result["count"] = len(self._pages)
return result
def snapshot(self): def snapshot(self):
self._ensure() self._ensure()
try: try:
@@ -155,15 +378,84 @@ class Browser:
result["truncated"] = truncated result["truncated"] = truncated
return result return result
def _click_href(self, selector):
"""Best-effort anchor href for a click target, or None.
Reads the first matching element's href (the same element
page.click would click). Returns the href resolved
against the current page URL, or None when the target has
no href, is not an anchor, or the locator API cannot
answer (fake pages, non-anchor selectors). A None here
does NOT mean allowed: it means the target is unknown
before the click, so only the post-click URL check can
catch it. Fragment-only hrefs resolve to the current
page and are returned as None for the same reason: they
never navigate to another host."""
try:
locator = self._page.locator(str(selector))
except Exception: # noqa: BLE001 - unknown target
return None
target = getattr(locator, "first", locator)
get_attr = getattr(target, "get_attribute", None)
if get_attr is None:
return None
try:
href = get_attr("href")
except Exception: # noqa: BLE001 - unknown target
return None
if not href or not isinstance(href, str):
return None
href = href.strip()
if not href or href.startswith("#"):
return None
from urllib.parse import urljoin
try:
return urljoin(self._current_url or "", href)
except Exception: # noqa: BLE001 - unknown target
return None
def _check_click_target(self, selector):
"""Pre-click allowlist gate for click/click_ref.
When the allowlist is unset this is a no-op, so click
behaviour is byte-identical to before. When set and the
target href resolves to an http(s) URL, the same check
as navigate runs BEFORE the click: a miss raises and
the click is never executed. Unknown targets (no href,
button/JS handler) skip this gate and are caught, if at
all, by _check_navigated_current after the click."""
if _allowed_hosts() is None:
return
target = self._click_href(selector)
if target is not None:
_check_navigated_url(target)
def _check_navigated_current(self):
"""Post-click allowlist detection (not prevention).
The click already happened, so this can only report a
blocked landing host, never undo the navigation. It
exists because some click targets cannot be resolved
before the click (coordinates, JS handlers, forms): for
those, a raised error here is still better than silently
continuing on a blocked host, and the egress proxy 403
is the layer that actually stops the page load when the
allowlist equals the proxy whitelist."""
if _allowed_hosts() is None or not self._current_url:
return
_check_navigated_url(self._current_url)
def click(self, selector): def click(self, selector):
if not selector or not str(selector).strip(): if not selector or not str(selector).strip():
raise BrowserError("selector is required") raise BrowserError("selector is required")
self._ensure() self._ensure()
self._check_click_target(selector)
try: try:
self._page.click(str(selector), timeout=10000) self._page.click(str(selector), timeout=10000)
self._current_url = self._page.url self._current_url = self._page.url
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise BrowserError("click failed: %s" % exc) raise BrowserError("click failed: %s" % exc)
self._check_navigated_current()
result = self._info() result = self._info()
result["ok"] = True result["ok"] = True
return result return result
@@ -293,11 +585,16 @@ class Browser:
"unknown ref %r: run browser_snapshot_refs first" % (ref,)) "unknown ref %r: run browser_snapshot_refs first" % (ref,))
self._ensure() self._ensure()
path = self._ref_paths[ref] path = self._ref_paths[ref]
# The ref path is a CSS path, so the same href gate as
# click applies: an anchor ref to a blocked host is
# refused before the click, never executed.
self._check_click_target(path)
try: try:
self._page.locator(path).first.click(timeout=10000) self._page.locator(path).first.click(timeout=10000)
self._current_url = self._page.url self._current_url = self._page.url
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise BrowserError("click_ref failed: %s" % exc) raise BrowserError("click_ref failed: %s" % exc)
self._check_navigated_current()
result = self._info() result = self._info()
result["ok"] = True result["ok"] = True
result["ref"] = ref result["ref"] = ref
@@ -413,11 +710,21 @@ class Browser:
raise BrowserError( raise BrowserError(
"click_at out of viewport 1280x800: %s,%s" % (x, y)) "click_at out of viewport 1280x800: %s,%s" % (x, y))
self._ensure() self._ensure()
# Deliberately NO pre-click gate here: coordinates do not
# identify an element, so there is no href to check
# before the click. Probing elementFromPoint would be
# viewport/scroll-dependent, would miss JS-driven
# navigations (the common click_at case: buttons, canvas,
# SPA controls), and would run page JS on every user
# click. What click_at does get is the post-click URL
# check below, plus the egress proxy 403 backstop for
# any host outside the proxy whitelist.
try: try:
self._page.mouse.click(fx, fy) self._page.mouse.click(fx, fy)
self._current_url = self._page.url self._current_url = self._page.url
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise BrowserError("click_at failed: %s" % exc) raise BrowserError("click_at failed: %s" % exc)
self._check_navigated_current()
result = self._info() result = self._info()
result["ok"] = True result["ok"] = True
result["x"] = fx result["x"] = fx
@@ -482,8 +789,17 @@ class Browser:
pass pass
self._cdp = None self._cdp = None
self._cdp_page = None self._cdp_page = None
for closer in (lambda: self._page and self._page.close(), for page in list(self._pages):
lambda: self._browser and self._browser.close(), try:
page.close()
except Exception as exc: # noqa: BLE001 - close is best-effort
errors.append(str(exc))
if self._page is not None and self._page not in self._pages:
try:
self._page.close()
except Exception as exc: # noqa: BLE001 - close is best-effort
errors.append(str(exc))
for closer in (lambda: self._browser and self._browser.close(),
lambda: self._pw and self._pw.stop()): lambda: self._pw and self._pw.stop()):
try: try:
closer() closer()
@@ -492,54 +808,201 @@ class Browser:
self._page = None self._page = None
self._browser = None self._browser = None
self._pw = None self._pw = None
self._pages = []
self._current_index = 0
self._ref_paths = {}
# Live-view files are per-browser, not per-shot: closing
# the browser must not leave a stale browser-live.png /
# browser-live.json for a watcher to mistake for a live
# page. Failures are ignored on purpose (close is
# best-effort; a missing file is the normal case).
for name in ("browser-live.png", "browser-live.json"):
try:
os.remove(os.path.join(self.cwd, name))
except OSError:
pass
return {"closed": True, "errors": errors} return {"closed": True, "errors": errors}
# ------------------------------------------------------- codex-home glue # ------------------------------------------------------- codex-home glue
PLAYWRIGHT_SCRIPT_NAMES = ("playwright_browser.py", "playwright_mcp.py",
"playwright_remote.py")
# SHA-256 of the three MCP scripts as shipped at origin/main
# 2a569ac (PR #272), verified against the git blobs. A tenant
# script matching one of these hashes is PROVEN unmodified
# (it is exactly what a previous seeding wrote), so the safe
# upgrade below may replace it even when no manifest exists
# (legacy tenants seeded before manifests were written).
# A tenant script matching neither a manifest entry nor a hash
# here is treated as tenant-modified and is NEVER overwritten.
KNOWN_SHIPPED_SCRIPT_HASHES = {
"playwright_browser.py":
"0c36a286da3f8733fef4fec2dd18dec003fa68f54385fd4dc8da4b38e7571828",
"playwright_mcp.py":
"8455e67c2eb2ec6eb07bc9645292e2323b587e01d5a75fd8a985bbcd04a11198",
"playwright_remote.py":
"15a8026a02dfed6c630ea1614f75d54764a851df9d977c2380e15d0ed4f8a173",
}
_MANIFEST_NAME = "playwright_scripts_manifest.json"
_AUDIT_NAME = "playwright_scripts_audit.jsonl"
def _sha256_bytes(data):
import hashlib
return hashlib.sha256(data).hexdigest()
def _read_script_manifest(home):
"""name -> sha256 last written by seeding, or {}.
The manifest is this helper's own metadata file in the
tenant codex-home (not tenant data): it records what the
last seeding wrote, so a later upgrade can tell an
untouched script from a tenant-modified one. Missing or
corrupt manifests read as empty -- the caller then falls
back to KNOWN_SHIPPED_SCRIPT_HASHES, never to overwriting
an unknown file."""
try:
with open(os.path.join(home, _MANIFEST_NAME),
encoding="utf-8") as fh:
data = json.load(fh)
except Exception: # noqa: BLE001 - missing/corrupt = no proof
return {}
if not isinstance(data, dict):
return {}
return {k: v for k, v in data.items()
if isinstance(k, str) and isinstance(v, str)}
def _write_script_manifest(home, hashes):
"""Best-effort manifest write; failures never block seeding."""
try:
with open(os.path.join(home, _MANIFEST_NAME), "w",
encoding="utf-8") as fh:
json.dump({k: hashes[k] for k in sorted(hashes)},
fh, ensure_ascii=False, sort_keys=True)
fh.write("\n")
except OSError:
pass
def _audit_script_skipped(home, name, dst_hash, src_hash):
"""Append one audit record for a skipped modified script.
Idempotent: a record with the same script + tenant hash is
never appended twice, so re-running an upgrade does not
grow the log. The audit file is append-only metadata; the
skipped script itself is left byte-identical."""
path = os.path.join(home, _AUDIT_NAME)
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
try:
rec = json.loads(line)
except ValueError:
continue
if (isinstance(rec, dict)
and rec.get("script") == name
and rec.get("dst_sha256") == dst_hash
and rec.get("action") == "skipped_modified"):
return path
except OSError:
pass
record = {"ts": int(time.time()),
"action": "skipped_modified",
"script": name,
"dst_sha256": dst_hash,
"src_sha256": src_hash,
"note": "tenant-modified script left untouched; "
"manual review required before upgrade"}
try:
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError:
pass
return path
def ensure_codex_home_playwright(codex_home_dir, command_args=None, def ensure_codex_home_playwright(codex_home_dir, command_args=None,
script_dir=None): script_dir=None):
"""Seed playwright MCP files + config into a tenant codex-home. """Seed playwright MCP files + config into a tenant codex-home.
MCP scripts are SYSTEM files, not tenant data: a tenant never SAFE UPGRADE RULE (changed from the old refresh-anything
edits them (user files live in the worktree, not codex-home), rule): a tenant script is auto-merged to the deployed
so unlike the old never-overwrite rule, a differing script is version ONLY when it is proven unmodified -- its bytes
REFRESHED to the deployed version and reported in match the seeding manifest, or match a
``script_refreshed``. Without this, every MCP feature ships KNOWN_SHIPPED_SCRIPT_HASHES entry (legacy tenants), or
dead for existing projects: they keep the first-seeded script the file is missing (fresh seed) or already current. A
forever and only brand-new projects get the update. script that differs AND matches neither proof is
Appends ``[mcp_servers.playwright]`` only when absent. No tenant-modified: it is NEVER overwritten, it is reported
secrets live here -- proxy vars arrive via in ``script_skipped_modified`` (alias ``script_differs``),
``ensure_playwright_mcp_env`` from worker proxy_env, and the and one deduped audit record is appended to
browsers path is a fixed image path. ``playwright_scripts_audit.jsonl`` for manual review.
The upgrade never deletes a tenant file and never rewrites
existing config semantics: ``[mcp_servers.playwright]``
is appended only when absent, and the manifest/audit files
are this helper's own additive metadata. No secrets live
here -- proxy vars arrive via ``ensure_playwright_mcp_env``
from worker proxy_env, and the browsers path is a fixed
image path.
""" """
import shutil import shutil
home = os.path.abspath(codex_home_dir) home = os.path.abspath(codex_home_dir)
os.makedirs(home, exist_ok=True) os.makedirs(home, exist_ok=True)
here = os.path.abspath(script_dir or os.path.dirname(__file__)) here = os.path.abspath(script_dir or os.path.dirname(__file__))
manifest = _read_script_manifest(home)
script_changed = False script_changed = False
script_differs = [] script_differs = []
script_refreshed = [] script_refreshed = []
for name in ("playwright_browser.py", "playwright_mcp.py", script_skipped = []
"playwright_remote.py"): audit_path = os.path.join(home, _AUDIT_NAME)
new_hashes = {}
for name in PLAYWRIGHT_SCRIPT_NAMES:
src = os.path.join(here, name) src = os.path.join(here, name)
dst = os.path.join(home, name) dst = os.path.join(home, name)
if not os.path.exists(src): if not os.path.exists(src):
continue continue
with open(src, "rb") as fh:
src_bytes = fh.read()
src_hash = _sha256_bytes(src_bytes)
if not os.path.exists(dst): if not os.path.exists(dst):
shutil.copyfile(src, dst) shutil.copyfile(src, dst)
script_changed = True script_changed = True
new_hashes[name] = src_hash
continue
with open(dst, "rb") as fh:
dst_bytes = fh.read()
dst_hash = _sha256_bytes(dst_bytes)
if dst_bytes == src_bytes:
new_hashes[name] = src_hash
continue
proven_unmodified = (
manifest.get(name) == dst_hash
or KNOWN_SHIPPED_SCRIPT_HASHES.get(name) == dst_hash)
if proven_unmodified:
shutil.copyfile(src, dst)
script_refreshed.append(name)
script_changed = True
new_hashes[name] = src_hash
else: else:
with open(src, "rb") as fh: script_differs.append(name)
src_bytes = fh.read() script_skipped.append(name)
with open(dst, "rb") as fh: _audit_script_skipped(home, name, dst_hash, src_hash)
dst_bytes = fh.read() # NEVER record the tenant-modified hash as proven:
if src_bytes != dst_bytes: # writing dst_hash into the manifest would make the
shutil.copyfile(src, dst) # NEXT upgrade mistake this modified file for an
script_refreshed.append(name) # untouched one and overwrite it. Keep any older
script_changed = True # proof entry (so a revert to that version can still
continue # auto-merge later); otherwise drop the entry, which
# fails closed to skip again.
if name in manifest:
new_hashes[name] = manifest[name]
if new_hashes:
_write_script_manifest(home, new_hashes)
config_path = os.path.join(home, "config.toml") config_path = os.path.join(home, "config.toml")
if command_args is None: if command_args is None:
command_args = ["python3", command_args = ["python3",
@@ -565,7 +1028,9 @@ def ensure_codex_home_playwright(codex_home_dir, command_args=None,
"config_changed": config_changed, "config_changed": config_changed,
"script_changed": script_changed, "script_changed": script_changed,
"script_differs": script_differs, "script_differs": script_differs,
"script_refreshed": script_refreshed} "script_refreshed": script_refreshed,
"script_skipped_modified": script_skipped,
"audit_path": audit_path}
def ensure_playwright_mcp_env(codex_home_dir, env): def ensure_playwright_mcp_env(codex_home_dir, env):
@@ -576,7 +1041,9 @@ def ensure_playwright_mcp_env(codex_home_dir, env):
proxy vars and PLAYWRIGHT_BROWSERS_PATH must live in proxy vars and PLAYWRIGHT_BROWSERS_PATH must live in
config.toml. Idempotent in-place replacement of the env config.toml. Idempotent in-place replacement of the env
subsection; other sections untouched. Returns True when the subsection; other sections untouched. Returns True when the
file was written. file was written, False without rewriting when the resulting
file would be identical to the existing one (so a second
task prep for an unchanged tenant leaves the file alone).
""" """
if not env: if not env:
return False return False
@@ -589,6 +1056,7 @@ def ensure_playwright_mcp_env(codex_home_dir, env):
existing = fh.read() existing = fh.read()
except OSError: except OSError:
return False return False
original = existing
start = existing.find("[mcp_servers.playwright.env]") start = existing.find("[mcp_servers.playwright.env]")
if start >= 0: if start >= 0:
end = existing.find("\n[", start + 1) end = existing.find("\n[", start + 1)
@@ -598,12 +1066,23 @@ def ensure_playwright_mcp_env(codex_home_dir, env):
lines = ["\n[mcp_servers.playwright.env]\n"] lines = ["\n[mcp_servers.playwright.env]\n"]
for key in sorted(env): for key in sorted(env):
lines.append("%s = %s\n" % (key, json.dumps(str(env[key])))) lines.append("%s = %s\n" % (key, json.dumps(str(env[key]))))
# Canonical join: the old write (stripped body + lines[0]'s
# leading blank line) grew one blank line per call, so a
# second prep never produced identical bytes. Normalise
# instead: body without trailing newlines, exactly one blank
# line, then the block; an empty body keeps a single leading
# blank line. This converges in one rewrite and is stable
# afterwards, which the no-op check below relies on.
block = "".join(lines).lstrip("\n")
if existing.strip():
new_text = existing.rstrip("\n") + "\n\n" + block
else:
new_text = "\n" + block
if new_text == original:
return False
try: try:
with open(config_path, "w", encoding="utf-8") as fh: with open(config_path, "w", encoding="utf-8") as fh:
fh.write(existing) fh.write(new_text)
if existing and not existing.endswith("\n"):
fh.write("\n")
fh.writelines(lines)
except OSError: except OSError:
return False return False
return True return True
+79 -4
View File
@@ -16,7 +16,11 @@ proxy vars and PLAYWRIGHT_BROWSERS_PATH arrive via the MCP env
block in config.toml, never via tool arguments. block in config.toml, never via tool arguments.
One Browser instance lives for the whole MCP process: navigate One Browser instance lives for the whole MCP process: navigate
once, then snapshot/click/fill/screenshot against the same page. once, then snapshot/click/fill/screenshot against the current
page. browser_new_page / browser_list_pages /
browser_switch_page / browser_close_page manage multiple pages;
each new page gets a fresh Playwright context, so cookies are
isolated between pages.
""" """
import json import json
@@ -68,7 +72,12 @@ _TOOLS = [
"description": ( "description": (
"Click an element by CSS selector or Playwright " "Click an element by CSS selector or Playwright "
"selector (e.g. 'button.submit', 'text=Next'). " "selector (e.g. 'button.submit', 'text=Next'). "
"Then snapshot to see the result." "Then snapshot to see the result. When "
"BROWSER_ALLOWED_HOSTS is set, an anchor click to "
"a non-listed host is refused BEFORE the click; a "
"click whose target cannot be resolved beforehand "
"(button, JS handler) is reported after it lands "
"on a blocked host instead."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -116,7 +125,11 @@ _TOOLS = [
"top-left of the 1280x800 viewport). This is the tool " "top-left of the 1280x800 viewport). This is the tool "
"a live viewer drives: the user clicks on a screenshot " "a live viewer drives: the user clicks on a screenshot "
"and the coordinates arrive here. Then snapshot to see " "and the coordinates arrive here. Then snapshot to see "
"the result." "the result. Coordinates identify no element, so "
"there is no pre-click allowlist gate here: when "
"BROWSER_ALLOWED_HOSTS is set, a landing on a "
"blocked host is reported after the click, and the "
"egress proxy 403 is the backstop that stops the load."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -161,7 +174,10 @@ _TOOLS = [
"name": "browser_click_ref", "name": "browser_click_ref",
"description": ( "description": (
"Click an element by its ref from browser_snapshot_refs. " "Click an element by its ref from browser_snapshot_refs. "
"More robust than a CSS selector across re-renders." "More robust than a CSS selector across re-renders. "
"Same allowlist rule as browser_click: an anchor ref "
"to a non-listed host is refused before the click "
"when BROWSER_ALLOWED_HOSTS is set."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -217,6 +233,57 @@ _TOOLS = [
}, },
}, },
}, },
{
"name": "browser_new_page",
"description": (
"Open a new page (tab) and make it current. Every "
"new page gets a fresh Playwright context, so "
"cookies and storage are isolated from other pages. "
"Later navigate/snapshot/click/fill/screenshot act "
"on this page until browser_switch_page."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "browser_list_pages",
"description": (
"List all open pages with index, URL, title and "
"which one is current. Does not launch a browser "
"when none is open."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "browser_switch_page",
"description": (
"Make page ``index`` (from browser_list_pages) "
"current. Later single-page tools act on it. "
"Switching to a missing index is an error, not a "
"crash; refs from the previous page are dropped."
),
"inputSchema": {
"type": "object",
"properties": {
"index": {"type": "integer"},
},
"required": ["index"],
},
},
{
"name": "browser_close_page",
"description": (
"Close one page (``index``, or the current page "
"when omitted). Closing the last page is an error; "
"use browser_close for the whole browser. Closing "
"the current page switches to the previous page."
),
"inputSchema": {
"type": "object",
"properties": {
"index": {"type": "integer"},
},
},
},
{ {
"name": "browser_close", "name": "browser_close",
"description": "Close the browser page. Safe to call twice.", "description": "Close the browser page. Safe to call twice.",
@@ -337,6 +404,14 @@ def handle_request(request, browser=None):
elif name == "browser_screenshot": elif name == "browser_screenshot":
result = target.screenshot( result = target.screenshot(
args.get("output_filename")) args.get("output_filename"))
elif name == "browser_new_page":
result = target.new_page()
elif name == "browser_list_pages":
result = target.list_pages()
elif name == "browser_switch_page":
result = target.switch_page(args.get("index"))
elif name == "browser_close_page":
result = target.close_page(args.get("index"))
elif name == "browser_close": elif name == "browser_close":
result = target.close() result = target.close()
else: else:
+1585
View File
File diff suppressed because it is too large. Load diff
+212
View File
@@ -110,6 +110,16 @@ from cloud.wait_ops import worktree_status as _wait_worktree_status
from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp
from cloud.automations import ensure_automations_tables as _auto_ensure from cloud.automations import ensure_automations_tables as _auto_ensure
from cloud.automations import list_automations as _auto_list from cloud.automations import list_automations as _auto_list
from cloud.resident import create_watch as _res_create
from cloud.resident import end_watch as _res_end
from cloud.resident import ensure_resident_tables as _res_ensure
from cloud.resident import get_watch as _res_get
from cloud.resident import list_polls as _res_polls
from cloud.resident import list_watches as _res_list
from cloud.resident import pause_watch as _res_pause
from cloud.resident import resident_metrics as _res_metrics
from cloud.resident import resume_watch as _res_resume
from cloud.resident import update_watch as _res_update
from cloud.todos import create_todo as _todo_create from cloud.todos import create_todo as _todo_create
from cloud.todos import delete_todo as _todo_delete from cloud.todos import delete_todo as _todo_delete
from cloud.todos import get_todo as _todo_get from cloud.todos import get_todo as _todo_get
@@ -1731,6 +1741,10 @@ def init_db(db_path):
_ensure_task_checkpoints_table(conn) _ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn) _ensure_task_steps_table(conn)
ensure_kill_requests_table(conn) ensure_kill_requests_table(conn)
# 常驻监听表(P0 S3 的 SQLite 侧):启动时建一次,
# 热路径(claim/run_due/批量)不再逐条 ensure,
# 详见 cloud/resident.py ensure 的调用约定。
_res_ensure(conn)
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的 # worker 每轮 poll 都按 status 取任务(见 load_tasks 的
# WHERE status IN (...)),无索引时是全表扫描;任务表随 # WHERE status IN (...)),无索引时是全表扫描;任务表随
# 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。 # 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。
@@ -3371,6 +3385,54 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return return
self._triage_inbox(project_id) self._triage_inbox(project_id)
return return
# /projects/{id}/resident-watches:常驻监听任务。
# 创建/更新/暂停/恢复/结束用 create_task,读用 read;
# 状态机见 cloud/resident.py(running/paused/ended,
# ended 是终态,恢复已结束的监听必须新建)。
if len(parts) == 2 and parts[1] == "resident-watches":
if method == "POST":
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._resident_create(user_id, project_id)
elif method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._resident_list(project_id)
else:
self._send_json(404, {"error": "not found"})
return
if len(parts) == 3 and parts[1] == "resident-watches":
if method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._resident_get(user_id, project_id, parts[2])
elif method == "PATCH":
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._resident_update(user_id, project_id, parts[2])
else:
self._send_json(404, {"error": "not found"})
return
if len(parts) == 4 and parts[1] == "resident-watches" \
and parts[3] in ("pause", "resume", "end"):
if method != "POST":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._resident_transition(user_id, project_id, parts[2],
parts[3])
return
# /projects/{id}/todos GET 列表 / POST 新建;单条 # /projects/{id}/todos GET 列表 / POST 新建;单条
# GET / PATCH / DELETE(项目 read / create_task) # GET / PATCH / DELETE(项目 read / create_task)
if len(parts) == 2 and parts[1] == "todos": if len(parts) == 2 and parts[1] == "todos":
@@ -8845,6 +8907,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else: else:
unpriced_tokens += row["total_tokens"] or 0 unpriced_tokens += row["total_tokens"] or 0
all_tokens = actual_tokens + estimated_tokens all_tokens = actual_tokens + estimated_tokens
# 常驻监听口径(P1 G5):只统计调用者是成员的项目,
# 与任务计数同款成员过滤;表缺失时 resident_metrics
# 回 available=False,不炸 /metrics。
try:
resident_stats = _res_metrics(conn, member_ids, now)
except Exception: # noqa: BLE001 - 观测端点不因 resident 失败
resident_stats = {"available": False}
finally: finally:
conn.close() conn.close()
try: try:
@@ -8872,6 +8941,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"by_model": sorted(by_model.values(), "by_model": sorted(by_model.values(),
key=lambda e: -e["total_tokens"]), key=lambda e: -e["total_tokens"]),
}, },
"resident": resident_stats,
"queue_depth": { "queue_depth": {
"queued": by_status.get("queued", 0), "queued": by_status.get("queued", 0),
"running": by_status.get("running", 0), "running": by_status.get("running", 0),
@@ -10067,6 +10137,148 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
automation_id) automation_id)
self._send_json(200, auto) self._send_json(200, auto)
# -- 常驻监听任务(见 cloud/resident.py)-------------------
# 权限口径与自动化同款:创建/更新/暂停/恢复/结束用
# create_task,读用 read;状态变更记审计。创建不走配额门
# (轮询不花模型额度),配额只在变化建任务时由驱动过,
# 耗尽时转暂停而非拒绝创建。
def _resident_create(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
interval = body.get("interval_seconds", 3600)
if interval is None:
interval = 3600
conn = self._db()
try:
try:
watch = _res_create(
conn, project_id, body.get("name"),
body.get("target_url"), body.get("prompt"), user_id,
interval, body.get("fetch_timeout_seconds"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
except Exception as exc:
# P2:502 不再透传异常文本(可能含表名/列名等
# DB 细节),统一固定错误码,详情只记服务端日志。
_server_log.warning("resident create failed: %s", exc)
self._send_json(502, {"error": "resident_operation_failed"})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "resident_watch_created",
watch["id"])
self._send_json(201, watch)
def _resident_list(self, project_id):
conn = self._db()
try:
try:
watches = _res_list(conn, project_id)
except Exception as exc:
_server_log.warning("resident list failed: %s", exc)
self._send_json(502, {"error": "resident_operation_failed"})
return
conn.commit()
finally:
conn.close()
self._send_json(200, {
"project_id": project_id, "resident_watches": watches,
"due_count": sum(1 for w in watches if w["due"])})
def _resident_get(self, user_id, project_id, watch_id):
conn = self._db()
try:
watch = _res_get(conn, watch_id)
polls = []
polls_note = ""
if watch is not None:
try:
polls = _res_polls(conn, watch_id)
except Exception as exc:
# P2:polls 异常不再静默吞成空串,至少记
# 服务端日志;响应仍保持 polls=[] + note
# 的兼容形态(排障信号在日志里,不在响应里丢)。
_server_log.warning(
"resident polls unavailable for %s: %s",
watch_id, exc)
polls_note = "polls unavailable"
finally:
conn.close()
if watch is None or watch["project_id"] != project_id:
self._send_json(404, {"error": "resident watch not found"})
return
self._send_json(200, dict(watch, polls=polls,
polls_note=polls_note))
def _resident_update(self, user_id, project_id, watch_id):
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
conn = self._db()
try:
try:
ok, watch = _res_update(conn, watch_id, body)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
except Exception as exc:
_server_log.warning("resident update failed: %s", exc)
self._send_json(502, {"error": "resident_operation_failed"})
return
finally:
conn.close()
if watch is None or watch["project_id"] != project_id:
self._send_json(404, {"error": "resident watch not found"})
return
if not ok:
self._send_json(409, {
"error": "ended resident watch cannot be updated;"
" create a new watch instead"})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "resident_watch_updated",
watch_id)
self._send_json(200, watch)
def _resident_transition(self, user_id, project_id, watch_id, action):
fn = {"pause": _res_pause, "resume": _res_resume,
"end": _res_end}[action]
conn = self._db()
try:
try:
ok, watch = fn(conn, watch_id)
except Exception as exc:
_server_log.warning(
"resident %s failed: %s", action, exc)
self._send_json(502, {"error": "resident_operation_failed"})
return
finally:
conn.close()
if watch is None or watch["project_id"] != project_id:
self._send_json(404, {"error": "resident watch not found"})
return
if not ok:
if watch["status"] == "ended":
self._send_json(409, {
"error": "ended resident watch is terminal;"
" create a new watch instead"})
else:
self._send_json(409, {
"error": "resident watch in status %s cannot %s"
% (watch["status"], action)})
return
audit_action = {"pause": "resident_watch_paused",
"resume": "resident_watch_resumed",
"end": "resident_watch_ended"}[action]
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, audit_action, watch_id)
self._send_json(200, watch)
def _triage_inbox(self, project_id): def _triage_inbox(self, project_id):
"""分诊收件箱(只读):到期自动化、失败/中断任务、 """分诊收件箱(只读):到期自动化、失败/中断任务、
待处理后续消息、最近自动化产出。见 automations 模块 待处理后续消息、最近自动化产出。见 automations 模块
+62
View File
@@ -60,3 +60,65 @@ snapshot_refs 再点);`browser_mouse_move` / `browser_key_press` /
5. 不要用浏览器做静态抓取能做的事;不要用 evaluate 绕过 5. 不要用浏览器做静态抓取能做的事;不要用 evaluate 绕过
click / fill 的正常路径去改页面状态,除非 snapshot 已证明 click / fill 的正常路径去改页面状态,除非 snapshot 已证明
正常路径走不通。 正常路径走不通。
## 多页与域名白名单(本期新增)
多页工具:`browser_new_page`(开新页并切到它,每次
new_page 都是全新 Playwright context,cookie 与存储天然
隔离,适合同时登录两个账号或对照两个页面)、
`browser_list_pages`(列出全部页:index、URL、标题、
哪一页是当前页;浏览器未启动时不启动,只返回空列表)、
`browser_switch_page`(按 index 切到指定页,之后
navigate / snapshot / click / fill / screenshot 都针对该页;
切到不存在的 index 会明确报错,不会崩掉 MCP,当前页不变;
切换后旧页的 snapshot_refs 全部失效,要重新 snapshot_refs)、
`browser_close_page`(关一页,缺省关当前页;关当前页后
自动切到上一页;关最后一页会明确报错,改用
`browser_close` 关整个浏览器)。
域名白名单:环境变量 `BROWSER_ALLOWED_HOSTS`(逗号列表,
如 `example.com,.python.org`)。未设置时行为完全不变,
只靠 egress 代理白名单;设置后 navigate 在启动浏览器之前
就检查:`file://` 一律放行,`http(s)` 的 host 必须命中列表
(前导点条目做后缀匹配:`.python.org` 同时放行
`python.org` 与 `docs.python.org`,不放行
`notpython.org`;注意:不带前导点的 `python.org` 只精确
匹配自身,不放行 `docs.python.org`),大小写与端口不影响
匹配,未命中直接报错且不启动 Chromium。`localhost` /
`127.0.0.1` 没有特殊待遇:本地开发服务器要自己把它列进
变量,否则会被挡。
页内点击白名单(本轮补齐,与 navigate 同款检查):
`browser_click` / `browser_click_ref` 点到带 href 的链接时,
先解析 href(相对链接按当前页 URL 展开)再检查:未命中
直接报错、点击根本不执行;点到没有 href 的目标(按钮、
JS 处理函数)时事前无从得知目的地,只能在点击后检查落点
URL、命中 blocked host 时报错(此时点击已发生,只是事后
发现,不是事前拦截)。`browser_click_at` 是坐标点击,坐标
本身对应不了任何元素,所以没有事前检查,只有事后落点检查。
真正拦不住的路径(不要假装已拦):页内 JS 自行跳转
(`location.href`、表单提交、SPA 路由、回车提交)、
`browser_evaluate` 里跑的任意 JS、meta refresh、弹窗新页。
这些路径没有“点击前已知目的地”可供检查,只能靠 egress
代理 403 兜底——白名单与代理白名单取同一域名集合时,被挡
域名在代理层就加载失败,不会真的打开目标页;但 evaluate
与 shell 同权,白名单从来不是它的沙箱边界,不要把它当
成防绕过手段。
老租户脚本升级(本轮改动):播种器只对已证明未改动的
租户脚本自动合并新版(播种 manifest 命中,或命中
2a569ac 已发布版本的哈希,或文件缺失/已是最新);租户
改过的脚本绝不覆盖,原样不动、记一条审计
(租户 codex-home 里的 `playwright_scripts_audit.jsonl`),
留待人工处理——下次任务播种时它仍会被跳过,不会悄悄
被新版盖掉。
清理:`browser_close` 会关掉全部页,并尽力删除工作区里的
`browser-live.png` 与 `browser-live.json`(删不掉也不报错),
所以 close 之后不要再拿旧 live 文件当“当前画面”汇报;
单个截图(`browser_screenshot` 的 `shot-*.png`)不受影响。
不在本轨范围:下载没有落盘清单(下载仍只落工作区文件,
无统一清单可查);没有持久 profile(每次 MCP 进程结束
cookie 即丢);没有登录保险库(账号密码仍不进浏览器工具)。
需要这些时不要假装已有,先如实说明缺口。
+358
View File
@@ -0,0 +1,358 @@
"""Programming search scope (scope="code") + provider extract chain.
All HTTP is mocked; no network, no real keys. Fake key values are
distinctive so a leak into an error string or result is caught by
assertion.
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
ensure_codex_home_websearch, fetch_best, fetch_via_provider,
search)
from cloud import websearch_mcp as _mcp
from cloud.websearch_mcp import handle_request
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
ensure_codex_home_websearch, fetch_best, fetch_via_provider,
search)
from cloud import websearch_mcp as _mcp
from cloud.websearch_mcp import handle_request
_FAKE_KEYS = {
"PERPLEXITY_API_KEY": "pplx-SECRET-111",
"EXA_API_KEY": "exa-SECRET-222",
"TAVILY_API_KEY": "tvly-SECRET-333",
"BRAVE_API_KEY": "brave-SECRET-444",
}
class FakeHttp:
"""post_json/get keyed by URL substring, in route order."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), \
"application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
def _tavily_body(urls):
return {"results": [{"title": "T %s" % u, "url": u,
"content": "snippet %s" % u} for u in urls]}
class CodeScopeTestCase(unittest.TestCase):
def test_tavily_code_sends_include_domains_and_filters(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://github.com/python/cpython",
"https://news.example.com/python-312",
"https://docs.python.org/3/library/pathlib.html",
]), 200),
])
out = search("pathlib changes", provider="tavily",
scope="code", env=dict(_FAKE_KEYS), http=http)
payload = http.seen[0][2]
self.assertEqual(payload["include_domains"],
list(CODE_SEARCH_DOMAINS))
self.assertEqual(out["scope"], "code")
self.assertEqual(out["code_domains"], list(CODE_SEARCH_DOMAINS))
kept = [r["url"] for r in out["results"]]
self.assertEqual(kept, ["https://github.com/python/cpython",
"https://docs.python.org/3/library/"
"pathlib.html"])
self.assertEqual(out["attempted"][0]["domain_filtered_out"], 1)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, json.dumps(out))
def test_exa_code_sends_include_domains(self):
http = FakeHttp([
("api.exa.ai", {"results": [
{"title": "E", "url": "https://crates.io/crates/serde",
"text": "serde", "score": 0.9}]}, 200),
])
out = search("serde", provider="exa", scope="code",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen[0][2]["include_domains"],
list(CODE_SEARCH_DOMAINS))
self.assertEqual(out["results"][0]["url"],
"https://crates.io/crates/serde")
def test_perplexity_code_sends_domain_filter_within_cap(self):
self.assertLessEqual(len(CODE_SEARCH_DOMAINS), 20)
http = FakeHttp([
("api.perplexity.ai",
{"choices": [{"message": {"content": "answer"}}],
"search_results": [
{"title": "P", "url": "https://go.dev/doc/",
"snippet": "go docs"},
{"title": "Ad", "url": "https://shop.example.com/go",
"snippet": "buy go"}]}, 200),
])
out = search("go docs", provider="perplexity", scope="code",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen[0][2]["search_domain_filter"],
list(CODE_SEARCH_DOMAINS))
self.assertEqual([r["url"] for r in out["results"]],
["https://go.dev/doc/"])
def test_brave_code_has_no_native_filter_but_is_post_filtered(self):
http = FakeHttp([
("api.search.brave.com",
{"web": {"results": [
{"title": "B1",
"url": "https://stackoverflow.com/questions/1",
"description": "so"},
{"title": "B2", "url": "https://blog.example.com/x",
"description": "blog"}]}}, 200),
])
out = search("q", provider="brave", scope="code",
env=dict(_FAKE_KEYS), http=http)
# No native domain parameter was invented for Brave.
self.assertNotIn("include_domains", http.seen[0][1])
self.assertNotIn("site:", http.seen[0][1])
self.assertEqual([r["url"] for r in out["results"]],
["https://stackoverflow.com/questions/1"])
def test_all_filtered_out_continues_chain_then_fails_clearly(self):
http = FakeHttp([
("api.search.brave.com",
{"web": {"results": [
{"title": "B", "url": "https://blog.example.com/x",
"description": "blog"}]}}, 200),
])
with self.assertRaises(WebSearchError) as ctx:
search("q", provider="brave", scope="code",
env=dict(_FAKE_KEYS), http=http)
self.assertIn("code-domain filter removed all 1 results",
str(ctx.exception))
def test_default_scope_unchanged_no_filter_no_post_filter(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://example.com/general",
"https://github.com/python/cpython",
]), 200),
])
out = search("q", provider="tavily",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["scope"], "general")
self.assertNotIn("code_domains", out)
self.assertNotIn("include_domains", http.seen[0][2])
self.assertEqual(len(out["results"]), 2)
def test_unknown_scope_raises_before_http(self):
http = FakeHttp([("api.tavily.com", _tavily_body([]), 200)])
with self.assertRaises(ValueError):
search("q", provider="tavily", scope="shopping",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen, [])
def test_domain_matching_is_dot_boundary(self):
self.assertTrue(_is_code_domain("https://gist.github.com/u/1"))
self.assertTrue(_is_code_domain(
"https://www.stackoverflow.com/questions/1"))
self.assertFalse(_is_code_domain("https://evilgithub.com/x"))
self.assertFalse(_is_code_domain("https://github.com.evil.com/x"))
self.assertFalse(_is_code_domain("https://notstackoverflow.com/"))
def test_missing_key_in_code_scope_reports_unconfigured(self):
http = FakeHttp([])
with self.assertRaises(WebSearchError) as ctx:
search("q", provider="tavily", scope="code",
env={}, http=http)
self.assertIn("missing API key (TAVILY_API_KEY unset)",
str(ctx.exception))
self.assertEqual(http.seen, [])
class ExtractChainTestCase(unittest.TestCase):
_PAGE = ("<html><head><title>Direct</title></head><body>"
"<p>direct page text</p></body></html>")
def test_tavily_extract_success(self):
http = FakeHttp([
("api.tavily.com/extract",
{"results": [{"url": "https://docs.python.org/3/",
"raw_content": "extracted docs text"}],
"failed_results": []}, 200),
])
out = fetch_via_provider("https://docs.python.org/3/",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "tavily-extract")
self.assertEqual(out["text"], "extracted docs text")
self.assertEqual(http.seen[0][1], "https://api.tavily.com/extract")
def test_tavily_fails_then_exa_contents_used(self):
http = FakeHttp([
("api.tavily.com/extract", {}, 500),
("api.exa.ai/contents",
{"results": [{"url": "https://docs.python.org/3/",
"text": "exa page text"}]}, 200),
])
out = fetch_via_provider("https://docs.python.org/3/",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "exa-contents")
self.assertEqual(out["text"], "exa page text")
def test_both_extracts_fail_then_direct_fetch_fallback(self):
http = FakeHttp([
("api.tavily.com/extract", {}, 500),
("api.exa.ai/contents", {}, 500),
("docs.python.org", self._PAGE, 200),
])
out = fetch_best("https://docs.python.org/3/",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "direct-fetch")
self.assertIn("direct page text", out["text"])
self.assertEqual(
[a["provider"] for a in out["extract_attempted"]],
["tavily", "exa"])
def test_both_fail_and_direct_fails_reports_both_no_fabrication(self):
http = FakeHttp([
("api.tavily.com/extract", {}, 500),
("api.exa.ai/contents", {}, 500),
("docs.python.org", {}, 403),
])
with self.assertRaises(WebSearchError) as ctx:
fetch_best("https://docs.python.org/3/",
env=dict(_FAKE_KEYS), http=http)
msg = str(ctx.exception)
self.assertIn("provider extraction failed", msg)
self.assertIn("direct fetch failed", msg)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, msg)
def test_no_keys_extract_errors_but_best_still_direct_fetches(self):
with self.assertRaises(WebSearchError) as ctx:
fetch_via_provider("https://docs.python.org/3/",
env={}, http=FakeHttp([]))
self.assertIn("no extract provider configured", str(ctx.exception))
http = FakeHttp([("docs.python.org", self._PAGE, 200)])
out = fetch_best("https://docs.python.org/3/",
env={}, http=http)
self.assertEqual(out["source"], "direct-fetch")
def test_localhost_blocked_before_any_provider_call(self):
http = FakeHttp([])
with self.assertRaises(WebSearchError):
fetch_best("http://127.0.0.1/admin",
env=dict(_FAKE_KEYS), http=http)
with self.assertRaises(WebSearchError):
fetch_via_provider("http://10.0.0.5/admin",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen, [])
class McpScopeTestCase(unittest.TestCase):
def test_tools_list_exposes_scope_enum(self):
resp = handle_request(
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
tool = [t for t in resp["result"]["tools"]
if t["name"] == "web_search"][0]
scope = tool["inputSchema"]["properties"]["scope"]
self.assertEqual(scope["enum"], ["general", "code"])
self.assertEqual(scope["default"], "general")
self.assertIn("shopping", tool["description"])
fetch_tool = [t for t in resp["result"]["tools"]
if t["name"] == "web_fetch"][0]
self.assertIn("Tavily extract", fetch_tool["description"])
def test_tools_call_passes_scope_through(self):
seen = {}
def fake_search(query, limit=5, provider="auto", scope="general"):
seen.update(query=query, scope=scope)
return {"provider": "fake", "results": [], "answer": None,
"attempted": [], "scope": scope}
resp = handle_request(
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "web_search",
"arguments": {"query": "serde",
"scope": "code"}}},
search_fn=fake_search)
self.assertFalse(resp["result"]["isError"])
self.assertEqual(seen["scope"], "code")
def test_web_fetch_default_is_extract_first_chain(self):
# Wiring assertion: the MCP default fetch is fetch_best, not
# the direct-only fetch_url.
self.assertIs(_mcp.fetch_best, _ws.fetch_best)
seen = {}
def fake_fetch(url, char_limit=15000):
seen.update(url=url, char_limit=char_limit)
return {"url": url, "text": "t", "source": "fake"}
resp = handle_request(
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "web_fetch",
"arguments": {"url": "https://example.com/x"}}},
fetch_fn=fake_fetch)
self.assertFalse(resp["result"]["isError"])
self.assertEqual(seen["url"], "https://example.com/x")
class SeededScriptTestCase(unittest.TestCase):
def test_seeded_flat_copy_exposes_code_scope(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "codex-home")
os.makedirs(home)
out = ensure_codex_home_websearch(
home, command_args=["python3", "/tmp/ws/websearch_mcp.py"])
self.assertTrue(out["config_changed"])
# Run the seeded flat copy standalone (no repo cloud
# package on its path, exactly like a tenant codex-home).
probe = (
"import json, websearch_mcp\n"
"resp = websearch_mcp.handle_request(\n"
" {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'})\n"
"tool = [t for t in resp['result']['tools']\n"
" if t['name'] == 'web_search'][0]\n"
"print(json.dumps(tool['inputSchema']['properties']"
"['scope']))\n")
env = {"PATH": os.environ.get("PATH", ""),
"PYTHONPATH": home}
proc = subprocess.run(
[sys.executable, "-c", probe], cwd=home, env=env,
capture_output=True, text=True, timeout=60)
self.assertEqual(proc.returncode, 0, proc.stderr)
scope = json.loads(proc.stdout.strip().splitlines()[-1])
self.assertEqual(scope["enum"], ["general", "code"])
if __name__ == "__main__":
unittest.main()
+220
View File
@@ -0,0 +1,220 @@
"""Task-level code-scope extension (search(..., allowed_hosts=[...])).
All HTTP is mocked; no network, no real keys. Fake key values are
distinctive so a leak into an error string or result is caught by
assertion. Extension values are the caller's own input echoed in
its own result only -- no test here writes them to a log, and the
code under test never formats them into error strings.
"""
import json
import sys
import unittest
from pathlib import Path
try:
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
_normalize_allowed_hosts, search)
from cloud.websearch_mcp import handle_request
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
_normalize_allowed_hosts, search)
from cloud.websearch_mcp import handle_request
_FAKE_KEYS = {
"PERPLEXITY_API_KEY": "pplx-SECRET-111",
"EXA_API_KEY": "exa-SECRET-222",
"TAVILY_API_KEY": "tvly-SECRET-333",
"BRAVE_API_KEY": "brave-SECRET-444",
}
_EXTRA = "docs.example-project.dev"
class FakeHttp:
"""post_json/get keyed by URL substring, in route order."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), \
"application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
def _tavily_body(urls):
return {"results": [{"title": "T %s" % u, "url": u,
"content": "snippet %s" % u} for u in urls]}
class ExtensionTestCase(unittest.TestCase):
def test_specified_extra_domain_returned_and_native_param_sent(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://docs.example-project.dev/guide",
"https://github.com/example/project",
"https://random-blog.example.com/post",
]), 200),
])
out = search("project guide", provider="tavily", scope="code",
allowed_hosts=[_EXTRA],
env=dict(_FAKE_KEYS), http=http)
payload = http.seen[0][2]
self.assertIn(_EXTRA, payload["include_domains"])
self.assertEqual(out["code_domains"],
list(CODE_SEARCH_DOMAINS) + [_EXTRA])
self.assertEqual(
[r["url"] for r in out["results"]],
["https://docs.example-project.dev/guide",
"https://github.com/example/project"])
self.assertEqual(out["attempted"][0]["domain_filtered_out"], 1)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, json.dumps(out))
def test_unspecified_extra_domain_still_filtered_out(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://docs.example-project.dev/guide",
"https://github.com/example/project",
]), 200),
])
out = search("project guide", provider="tavily", scope="code",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["code_domains"], list(CODE_SEARCH_DOMAINS))
self.assertEqual([r["url"] for r in out["results"]],
["https://github.com/example/project"])
def test_dot_boundary_positive_and_negative(self):
domains = list(CODE_SEARCH_DOMAINS) + [_EXTRA]
self.assertTrue(_is_code_domain(
"https://api.docs.example-project.dev/v1", domains))
self.assertTrue(_is_code_domain(
"https://docs.example-project.dev/", domains))
self.assertFalse(_is_code_domain(
"https://evildocs.example-project.dev/", domains))
self.assertFalse(_is_code_domain(
"https://docs.example-project.dev.evil.com/", domains))
# Same boundary rule for a builtin domain, unchanged.
self.assertFalse(_is_code_domain(
"https://evilgithub.com/x", domains))
self.assertTrue(_is_code_domain(
"https://gist.github.com/x", domains))
def test_builtin_entries_are_deduped_not_errors(self):
self.assertEqual(
_normalize_allowed_hosts(["github.com", _EXTRA]), [_EXTRA])
self.assertEqual(
_normalize_allowed_hosts(["Docs.Example-Project.Dev."]),
["docs.example-project.dev"])
def test_empty_and_illegal_values_raise_before_http(self):
bad_values = [
[], [""], [" "], ["*.example.com"],
["http://example.com"], ["https://example.com/x"],
["example.com:8080"], ["example.com/path"],
[".example.com"], ["example.com..dev"], ["exa mple.com"],
["user@example.com"], [123], [None], ["github.com"],
]
for bad in bad_values:
http = FakeHttp([("api.tavily.com", _tavily_body([]), 200)])
with self.assertRaises(ValueError, msg=repr(bad)):
search("q", provider="tavily", scope="code",
allowed_hosts=bad, env=dict(_FAKE_KEYS),
http=http)
self.assertEqual(http.seen, [],
"invalid %r must fail before any HTTP"
% (bad,))
# A bare string is iterable but is not a list of hostnames.
with self.assertRaises(ValueError):
search("q", provider="tavily", scope="code",
allowed_hosts="example.com",
env=dict(_FAKE_KEYS), http=FakeHttp([]))
def test_allowed_hosts_with_general_scope_raises_not_ignored(self):
http = FakeHttp([("api.tavily.com", _tavily_body(
["https://docs.example-project.dev/guide"]), 200)])
with self.assertRaises(ValueError):
search("q", provider="tavily", scope="general",
allowed_hosts=[_EXTRA],
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen, [])
def test_perplexity_over_cap_skips_native_filter_keeps_postfilter(self):
extras = ["extra%d.example.com" % i for i in range(6)]
self.assertGreater(len(CODE_SEARCH_DOMAINS) + len(extras), 20)
http = FakeHttp([
("api.perplexity.ai",
{"choices": [{"message": {"content": "answer"}}],
"search_results": [
{"title": "P",
"url": "https://extra0.example.com/doc",
"snippet": "extra"},
{"title": "Ad", "url": "https://shop.example.com/x",
"snippet": "ad"}]}, 200),
])
out = search("q", provider="perplexity", scope="code",
allowed_hosts=extras,
env=dict(_FAKE_KEYS), http=http)
payload = http.seen[0][2]
self.assertNotIn("search_domain_filter", payload)
self.assertEqual([r["url"] for r in out["results"]],
["https://extra0.example.com/doc"])
self.assertEqual(len(out["code_domains"]), 21)
class McpPassthroughTestCase(unittest.TestCase):
def test_mcp_forwards_allowed_hosts_when_supplied(self):
captured = {}
def fake_search(query, limit=5, provider="auto", scope="general",
allowed_hosts=None):
captured["allowed_hosts"] = allowed_hosts
return {"provider": "fake", "results": [], "answer": None,
"attempted": [], "scope": scope}
resp = handle_request(
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "web_search",
"arguments": {"query": "hi", "scope": "code",
"allowed_hosts": [_EXTRA]}}},
search_fn=fake_search)
self.assertFalse(resp["result"]["isError"])
self.assertEqual(captured["allowed_hosts"], [_EXTRA])
def test_mcp_without_allowed_hosts_keeps_old_fake_working(self):
def fake_search(query, limit=5, provider="auto",
scope="general"):
return {"provider": "fake", "results": [], "answer": None,
"attempted": [], "scope": scope}
resp = handle_request(
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "web_search",
"arguments": {"query": "hi"}}},
search_fn=fake_search)
self.assertFalse(resp["result"]["isError"])
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""JS-heavy page extraction evaluation, mock-first.
All HTTP is mocked; no network, no real keys. The three page shapes
below are the mock stand-ins for real JS-heavy pages (a static shell
whose body is rendered by JS, a shell whose pagination is JS-loaded,
and a shell whose content lives only in a <script> blob):
- direct fetch (fetch_url) parses static HTML only: <script> and
<style> contents are stripped, so anything JS would have rendered
is absent from the result -- this is by construction, not a bug;
- a provider extract stage (fetch_via_provider, mocked here) can
return the rendered text when the provider actually renders, and
an empty extraction is a failure, never a fabricated page.
Live对照: only the keyless SearXNG route is ever used live, and
only when a test environment actually provides SEARXNG_URL; this
file never calls a paid backend live. Set
WEBSEARCH_EVAL_LIVE_SEARXNG=1 with SEARXNG_URL in the environment
to enable the live smoke test, otherwise it is skipped.
"""
import json
import os
import sys
import unittest
from pathlib import Path
try:
from cloud.websearch import (
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.websearch import (
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
_FAKE_KEYS = {
"TAVILY_API_KEY": "tvly-SECRET-333",
"EXA_API_KEY": "exa-SECRET-222",
}
# Shape 1: shell HTML only; the article body is JS-rendered.
_SHELL_HTML = """<html><head><title>Guide</title></head><body>
<div id="app">Loading…</div>
<script src="/app.js"></script>
<script>window.__DATA__ = {"body": "RENDERED-BODY-PARAGRAPH"};</script>
<a href="https://example.com/guide/page2">Next (static link)</a>
</body></html>"""
# Shape 2: paginated list; page 2 items arrive only via JS fetch.
_PAGED_SHELL_HTML = """<html><head><title>List</title></head><body>
<ul id="items"><li>ITEM-PAGE-1</li></ul>
<button id="more">Load more</button>
<script>fetch("/api/items?page=2").then(r => r.json());</script>
</body></html>"""
class FakeHttp:
"""get/post_json keyed by URL substring, in route order."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), \
"application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
class JsHeavyDirectFetchTestCase(unittest.TestCase):
def test_direct_fetch_gets_shell_only_not_js_rendered_body(self):
http = FakeHttp([("example.com/guide", _SHELL_HTML, 200)])
out = fetch_url("https://example.com/guide", http=http, env={})
# Static shell survives: title + placeholder + static link.
self.assertEqual(out["title"], "Guide")
self.assertIn("Loading", out["text"])
self.assertIn("https://example.com/guide/page2", out["links"])
# JS-rendered body does not: <script> contents are stripped,
# so neither the rendered paragraph nor the data blob leaks
# in as text.
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
self.assertNotIn("window.__DATA__", out["text"])
def test_direct_fetch_gets_page1_only_not_js_paginated_items(self):
http = FakeHttp([("example.com/list", _PAGED_SHELL_HTML, 200)])
out = fetch_url("https://example.com/list", http=http, env={})
self.assertIn("ITEM-PAGE-1", out["text"])
self.assertNotIn("ITEM-PAGE-2", out["text"])
self.assertNotIn("/api/items?page=2", out["text"])
class JsHeavyProviderExtractTestCase(unittest.TestCase):
def test_mock_provider_extract_can_supply_rendered_text(self):
# Mock stand-in for "provider renders JS": Tavily extract
# returns the full rendered article text.
http = FakeHttp([
("api.tavily.com/extract",
{"results": [{"raw_content": "RENDERED-BODY-PARAGRAPH "
"plus ITEM-PAGE-2"}]}, 200),
])
out = fetch_via_provider("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "tavily-extract")
self.assertIn("RENDERED-BODY-PARAGRAPH", out["text"])
def test_mock_provider_extract_empty_is_failure_not_fabrication(self):
http = FakeHttp([
("api.tavily.com/extract", {"results": []}, 200),
("api.exa.ai/contents", {"results": []}, 200),
])
with self.assertRaises(WebSearchError):
fetch_via_provider("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
def test_fetch_best_falls_back_to_shell_when_extract_fails(self):
http = FakeHttp([
("api.tavily.com/extract", {"results": []}, 200),
("api.exa.ai/contents", {"results": []}, 200),
("example.com/guide", _SHELL_HTML, 200),
])
out = fetch_best("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "direct-fetch")
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
@unittest.skipUnless(
os.environ.get("WEBSEARCH_EVAL_LIVE_SEARXNG") == "1"
and os.environ.get("SEARXNG_URL"),
"live SearXNG对照 only when the test env provides SEARXNG_URL")
class LiveSearxngSmokeTestCase(unittest.TestCase):
def test_live_searxng_chain_answers_without_paid_keys(self):
from cloud.websearch import search
out = search("python pathlib documentation", provider="searxng",
scope="general",
env={"SEARXNG_URL": os.environ["SEARXNG_URL"]})
self.assertTrue(out["results"])
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -455,5 +455,293 @@ class LineReaderTest(unittest.TestCase):
pass pass
class _FakeBrowserHandleMulti:
"""Distinct fake page per new_page (the old single-page fake
returns the same page every time, which cannot tell pages
apart)."""
def __init__(self):
self.pages = []
def new_page(self, viewport=None):
page = _FakePage()
page.url = "https://page%d.example.com/" % len(self.pages)
self.pages.append(page)
return page
def close(self):
pass
class _FakePWMulti:
def __init__(self):
self.handle = _FakeBrowserHandleMulti()
self.launch_kwargs = None
self.launch_count = 0
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_kwargs = kwargs
outer.launch_count += 1
return outer.handle
return _Chromium()
def stop(self):
pass
class _EnvGuard:
"""Save/restore BROWSER_ALLOWED_HOSTS around a test case."""
def setUp(self):
self._old_allowlist = os.environ.get("BROWSER_ALLOWED_HOSTS")
def tearDown(self):
if self._old_allowlist is None:
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
else:
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old_allowlist
class MultiPageTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
self._tmp = tempfile.TemporaryDirectory()
self.factory = _FakePWMulti()
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def test_new_list_switch_current_page_semantics(self):
self.browser.navigate("https://a.example.com")
self.assertEqual(self.browser.list_pages()["count"], 1)
out = self.browser.new_page()
self.assertEqual(out["index"], 1)
self.assertEqual(out["count"], 2)
self.browser.navigate("https://b.example.com")
self.browser.click("button.x")
page0, page1 = self.factory.handle.pages
self.assertIn(("goto", "https://b.example.com"), page1.calls)
self.assertNotIn(("goto", "https://b.example.com"), page0.calls)
self.assertIn(("click", "button.x"), page1.calls)
self.assertNotIn(("click", "button.x"), page0.calls)
switched = self.browser.switch_page(0)
self.assertEqual(switched["index"], 0)
self.browser.click("a.y")
self.assertIn(("click", "a.y"), page0.calls)
self.assertNotIn(("click", "a.y"), page1.calls)
listed = self.browser.list_pages()
self.assertEqual(listed["count"], 2)
self.assertEqual(listed["current"], 0)
self.assertEqual([p["url"] for p in listed["pages"]],
["https://a.example.com",
"https://b.example.com"])
self.assertEqual([p["current"] for p in listed["pages"]],
[True, False])
def test_switch_missing_page_errors_and_keeps_current(self):
self.browser.navigate("https://a.example.com")
self.browser.new_page()
for bad in (99, -1, "x"):
with self.assertRaises(BrowserError) as ctx:
self.browser.switch_page(bad)
if bad != "x":
self.assertIn("no such page", str(ctx.exception))
self.assertEqual(self.browser.list_pages()["current"], 1)
def test_close_current_switches_to_previous(self):
self.browser.navigate("https://a.example.com")
self.browser.new_page()
self.browser.new_page()
out = self.browser.close_page()
self.assertEqual(out["closed_index"], 2)
self.assertEqual(out["index"], 1)
self.assertEqual(out["count"], 2)
self.assertIs(self.browser._page,
self.factory.handle.pages[1])
self.assertEqual(self.browser.list_pages()["current"], 1)
def test_close_noncurrent_keeps_same_page(self):
self.browser.navigate("https://a.example.com")
self.browser.new_page()
self.browser.new_page()
self.browser.switch_page(2)
page2 = self.factory.handle.pages[2]
out = self.browser.close_page(0)
self.assertEqual(out["count"], 2)
self.assertEqual(out["index"], 1)
self.assertIs(self.browser._page, page2)
def test_close_last_page_errors_and_keeps_page(self):
self.browser.navigate("https://a.example.com")
with self.assertRaises(BrowserError) as ctx:
self.browser.close_page()
self.assertIn("last page", str(ctx.exception))
with self.assertRaises(BrowserError):
self.browser.close_page(0)
self.assertEqual(self.browser.list_pages()["count"], 1)
# Still usable after the refused close.
self.browser.navigate("https://b.example.com")
self.assertEqual(self.browser._current_url,
"https://b.example.com")
def test_close_removes_live_files_and_list_needs_no_launch(self):
self.browser.navigate("https://a.example.com")
self.browser.live_shot()
self.assertTrue(os.path.exists(os.path.join(
self._tmp.name, "browser-live.png")))
self.assertTrue(os.path.exists(os.path.join(
self._tmp.name, "browser-live.json")))
launches = self.factory.launch_count
self.assertTrue(self.browser.close()["closed"])
self.assertFalse(os.path.exists(os.path.join(
self._tmp.name, "browser-live.png")))
self.assertFalse(os.path.exists(os.path.join(
self._tmp.name, "browser-live.json")))
self.assertTrue(self.browser.close()["closed"])
self.assertEqual(self.browser.list_pages(),
{"pages": [], "current": None, "count": 0})
self.assertEqual(self.factory.launch_count, launches)
self.assertIsNone(self.browser._pw)
class AllowlistTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
self._tmp = tempfile.TemporaryDirectory()
self.factory = _FakePWMulti()
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def test_unset_allowlist_behaves_as_before(self):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
out = self.browser.navigate("https://anything.example")
self.assertTrue(out["ok"])
self.assertEqual(self.factory.launch_count, 1)
def test_exact_and_suffix_match(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
self.assertTrue(self.browser.navigate(
"https://example.com")["ok"])
self.assertTrue(self.browser.navigate(
"https://docs.python.org")["ok"])
self.assertTrue(self.browser.navigate(
"https://python.org")["ok"])
def test_port_and_case_insensitive(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
out = self.browser.navigate("https://EXAMPLE.com:8443/path")
self.assertTrue(out["ok"])
def test_miss_blocked_before_launch(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
with self.assertRaises(BrowserError) as ctx:
self.browser.navigate("https://evil.com")
self.assertIn("evil.com", str(ctx.exception))
self.assertEqual(self.factory.launch_count, 0)
self.assertIsNone(self.browser._pw)
self.assertIsNone(self.browser._page)
def test_localhost_blocked_unless_listed(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
with self.assertRaises(BrowserError):
self.browser.navigate("http://localhost:8000/")
self.assertEqual(self.factory.launch_count, 0)
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
out = self.browser.navigate("http://localhost:8000/")
self.assertTrue(out["ok"])
self.assertEqual(self.factory.launch_count, 1)
def test_file_allowed_when_allowlist_set(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
out = self.browser.navigate("file:///tmp/page.html")
self.assertTrue(out["ok"])
def test_non_http_blocked_when_allowlist_set(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
with self.assertRaises(BrowserError):
self.browser.navigate("about:blank")
self.assertEqual(self.factory.launch_count, 0)
class MCPMultiPageTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self._tmp = tempfile.TemporaryDirectory()
self.factory = _FakePWMulti()
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def _call(self, name, args=None):
resp = handle_request(
{"jsonrpc": "2.0", "id": 9, "method": "tools/call",
"params": {"name": name, "arguments": args or {}}},
browser=self.browser)
return resp
def _call_result(self, name, args=None):
resp = self._call(name, args)
self.assertFalse(resp["result"]["isError"],
resp["result"]["content"][0]["text"])
return json.loads(resp["result"]["content"][0]["text"])
def test_tools_list_includes_new_page_tools(self):
listed = handle_request({"jsonrpc": "2.0", "id": 9,
"method": "tools/list"})
names = [t["name"] for t in listed["result"]["tools"]]
for name in ("browser_new_page", "browser_list_pages",
"browser_switch_page", "browser_close_page"):
self.assertIn(name, names)
def test_mcp_dispatch_new_list_switch_close(self):
self._call_result("browser_navigate",
{"url": "https://a.example.com"})
self.assertEqual(self._call_result("browser_new_page")["count"],
2)
listed = self._call_result("browser_list_pages")
self.assertEqual(listed["count"], 2)
self.assertEqual(listed["current"], 1)
self.assertEqual(self._call_result("browser_switch_page",
{"index": 0})["index"], 0)
closed = self._call_result("browser_close_page")
self.assertEqual(closed["count"], 1)
self.assertEqual(closed["index"], 0)
def test_mcp_switch_missing_and_close_last_are_errors_not_crash(self):
self._call_result("browser_navigate",
{"url": "https://a.example.com"})
resp = self._call("browser_switch_page", {"index": 99})
self.assertTrue(resp["result"]["isError"])
self.assertIn("no such page",
resp["result"]["content"][0]["text"])
resp = self._call("browser_close_page")
self.assertTrue(resp["result"]["isError"])
self.assertIn("last page",
resp["result"]["content"][0]["text"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+209
View File
@@ -0,0 +1,209 @@
"""BROWSER_ALLOWED_HOSTS activation-candidate validation.
The candidate value is the ALREADY-DEPLOYED Tinyproxy
programming-domain set (deploy/egress/tinyproxy-filter) plus the
already-present github.com / pypi.org / npmjs.org -- no invented
domains. NOTE: the task text said "npmjs.com", but the deployed
filter (tinyproxy-filter line 23, new-host-checklist.md, and
docs/platform-browser.md) all say npmjs.org, so the candidate
follows the DEPLOYED set (npmjs.org); npmjs.com is deliberately
NOT in the candidate -- adding it would widen the allowlist
beyond the proxy, breaking allowlist==proxy parity. If the npm
website itself must be browsable, that is a separate filter
change for the parent to decide, not something this round
invents. Because the Tinyproxy lines are subdomain-inclusive
(``^\\(.*\\.\\)*domain$``), the env value uses leading-dot
entries, which are the allowlist encoding of the same
subdomain-inclusive set; plain entries would NOT match
subdomains (proven by test_plain_entries_would_break_subdomains)
and would therefore regress docs.python.org etc.
All checks run against fake browsers / the filter file itself:
no production tenant, no network, no real credentials.
"""
import os
import re
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from playwright_browser import Browser, BrowserError # noqa: E402
from playwright_browser import _check_allowed_url # noqa: E402
# The activation candidate: the deployed Tinyproxy programming
# set, encoded subdomain-inclusively. Domain SET is fixed by the
# task (no new domains); only the encoding (dot prefix) follows
# from the filter's own subdomain rule.
CANDIDATE_DOMAINS = [
"python.org", "nodejs.org", "go.dev", "pkg.go.dev",
"rust-lang.org", "developer.mozilla.org", "crates.io",
"stackoverflow.com", "cve.mitre.org", "nvd.nist.gov",
"github.com", "pypi.org", "npmjs.org",
]
CANDIDATE = ",".join("." + d for d in CANDIDATE_DOMAINS)
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),
"..", ".."))
FILTER_PATH = os.path.join(REPO_ROOT, "deploy", "egress",
"tinyproxy-filter")
class _FakePage:
def __init__(self):
self.url = ""
self.calls = []
self.mouse = self
def title(self):
return "T"
def goto(self, url, timeout=None, wait_until=None):
self.calls.append(("goto", url))
self.url = url
def locator(self, selector):
raise AssertionError("no locator use in activation tests")
def click(self, selector, timeout=None):
self.calls.append(("click", selector))
def close(self):
pass
class _FakeHandle:
def __init__(self, page):
self._page = page
def new_page(self, viewport=None):
return self._page
def close(self):
pass
class _FakePW:
def __init__(self, page):
self._page = page
self.launch_count = 0
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_count += 1
return _FakeHandle(outer._page)
return _Chromium()
def stop(self):
pass
class _EnvGuard:
def setUp(self):
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
def tearDown(self):
if self._old is None:
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
else:
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
def _filter_domains():
"""Domains covered by subdomain-inclusive filter lines."""
with open(FILTER_PATH, encoding="utf-8") as fh:
lines = fh.read().splitlines()
out = set()
for line in lines:
m = re.match(r"^\^\\\(\.\*\\.\\\)\*(.+)\$$", line)
if m:
out.add(m.group(1).replace("\\.", "."))
return out
class ActivationCandidateTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
os.environ["BROWSER_ALLOWED_HOSTS"] = CANDIDATE
self._tmp = tempfile.TemporaryDirectory()
self.page = _FakePage()
self.factory = _FakePW(self.page)
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def test_candidate_matches_deployed_filter_set(self):
# Every candidate domain has a subdomain-inclusive line
# in the deployed filter; the candidate invents nothing.
filt = _filter_domains()
for domain in CANDIDATE_DOMAINS:
self.assertIn(domain, filt, domain)
def test_candidate_allows_bare_and_subdomains(self):
for domain in CANDIDATE_DOMAINS:
_check_allowed_url("https://%s/" % domain)
_check_allowed_url("https://www.%s/docs" % domain)
# The cases the filter explicitly exists for.
_check_allowed_url("https://docs.python.org/3/")
_check_allowed_url("https://docs.rust-lang.org/")
_check_allowed_url("https://api.github.com/")
_check_allowed_url("https://pypi.org/project/x/")
def test_candidate_blocks_suffix_tricks_and_outsiders(self):
for bad in ("https://notpython.org/",
"https://python.org.evil.com/",
"https://evilgithub.com/",
"https://api.diante.me/",
"https://www.amazon.com/",
"https://mail.google.com/"):
with self.assertRaises(BrowserError, msg=bad):
_check_allowed_url(bad)
def test_plain_entries_would_break_subdomains(self):
# Why the candidate uses dot forms: plain "python.org"
# does not match docs.python.org under the established
# navigate semantics, so a plain-entry activation would
# regress exactly the docs the filter was added for.
os.environ["BROWSER_ALLOWED_HOSTS"] = (
"python.org,nodejs.org,go.dev,pkg.go.dev,rust-lang.org,"
"developer.mozilla.org,crates.io,stackoverflow.com,"
"cve.mitre.org,nvd.nist.gov,github.com,pypi.org,npmjs.org")
with self.assertRaises(BrowserError):
_check_allowed_url("https://docs.python.org/3/")
def test_file_allowed_localhost_blocked_under_candidate(self):
out = self.browser.navigate("file:///tmp/page.html")
self.assertTrue(out["ok"])
with self.assertRaises(BrowserError):
self.browser.navigate("http://localhost:8000/")
# localhost blocking is a behaviour CHANGE vs unset:
# local dev servers need an explicit localhost entry.
self.assertEqual(self.factory.launch_count, 1)
def test_task_level_navigate_allow_and_block(self):
out = self.browser.navigate("https://docs.python.org/3/")
self.assertTrue(out["ok"])
launches = self.factory.launch_count
with self.assertRaises(BrowserError):
self.browser.navigate("https://evil.com/")
self.assertEqual(self.factory.launch_count, launches)
def test_unset_means_no_change(self):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
out = self.browser.navigate("https://anything.example/")
self.assertTrue(out["ok"])
_check_allowed_url("https://anything.example/")
@@ -0,0 +1,309 @@
"""Click-path allowlist tests for the Playwright browser (fake only).
Covers the continuation of the navigate-only allowlist: click /
click_ref get the SAME check before the click when the target
anchor href is resolvable, and all three click paths get a
post-click URL check. click_at has no pre-click gate by design
(coordinates identify no element). No real Chromium, no network,
no real credentials -- values here are test placeholders.
"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from playwright_browser import Browser, BrowserError # noqa: E402
from playwright_browser import _check_navigated_url # noqa: E402
class _FakeLocator:
def __init__(self, page, href):
self._page = page
self._href = href
@property
def first(self):
return self
def click(self, timeout=None):
self._page.calls.append(("locator_click",))
if self._page.click_landing is not None:
self._page.url = self._page.click_landing
def get_attribute(self, name):
if name == "href":
return self._href
return None
def inner_text(self, timeout=None):
return "text"
class _FakeMouse:
def __init__(self, page):
self._page = page
def click(self, x, y):
self._page.calls.append(("mouse_click", x, y))
if self._page.click_landing is not None:
self._page.url = self._page.click_landing
def move(self, x, y):
self._page.calls.append(("mouse_move", x, y))
class _FakePage:
def __init__(self):
self.url = "https://example.com/"
self.calls = []
self.href_map = {}
self.click_landing = None
self.mouse = _FakeMouse(self)
def title(self):
return "Example"
def goto(self, url, timeout=None, wait_until=None):
self.calls.append(("goto", url))
self.url = url
def locator(self, selector):
self.calls.append(("locator", selector))
return _FakeLocator(self, self.href_map.get(selector))
def click(self, selector, timeout=None):
self.calls.append(("click", selector))
if self.click_landing is not None:
self.url = self.click_landing
def fill(self, selector, text, timeout=None):
self.calls.append(("fill", selector, text))
def evaluate(self, expression):
self.calls.append(("evaluate", expression))
return None
def screenshot(self, path=None):
with open(path, "wb") as fh:
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)
def close(self):
pass
class _FakeHandle:
def __init__(self, page):
self._page = page
def new_page(self, viewport=None):
return self._page
def close(self):
pass
class _FakePW:
def __init__(self, page):
self._page = page
self.launch_count = 0
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_count += 1
return _FakeHandle(outer._page)
return _Chromium()
def stop(self):
pass
class _EnvGuard:
def setUp(self):
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
def tearDown(self):
if self._old is None:
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
else:
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
class ClickAllowlistTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
self._tmp = tempfile.TemporaryDirectory()
self.page = _FakePage()
self.factory = _FakePW(self.page)
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def _start(self, url="https://example.com/"):
return self.browser.navigate(url)
def test_click_allowed_href_executes(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
self._start()
self.page.href_map["a.docs"] = "https://docs.python.org/3/"
self.page.click_landing = "https://docs.python.org/3/"
out = self.browser.click("a.docs")
self.assertTrue(out["ok"])
self.assertIn(("click", "a.docs"), self.page.calls)
self.assertEqual(out["url"], "https://docs.python.org/3/")
def test_click_blocked_href_refused_before_click(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.evil"] = "https://evil.com/x"
self.page.click_landing = "https://evil.com/x"
with self.assertRaises(BrowserError) as ctx:
self.browser.click("a.evil")
self.assertIn("evil.com", str(ctx.exception))
self.assertNotIn(("click", "a.evil"), self.page.calls)
self.assertEqual(self.browser._current_url,
"https://example.com/")
def test_click_suffix_trick_blocked(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = ".python.org"
self._start("https://python.org/")
for bad in ("https://notpython.org/",
"https://python.org.evil.com/"):
self.page.href_map["a.bad"] = bad
with self.assertRaises(BrowserError):
self.browser.click("a.bad")
self.assertNotIn(("click", "a.bad"), self.page.calls)
self.page.calls.clear()
def test_click_plain_entry_is_exact_only(self):
# Same semantics as navigate: a plain entry does NOT
# match subdomains (that is what leading-dot entries
# are for). Documented, not a bug: the activation
# candidate therefore uses dot forms.
os.environ["BROWSER_ALLOWED_HOSTS"] = "python.org"
self._start("https://python.org/")
self.page.href_map["a.docs"] = "https://docs.python.org/"
with self.assertRaises(BrowserError):
self.browser.click("a.docs")
self.assertNotIn(("click", "a.docs"), self.page.calls)
def test_click_port_and_case_insensitive(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
self._start()
self.page.href_map["a.p"] = "https://EXAMPLE.com:8443/p"
self.page.click_landing = "https://EXAMPLE.com:8443/p"
out = self.browser.click("a.p")
self.assertTrue(out["ok"])
def test_click_file_href_allowed(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.file"] = "file:///tmp/page.html"
self.page.click_landing = "file:///tmp/page.html"
out = self.browser.click("a.file")
self.assertTrue(out["ok"])
def test_click_localhost_needs_explicit_listing(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.local"] = "http://localhost:8000/"
with self.assertRaises(BrowserError):
self.browser.click("a.local")
self.assertNotIn(("click", "a.local"), self.page.calls)
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
self.page.click_landing = "http://localhost:8000/"
out = self.browser.click("a.local")
self.assertTrue(out["ok"])
def test_click_relative_href_resolves_against_current(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start("https://example.com/dir/")
self.page.href_map["a.rel"] = "/other"
self.page.click_landing = "https://example.com/other"
out = self.browser.click("a.rel")
self.assertTrue(out["ok"])
# Fragment-only hrefs never navigate to another host.
self.page.href_map["a.frag"] = "#section"
self.page.click_landing = None
out = self.browser.click("a.frag")
self.assertTrue(out["ok"])
def test_click_unknown_target_post_click_detection(self):
# Button / JS handler: no href, so no pre-click gate.
# The click DOES execute; a blocked landing is then
# reported (detection, not prevention -- the proxy 403
# is the layer that stops the load in production).
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["button.go"] = None
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError) as ctx:
self.browser.click("button.go")
self.assertIn("evil.com", str(ctx.exception))
self.assertIn(("click", "button.go"), self.page.calls)
# Same target landing on an allowed host is fine.
self.page.click_landing = "https://example.com/ok"
out = self.browser.click("button.go")
self.assertTrue(out["ok"])
def test_click_unset_allowlist_unchanged(self):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self._start("https://anything.example/")
self.page.href_map["a.evil"] = "https://evil.com/"
self.page.click_landing = "https://evil.com/"
out = self.browser.click("a.evil")
self.assertTrue(out["ok"])
self.assertIn(("click", "a.evil"), self.page.calls)
def test_click_ref_blocked_href_refused_before_click(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.browser._ref_paths = {"e0": "a.evil", "e1": "a.docs"}
self.page.href_map["a.evil"] = "https://evil.com/"
self.page.href_map["a.docs"] = "https://example.com/docs"
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError):
self.browser.click_ref("e0")
self.assertNotIn(("locator_click",), self.page.calls)
self.page.click_landing = "https://example.com/docs"
out = self.browser.click_ref("e1")
self.assertTrue(out["ok"])
self.assertIn(("locator_click",), self.page.calls)
def test_click_at_post_click_only(self):
# click_at has no pre-click gate by design; a blocked
# landing raises after the click, an allowed one passes.
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError):
self.browser.click_at(100, 200)
self.assertIn(("mouse_click", 100.0, 200.0), self.page.calls)
self.page.click_landing = "https://example.com/"
out = self.browser.click_at(100, 200)
self.assertTrue(out["ok"])
def test_check_navigated_url_skips_non_page_schemes(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
# about:blank / mailto: / javascript: never navigate
# the page to a host, so they must not raise here.
_check_navigated_url("about:blank")
_check_navigated_url("mailto:someone@example.com")
_check_navigated_url("javascript:void(0)")
_check_navigated_url("file:///tmp/page.html")
with self.assertRaises(BrowserError):
_check_navigated_url("https://evil.com/")
+216
View File
@@ -0,0 +1,216 @@
"""Safe tenant-script upgrade tests (no Docker, no network).
The rule under test: only a PROVEN-unmodified tenant script
(manifest match, known-shipped-hash match, missing, or already
current) is auto-merged. A tenant-modified script is NEVER
overwritten: it stays byte-identical, is reported as skipped,
and gets exactly one deduped audit record. Reruns are idempotent.
No real credentials appear here.
"""
import hashlib
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import playwright_browser as pb # noqa: E402
from playwright_browser import ensure_codex_home_playwright # noqa: E402
NAMES = pb.PLAYWRIGHT_SCRIPT_NAMES
def _write_scripts(d, contents):
for name in NAMES:
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
fh.write(contents[name])
def _read_scripts(d):
out = {}
for name in NAMES:
with open(os.path.join(d, name), encoding="utf-8") as fh:
out[name] = fh.read()
return out
def _v1():
return {name: "# v1 %s\nprint('v1')\n" % name for name in NAMES}
def _v2():
return {name: "# v2 %s\nprint('v2')\n" % name for name in NAMES}
class UpgradeTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.src_v1 = os.path.join(self._tmp.name, "src-v1")
self.src_v2 = os.path.join(self._tmp.name, "src-v2")
self.home = os.path.join(self._tmp.name, "home")
os.makedirs(self.src_v1)
os.makedirs(self.src_v2)
os.makedirs(self.home)
_write_scripts(self.src_v1, _v1())
_write_scripts(self.src_v2, _v2())
def tearDown(self):
self._tmp.cleanup()
def _audit_records(self):
path = os.path.join(self.home, "playwright_scripts_audit.jsonl")
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
def test_fresh_seed_and_idempotent_rerun(self):
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(_read_scripts(self.home), _v2())
self.assertFalse(out["script_skipped_modified"])
self.assertEqual(self._audit_records(), [])
# Config section appended exactly once.
with open(os.path.join(self.home, "config.toml"),
encoding="utf-8") as fh:
config = fh.read()
self.assertEqual(config.count("[mcp_servers.playwright]"), 1)
out2 = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertFalse(out2["script_changed"])
self.assertFalse(out2["config_changed"])
self.assertFalse(out2["script_refreshed"])
self.assertEqual(_read_scripts(self.home), _v2())
with open(os.path.join(self.home, "config.toml"),
encoding="utf-8") as fh:
self.assertEqual(
fh.read().count("[mcp_servers.playwright]"), 1)
def test_unmodified_script_auto_merges(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
self.assertFalse(out["script_skipped_modified"])
self.assertEqual(_read_scripts(self.home), _v2())
manifest_path = os.path.join(
self.home, "playwright_scripts_manifest.json")
with open(manifest_path, encoding="utf-8") as fh:
manifest = json.load(fh)
for name in NAMES:
self.assertEqual(
manifest[name],
hashlib.sha256(_v2()[name].encode()).hexdigest())
def test_modified_script_untouched_with_audit(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
tenant_path = os.path.join(self.home, NAMES[0])
with open(tenant_path, "a", encoding="utf-8") as fh:
fh.write("# tenant tweak: custom timeout\n")
with open(tenant_path, encoding="utf-8") as fh:
tenant_bytes = fh.read()
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
self.assertEqual(out["script_differs"], [NAMES[0]])
# Modified file byte-identical; the other two auto-merged.
with open(tenant_path, encoding="utf-8") as fh:
self.assertEqual(fh.read(), tenant_bytes)
rest = _read_scripts(self.home)
for name in NAMES[1:]:
self.assertEqual(rest[name], _v2()[name])
records = self._audit_records()
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["action"], "skipped_modified")
self.assertEqual(records[0]["script"], NAMES[0])
self.assertEqual(
records[0]["dst_sha256"],
hashlib.sha256(tenant_bytes.encode()).hexdigest())
def test_modified_rerun_does_not_rewrite_or_duplicate_audit(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
tenant_path = os.path.join(self.home, NAMES[0])
with open(tenant_path, "a", encoding="utf-8") as fh:
fh.write("# tenant tweak\n")
with open(tenant_path, encoding="utf-8") as fh:
tenant_bytes = fh.read()
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertFalse(out["script_changed"])
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
self.assertEqual(len(self._audit_records()), 1)
with open(tenant_path, encoding="utf-8") as fh:
self.assertEqual(fh.read(), tenant_bytes)
# The manifest must NOT have learned the tenant hash:
# otherwise the next upgrade would mistake the modified
# file for untouched and overwrite it.
with open(os.path.join(
self.home, "playwright_scripts_manifest.json"),
encoding="utf-8") as fh:
manifest = json.load(fh)
self.assertNotEqual(
manifest.get(NAMES[0]),
hashlib.sha256(tenant_bytes.encode()).hexdigest())
def test_legacy_no_manifest_known_hash_merges(self):
# Legacy tenant: files seeded before manifests existed,
# content exactly a known shipped version -> proven
# unmodified, safe to merge even with no manifest.
old = {name: "# legacy %s\n" % name for name in NAMES}
_write_scripts(self.home, old)
patched = {name: hashlib.sha256(old[name].encode()).hexdigest()
for name in NAMES}
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
pb.KNOWN_SHIPPED_SCRIPT_HASHES = patched
try:
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
finally:
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
self.assertEqual(_read_scripts(self.home), _v2())
def test_legacy_no_manifest_unknown_hash_skips(self):
old = {name: "# tenant-custom %s\n" % name for name in NAMES}
_write_scripts(self.home, old)
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
pb.KNOWN_SHIPPED_SCRIPT_HASHES = {}
try:
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
finally:
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
self.assertEqual(set(out["script_skipped_modified"]), set(NAMES))
self.assertEqual(_read_scripts(self.home), old)
self.assertEqual(len(self._audit_records()), 3)
def test_upgrade_never_touches_tenant_data_or_config(self):
notes = os.path.join(self.home, "notes.txt")
with open(notes, "w", encoding="utf-8") as fh:
fh.write("tenant notes, must survive\n")
config = os.path.join(self.home, "config.toml")
with open(config, "w", encoding="utf-8") as fh:
fh.write('[user]\nname = "tenant"\n\n'
'[mcp_servers.playwright.env]\n'
'FOO = "bar"\n')
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
with open(notes, encoding="utf-8") as fh:
self.assertEqual(fh.read(), "tenant notes, must survive\n")
with open(config, encoding="utf-8") as fh:
text = fh.read()
# Existing sections intact, playwright section not
# duplicated (it was absent, so appended exactly once).
self.assertIn('[user]\nname = "tenant"', text)
self.assertIn('FOO = "bar"', text)
self.assertEqual(text.count("[mcp_servers.playwright]"), 1)
def test_known_shipped_hashes_are_well_formed(self):
self.assertEqual(set(pb.KNOWN_SHIPPED_SCRIPT_HASHES), set(NAMES))
for value in pb.KNOWN_SHIPPED_SCRIPT_HASHES.values():
self.assertEqual(len(value), 64)
int(value, 16) # must be hex
+530
View File
@@ -0,0 +1,530 @@
"""常驻监听任务单测(cloud/resident.py):不跑真实模型、不联网,
抓取一律注入假 fetcher。覆盖创建校验、暂停保游标、恢复续跑、
结束终态、防重复轮询、仅变化建任务、失败退避、配额耗尽转暂停、
worker 驱动批量入口。"""
import http.server
import os
import socket
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
import cloud.resident as resident_module
from cloud.resident import (
SSRFBlockedError,
claim_due_watch,
create_watch,
default_fetch,
end_watch,
ensure_resident_tables,
get_watch,
list_polls,
pause_watch,
process_due_resident_watches,
resume_watch,
run_due_resident_watch,
update_watch,
validate_target_url,
)
from cloud.server import init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import cloud.resident as resident_module
from cloud.resident import (
SSRFBlockedError,
claim_due_watch,
create_watch,
default_fetch,
end_watch,
ensure_resident_tables,
get_watch,
list_polls,
pause_watch,
process_due_resident_watches,
resume_watch,
run_due_resident_watch,
update_watch,
validate_target_url,
)
from cloud.server import init_db
from cloud.worker import Worker
URL = "https://example.com/data"
class _Fetcher:
"""可变假抓取:value 改了,下一次轮询就看到新内容。"""
def __init__(self, value="v1"):
self.value = value
self.calls = []
def __call__(self, url, timeout):
self.calls.append((url, timeout))
return self.value
def _failing_fetcher(url, timeout):
raise RuntimeError("boom")
class ResidentTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
ensure_resident_tables(self.conn)
self.now = 1700000000.0
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def _watch(self, name="watch1", **kw):
kw.setdefault("interval_seconds", 60)
return create_watch(self.conn, "proj", name, URL,
"变化时汇报", "owner1", now=self.now, **kw)
def _tasks(self):
cur = self.conn.execute("SELECT id, prompt, status FROM tasks")
return cur.fetchall()
def _set_quota(self, user_id, micros):
self.conn.execute(
"CREATE TABLE IF NOT EXISTS user_quotas("
"user_id TEXT PRIMARY KEY, quota_usd_micros BIGINT, "
" updated_at DOUBLE PRECISION)")
self.conn.execute(
"INSERT OR REPLACE INTO user_quotas(user_id,"
" quota_usd_micros, updated_at) VALUES(?, ?, ?)",
(user_id, micros, self.now))
self.conn.commit()
def test_create_validation(self):
watch = self._watch()
self.assertEqual(watch["status"], "running")
self.assertTrue(watch["due"])
self.assertEqual(watch["next_poll_at"], self.now)
for kwargs, _ in (
({"target_url": "ftp://example.com/x"}, None),
({"target_url": "https://localhost/x"}, None),
({"target_url": "http://127.0.0.1/x"}, None),
({"target_url": "https://10.0.0.5/x"}, None),
({"target_url": "https://user:pw@example.com/x"}, None),
({"interval_seconds": 30}, None),
({"name": "watch1"}, None),
({"prompt": " "}, None)):
with self.assertRaises(ValueError):
base = {"name": "other", "target_url": URL,
"prompt": "p", "interval_seconds": 60}
base.update(kwargs)
create_watch(self.conn, "proj", base["name"],
base["target_url"], base["prompt"], "owner1",
interval_seconds=base["interval_seconds"],
now=self.now)
def test_baseline_unchanged_changed_only_change_creates_task(self):
fetcher = _Fetcher("v1")
watch = self._watch()
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "baseline")
self.assertEqual(len(self._tasks()), 0)
# 同一间隔内第二次:抢不到,不重复轮询。
self.assertIsNone(run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now))
self.assertEqual(len(fetcher.calls), 1)
self.now += 61
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "unchanged")
self.assertEqual(len(self._tasks()), 0)
fetcher.value = "v2"
self.now += 61
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed")
self.assertIsNotNone(poll["task_id"])
tasks = self._tasks()
self.assertEqual(len(tasks), 1)
self.assertIn("v2", tasks[0][1])
self.assertIn("变化时汇报", tasks[0][1])
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["last_change_text"], "v2")
self.assertIsNotNone(after["last_change_at"])
self.assertEqual(
[p["status"] for p in list_polls(self.conn, watch["id"])],
["changed", "unchanged", "baseline"])
def test_pause_preserves_cursor_and_resume_reports_pause_period_change(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
cursor = get_watch(self.conn, watch["id"], now=self.now)
self.assertIsNotNone(cursor["last_content_hash"])
ok, paused = pause_watch(self.conn, watch["id"], now=self.now)
self.assertTrue(ok)
self.assertEqual(paused["status"], "paused")
self.assertEqual(paused["last_content_hash"],
cursor["last_content_hash"])
self.assertIsNone(paused["next_poll_at"])
self.assertFalse(paused["due"])
# 暂停期间即使时间过了间隔,驱动也不轮询。
self.assertIsNone(run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now + 3600))
self.assertEqual(len(fetcher.calls), 1)
# 暂停期间网站变了;恢复后第一次轮询用旧游标比对,报出来。
fetcher.value = "v2"
ok, resumed = resume_watch(self.conn, watch["id"], now=self.now)
self.assertTrue(ok)
self.assertEqual(resumed["status"], "running")
self.assertEqual(resumed["last_content_hash"],
cursor["last_content_hash"])
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed")
self.assertEqual(len(self._tasks()), 1)
def test_pause_resume_on_wrong_status_are_rejected(self):
watch = self._watch()
ok, _ = resume_watch(self.conn, watch["id"], now=self.now)
self.assertFalse(ok)
ok, _ = pause_watch(self.conn, watch["id"], now=self.now)
self.assertTrue(ok)
ok, _ = pause_watch(self.conn, watch["id"], now=self.now)
self.assertFalse(ok)
def test_end_is_terminal_and_name_reusable(self):
watch = self._watch()
ok, ended = end_watch(self.conn, watch["id"], now=self.now)
self.assertTrue(ok)
self.assertEqual(ended["status"], "ended")
self.assertIsNone(ended["next_poll_at"])
for fn in (pause_watch, resume_watch, end_watch):
ok, old = fn(self.conn, watch["id"], now=self.now)
self.assertFalse(ok)
self.assertEqual(old["status"], "ended")
self.assertIsNone(run_due_resident_watch(
self.conn, watch["id"], fetcher=_Fetcher(), now=self.now + 3600))
ok, _ = update_watch(self.conn, watch["id"],
{"prompt": "新提示"}, now=self.now)
self.assertFalse(ok)
# 已结束的名字可重用。
again = self._watch()
self.assertEqual(again["status"], "running")
def test_claim_is_single_winner(self):
watch = self._watch()
self.assertTrue(claim_due_watch(self.conn, watch["id"], now=self.now))
self.assertFalse(claim_due_watch(self.conn, watch["id"],
now=self.now))
# 下个间隔到了才能再抢(claim 已把 next_poll_at 预置到下间隔)。
self.assertTrue(claim_due_watch(self.conn, watch["id"],
now=self.now + 61))
def test_fetch_failure_backoff_and_cursor_unchanged(self):
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=_Fetcher("v1"), now=self.now)
cursor = get_watch(self.conn, watch["id"], now=self.now)
self.now += 61
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=_failing_fetcher, now=self.now)
self.assertEqual(poll["status"], "failed")
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["consecutive_failures"], 1)
self.assertEqual(after["last_content_hash"],
cursor["last_content_hash"])
# 失败退避:下次 = now + 间隔 + 30 秒,不是立刻重试。
self.assertEqual(after["next_poll_at"], self.now + 60 + 30)
self.now = after["next_poll_at"]
run_due_resident_watch(self.conn, watch["id"],
fetcher=_failing_fetcher, now=self.now)
after2 = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after2["consecutive_failures"], 2)
self.assertEqual(after2["next_poll_at"], self.now + 60 + 60)
def test_quota_exceeded_pauses_not_ends_and_keeps_cursor(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
cursor_hash = get_watch(self.conn, watch["id"],
now=self.now)["last_content_hash"]
self._set_quota("owner1", 0)
fetcher.value = "v2"
self.now += 61
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed_quota_paused")
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["status"], "paused")
self.assertEqual(after["paused_reason"], "quota_exceeded")
self.assertNotEqual(after["status"], "ended")
self.assertEqual(after["last_content_hash"], cursor_hash)
self.assertEqual(len(self._tasks()), 0)
# 恢复后配额仍耗尽:再次转暂停,不擅自结束、不丢变化。
resume_watch(self.conn, watch["id"], now=self.now)
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed_quota_paused")
# 配额恢复后,同一变化仍能报出来(游标没被配额暂停推进)。
self._set_quota("owner1", 100000000)
resume_watch(self.conn, watch["id"], now=self.now)
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed")
self.assertEqual(len(self._tasks()), 1)
def test_update_watch(self):
watch = self._watch()
ok, updated = update_watch(
self.conn, watch["id"],
{"interval_seconds": 3600, "prompt": "新处理提示"},
now=self.now)
self.assertTrue(ok)
self.assertEqual(updated["interval_seconds"], 3600)
self.assertEqual(updated["prompt"], "新处理提示")
with self.assertRaises(ValueError):
update_watch(self.conn, watch["id"], {}, now=self.now)
with self.assertRaises(ValueError):
update_watch(self.conn, watch["id"],
{"target_url": "https://localhost/x"}, now=self.now)
def test_process_due_and_worker_driver(self):
fetcher = _Fetcher("v1")
self._watch(name="a")
self._watch(name="b")
polls = process_due_resident_watches(self.conn, fetcher=fetcher,
now=self.now)
self.assertEqual(len(polls), 2)
self.assertEqual([p["status"] for p in polls],
["baseline", "baseline"])
self.assertEqual(process_due_resident_watches(
self.conn, fetcher=fetcher, now=self.now), [])
# worker 驱动入口:注入假抓取,变化时产出 changed 轮询。
worker = Worker(self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
runner=lambda cmd: SimpleNamespace(
returncode=0, stdout="{}", stderr=""))
fetcher.value = "v2"
self.now += 61
polls = worker.process_due_resident_watches(
now=self.now, fetcher=fetcher)
self.assertEqual(len(polls), 2)
self.assertTrue(all(p["status"] == "changed" for p in polls))
self.assertEqual(len(self._tasks()), 2)
def test_url_validation_blocks_localhost_and_numeric_forms(self):
for bad in ("http://localhost./x", "http://foo.localhost/x",
"http://2130706433/x", "http://0x7f000001/x",
"http://0177.0.0.1/x", "http://0x7f.0.0.1/x"):
with self.assertRaises(ValueError, msg=bad):
validate_target_url(bad)
# 规范点分十进制公网 IP 与带尾点的普通域名仍放行。
self.assertEqual(validate_target_url("https://8.8.8.8/x"),
"https://8.8.8.8/x")
self.assertEqual(validate_target_url("https://example.com./x"),
"https://example.com./x")
def test_unique_index_guards_concurrent_name_clash(self):
idx = self.conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'index'"
" AND name = 'idx_resident_watches_name'").fetchone()
self.assertIsNotNone(idx)
self._watch()
# 正常路径仍先报 ValueError。
with self.assertRaises(ValueError):
self._watch()
# 绕过 SELECT 查重直接插:唯一索引本身必须拦住,
# 这才是并发下真正的关口。
with self.assertRaises(sqlite3.IntegrityError):
self.conn.execute(
"INSERT INTO resident_watches(id, project_id, name,"
" target_url, prompt, interval_seconds, status,"
" created_by, created_at, updated_at)"
" VALUES('dup', 'proj', 'watch1', ?, 'p', 60,"
" 'running', 'owner1', ?, ?)",
(URL, self.now, self.now))
other = self._watch(name="watch2")
with self.assertRaises(ValueError):
update_watch(self.conn, other["id"], {"name": "watch1"},
now=self.now)
def test_quota_check_failed_is_transient_not_paused(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
cursor_hash = get_watch(self.conn, watch["id"],
now=self.now)["last_content_hash"]
original = resident_module._automation_quota_exceeded
resident_module._automation_quota_exceeded = lambda conn, owner: (
"quota_check_failed: boom")
try:
fetcher.value = "v2"
self.now += 61
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now)
finally:
resident_module._automation_quota_exceeded = original
self.assertEqual(poll["status"], "changed_quota_check_failed")
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["status"], "running")
self.assertEqual(after["last_content_hash"], cursor_hash)
self.assertEqual(after["consecutive_failures"], 1)
self.assertEqual(after["next_poll_at"], self.now + 60 + 30)
self.assertEqual(len(self._tasks()), 0)
def test_pause_during_fetch_aborts_without_task(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
cursor_hash = get_watch(self.conn, watch["id"],
now=self.now)["last_content_hash"]
db_path = self.db_path
def pausing_fetcher(url, timeout):
conn2 = sqlite3.connect(db_path)
try:
conn2.row_factory = sqlite3.Row
pause_watch(conn2, watch["id"], now=self.now + 61)
finally:
conn2.close()
return "v2"
self.now += 61
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=pausing_fetcher, now=self.now)
self.assertIsNone(poll)
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["status"], "paused")
self.assertIsNone(after["next_poll_at"])
self.assertEqual(after["last_content_hash"], cursor_hash)
self.assertEqual(len(self._tasks()), 0)
self.assertEqual(
[p["status"] for p in list_polls(self.conn, watch["id"])],
["baseline"])
class RedirectFetchTestCase(unittest.TestCase):
"""重定向与 DNS 门校验:默认解析器下 redirect-test.test
经 getaddrinfo 补丁指到本地(127.0.0.1),DNS 门必须拦
住它(私网解析结果);相对跳转的跟随逻辑走注入的测试
resolver(返回公网测试 IP,门正常放行)+ socket 层重定向
(测试进程内把该公网 IP:端口指回本地服务——门与 pin
逻辑全程真实跑,只有传输层终点是测试替身);no_proxy
同款处理,tearDown 全部还原。"""
HOST = "redirect-test.test"
def setUp(self):
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/go":
self.send_response(302)
self.send_header("Location", "/final")
elif self.path == "/bad":
self.send_response(302)
self.send_header(
"Location", "http://127.0.0.1:1/secret")
else:
body = b"final-body"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.end_headers()
def log_message(self, *args):
pass
self.server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), Handler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
self._orig_getaddrinfo = socket.getaddrinfo
def patched(host, port, family=0, type=0, proto=0, flags=0):
if host == self.HOST:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
("127.0.0.1", port))]
return self._orig_getaddrinfo(
host, port, family, type, proto, flags)
socket.getaddrinfo = patched
# socket 层测试替身:只重定向公网测试 IP 到本地服务端口,
# 其它地址一律走原始 create_connection。
self._orig_create_connection = socket.create_connection
test_port = self.port
def patched_create_connection(address, *args, **kwargs):
if address[0] == "93.184.216.34" \
and address[1] == test_port:
address = ("127.0.0.1", test_port)
return self._orig_create_connection(address, *args, **kwargs)
socket.create_connection = patched_create_connection
self._old_no_proxy = os.environ.get("no_proxy")
self._old_NO_PROXY = os.environ.get("NO_PROXY")
os.environ["no_proxy"] = (
(self._old_no_proxy + ",") if self._old_no_proxy
else "") + self.HOST
os.environ["NO_PROXY"] = (
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
else "") + self.HOST
def tearDown(self):
socket.create_connection = self._orig_create_connection
socket.getaddrinfo = self._orig_getaddrinfo
if self._old_no_proxy is None:
os.environ.pop("no_proxy", None)
else:
os.environ["no_proxy"] = self._old_no_proxy
if self._old_NO_PROXY is None:
os.environ.pop("NO_PROXY", None)
else:
os.environ["NO_PROXY"] = self._old_NO_PROXY
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
def test_relative_redirect_followed_and_private_blocked(self):
base = "http://%s:%d" % (self.HOST, self.port)
# 注入 resolver 的跟随路径:解析门放行公网测试 IP,
# socket 替身把建连指回本地,相对跳转 /go -> /final 走通。
def test_resolver(host, port):
return ["93.184.216.34"]
self.assertEqual(
default_fetch(base + "/go", 10, resolver=test_resolver),
"final-body")
# 默认 DNS 门:测试主机名解析到 127.0.0.1,直接拦住。
with self.assertRaises(SSRFBlockedError):
default_fetch(base + "/go", 10)
# 重定向跳本身:跳转目标是字面私网 IP,逐跳校验拦住。
with self.assertRaises(ValueError):
default_fetch(base + "/bad", 10, resolver=test_resolver)
# 混合解析结果:任一地址私网即整批拒绝,不取公网那个蒙混。
def mixed(host, port):
return ["93.184.216.34", "127.0.0.1"]
with self.assertRaises(ValueError):
default_fetch(base + "/go", 10, resolver=mixed)
if __name__ == "__main__":
unittest.main()
+684
View File
@@ -0,0 +1,684 @@
"""常驻监听 P0+P1 修复批单测(cloud/resident.py 等)。
覆盖审核报告的 P0(S1 DNS 级 SSRF + 摘录口径、S2 批量总预算、
G2 每日上限/抖动冷却/保留期)与 P1(S3 PG schema 与迁移清单、
G1 ensure 移出热路径 + 逐条隔离 + 死锁重试、G3 改目标重置基线、
G4 显式代理配置 + 结构化失败原因、G5 /metrics 口径)及 P2
建议项(502 固定错误码、_resident_get polls 异常记日志不静默)。
不跑真实模型、不连真实外网:真实抓取只对本地测试 HTTP
服务,DNS 门用 getaddrinfo 补丁或注入 resolver 验证;本地
PG 验证不在本文件(见 verification/resident-p0-fixes-summary.md
里的 /tmp 一次性库记录),本文件只做 SQLite 与覆盖断言。
"""
import http.client
import http.server
import json
import os
import socket
import sqlite3
import ssl
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
try:
import cloud.resident as resident_module
from cloud.auth import AuthStore
from cloud.db import PostgresOperationalError
from cloud.migrations import MIGRATION_TABLES, migration_coverage
from cloud.resident import (
FetchError,
HttpStatusFetchError,
SSRFBlockedError,
_PinnedHTTPSConnection,
classify_fetch_error,
create_watch,
default_fetch,
ensure_resident_tables,
get_watch,
list_polls,
prune_old_polls,
process_due_resident_watches,
resident_config,
resident_metrics,
resolve_public_addresses,
run_due_resident_watch,
update_watch,
)
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import cloud.resident as resident_module
from cloud.auth import AuthStore
from cloud.db import PostgresOperationalError
from cloud.migrations import MIGRATION_TABLES, migration_coverage
from cloud.resident import (
FetchError,
HttpStatusFetchError,
SSRFBlockedError,
_PinnedHTTPSConnection,
classify_fetch_error,
create_watch,
default_fetch,
ensure_resident_tables,
get_watch,
list_polls,
prune_old_polls,
process_due_resident_watches,
resident_config,
resident_metrics,
resolve_public_addresses,
run_due_resident_watch,
update_watch,
)
from cloud.server import create_server, init_db
from cloud.worker import Worker
URL = "https://example.com/data"
URL2 = "https://example.com/other"
class _Fetcher:
def __init__(self, value="v1"):
self.value = value
self.calls = []
def __call__(self, url, timeout):
self.calls.append((url, timeout))
return self.value
class ResidentP0TestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
ensure_resident_tables(self.conn)
self.now = 1700000000.0
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def _watch(self, name="watch1", url=URL, **kw):
kw.setdefault("interval_seconds", 60)
return create_watch(self.conn, "proj", name, url,
"变化时汇报", "owner1", now=self.now, **kw)
def _tasks(self):
return self.conn.execute(
"SELECT id, prompt, status FROM tasks").fetchall()
def _polls(self, watch_id):
return list_polls(self.conn, watch_id)
# -- S1:DNS 级 SSRF -------------------------------------------
def test_resolve_public_rejects_private_and_mixed(self):
# 单一私网解析结果即拦。
with self.assertRaises(SSRFBlockedError):
resolve_public_addresses(
"evil.test", 80,
resolver=lambda h, p: ["127.0.0.1"])
with self.assertRaises(SSRFBlockedError):
resolve_public_addresses(
"evil.test", 80, resolver=lambda h, p: ["::1"])
with self.assertRaises(SSRFBlockedError):
resolve_public_addresses(
"evil.test", 80,
resolver=lambda h, p: ["169.254.169.254"])
with self.assertRaises(SSRFBlockedError):
resolve_public_addresses(
"evil.test", 80,
resolver=lambda h, p: ["::ffff:127.0.0.1"])
# 混合结果:任一私网即整批拒绝,不取公网那个蒙混。
with self.assertRaises(SSRFBlockedError):
resolve_public_addresses(
"evil.test", 80,
resolver=lambda h, p: ["93.184.216.34", "10.0.0.5"])
# 全公网才放行。
self.assertEqual(
resolve_public_addresses(
"ok.test", 80, resolver=lambda h, p: ["93.184.216.34"]),
["93.184.216.34"])
# 解析失败按 dns,不放行。
with self.assertRaises(FetchError) as ctx:
resolve_public_addresses(
"nope.test", 80,
resolver=lambda h, p: (_ for _ in ()).throw(
socket.gaierror("no such host")))
self.assertEqual(ctx.exception.reason, "dns")
def test_ssrf_blocked_poll_has_no_excerpt_and_no_task(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
self.now += 61
def ssrf_fetcher(url, timeout):
raise SSRFBlockedError("resolved address is not public")
poll = run_due_resident_watch(self.conn, watch["id"],
fetcher=ssrf_fetcher, now=self.now)
self.assertEqual(poll["status"], "failed")
self.assertEqual(poll["failure_reason"], "ssrf_blocked")
self.assertTrue(poll["detail"].startswith("reason=ssrf_blocked"))
self.assertEqual(len(self._tasks()), 0)
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertIsNone(after["last_change_text"])
# 失败轮询的 detail 里绝不出现“内网响应正文”类内容。
self.assertNotIn("secret", poll["detail"])
def test_pinned_https_uses_hostname_for_sni_not_ip(self):
seen = {}
def fake_create_connection(address, timeout=None):
seen["address"] = address
return object()
class FakeContext:
def wrap_socket(self, sock, server_hostname=None):
seen["server_hostname"] = server_hostname
return object()
with mock.patch.object(socket, "create_connection",
fake_create_connection):
with mock.patch.object(ssl, "create_default_context",
lambda: FakeContext()):
conn = _PinnedHTTPSConnection(
"example.com", 443, ["93.184.216.34"], timeout=5)
conn.connect()
self.assertEqual(seen["address"], ("93.184.216.34", 443))
self.assertEqual(seen["server_hostname"], "example.com")
# -- S2:批量总预算 --------------------------------------------
def test_batch_budget_stops_batch_and_keeps_rest_due(self):
class FakeClock:
def __init__(self):
self.now = 0.0
def __call__(self):
return self.now
clock = FakeClock()
def slow_fetcher(url, timeout):
clock.now += 50.0
return "v1"
for name in ("a", "b", "c", "d", "e"):
self._watch(name=name)
errors = []
polls = process_due_resident_watches(
self.conn, fetcher=slow_fetcher, now=self.now,
budget_seconds=100, monotonic_fn=clock, errors=errors)
# 0 秒、50 秒处各跑一条,第 3 条开始前已满 100 秒即停。
self.assertEqual(len(polls), 2)
self.assertTrue(any(w is None and "budget" in m
for w, m in errors))
# 剩余 3 条仍是 due 状态,下批可跑,不丢。
clock2 = FakeClock()
polls2 = process_due_resident_watches(
self.conn, fetcher=slow_fetcher, now=self.now,
budget_seconds=10000, monotonic_fn=clock2)
self.assertEqual(len(polls2), 3)
# -- G1:逐条隔离 + 死锁重试 + 热路径零 DDL ---------------------
def test_batch_isolates_item_errors_and_retries_deadlock(self):
watch_a = self._watch(name="a")
watch_b = self._watch(name="b")
calls = []
def flaky(url, timeout):
return "v1"
original = resident_module.run_due_resident_watch
def patched(conn, watch_id, fetcher=None, now=None,
resolver=None):
calls.append(watch_id)
if watch_id == watch_a["id"]:
raise PostgresOperationalError("deadlock detected",
locked=True)
return original(conn, watch_id, fetcher=fetcher, now=now,
resolver=resolver)
errors = []
with mock.patch.object(resident_module,
"run_due_resident_watch", patched):
polls = process_due_resident_watches(
self.conn, fetcher=flaky, now=self.now, errors=errors)
self.assertEqual(len(polls), 1)
self.assertEqual(polls[0]["watch_id"], watch_b["id"])
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0][0], watch_a["id"])
# 死锁重试成功路径:第一次锁、第二次成功,errors 为空。
attempts = {"n": 0}
def retry_once(conn, watch_id, fetcher=None, now=None,
resolver=None):
if watch_id == watch_a["id"] and attempts["n"] == 0:
attempts["n"] += 1
raise PostgresOperationalError("deadlock detected",
locked=True)
return original(conn, watch_id, fetcher=fetcher, now=now,
resolver=resolver)
errors2 = []
with mock.patch.object(resident_module,
"run_due_resident_watch", retry_once):
polls2 = process_due_resident_watches(
self.conn, fetcher=flaky, now=self.now + 120,
errors=errors2)
self.assertEqual(errors2, [])
self.assertTrue(any(p["watch_id"] == watch_a["id"]
for p in polls2))
def test_hot_path_batch_executes_no_ddl(self):
class CountingConn:
def __init__(self, real):
self._real = real
self.statements = []
def execute(self, sql, params=()):
self.statements.append(sql)
return self._real.execute(sql, params)
def commit(self):
return self._real.commit()
def rollback(self):
return self._real.rollback()
self._watch(name="a")
self._watch(name="b")
counting = CountingConn(self.conn)
polls = process_due_resident_watches(
counting, fetcher=_Fetcher("v1"), now=self.now)
self.assertEqual(len(polls), 2)
ddl = [s for s in counting.statements
if s.strip().upper().startswith("CREATE")]
self.assertEqual(ddl, [])
# -- G2:每日上限 / 抖动冷却 / 保留期 ----------------------------
def test_daily_cap_and_cooldown_suppress_without_losing_change(self):
env = {
"CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS": "2",
"CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS": "300",
}
with mock.patch.dict(os.environ, env):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
# 第 1 次变化:建任务。
fetcher.value = "v2"
self.now += 61
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed")
self.assertEqual(len(self._tasks()), 1)
# 冷却期内再变:记 changed_cooldown,不建任务,游标不动。
fetcher.value = "v3"
self.now += 61
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed_cooldown")
self.assertEqual(len(self._tasks()), 1)
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["last_change_text"], "v2")
# 冷却过后:第 2 次变化建任务,达到上限 2。
self.now += 300
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed")
self.assertEqual(len(self._tasks()), 2)
# 再变:上限已满,记 changed_capped,不建任务,游标不动。
fetcher.value = "v4"
self.now += 361
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=fetcher, now=self.now)
self.assertEqual(poll["status"], "changed_capped")
self.assertEqual(len(self._tasks()), 2)
after = get_watch(self.conn, watch["id"], now=self.now)
self.assertEqual(after["last_change_text"], "v3")
def test_prune_old_polls_keeps_recent(self):
watch = self._watch()
old = self.now - 100 * 86400
self.conn.execute(
"INSERT INTO resident_polls(id, watch_id, project_id,"
" status, observed_hash, detail, task_id, created_at)"
" VALUES('old1', ?, 'proj', 'unchanged', 'h', '', NULL, ?)",
(watch["id"], old))
self.conn.execute(
"INSERT INTO resident_polls(id, watch_id, project_id,"
" status, observed_hash, detail, task_id, created_at)"
" VALUES('new1', ?, 'proj', 'unchanged', 'h', '', NULL, ?)",
(watch["id"], self.now))
self.conn.commit()
deleted = prune_old_polls(self.conn, now=self.now,
retention_days=90)
self.assertEqual(deleted, 1)
remaining = [p["id"] for p in self._polls(watch["id"])]
self.assertEqual(remaining, ["new1"])
# -- G3:改目标重置基线 ------------------------------------------
def test_update_target_resets_baseline_but_interval_does_not(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
before = get_watch(self.conn, watch["id"], now=self.now)
self.assertIsNotNone(before["last_content_hash"])
# 只改间隔:游标保留。
ok, updated = update_watch(
self.conn, watch["id"], {"interval_seconds": 3600},
now=self.now)
self.assertTrue(ok)
self.assertIsNotNone(updated["last_content_hash"])
# 改目标:游标清掉,下一轮只建基线、不建任务。
ok, updated = update_watch(
self.conn, watch["id"], {"target_url": URL2}, now=self.now)
self.assertTrue(ok)
self.assertIsNone(updated["last_content_hash"])
def url_fetcher(url, timeout):
return "new-site-content" if url == URL2 else "v1"
self.now += 61
poll = run_due_resident_watch(
self.conn, watch["id"], fetcher=url_fetcher, now=self.now)
self.assertEqual(poll["status"], "baseline")
self.assertEqual(len(self._tasks()), 0)
# -- G4:代理配置与结构化失败 -------------------------------------
def test_proxy_config_defaults_direct_and_env_opt_in(self):
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("CODEX_RESIDENT_PROXY", None)
os.environ.pop("http_proxy", None)
os.environ.pop("https_proxy", None)
cfg = resident_config()
self.assertEqual(cfg["proxy"]["mode"], "direct")
with mock.patch.dict(os.environ,
{"CODEX_RESIDENT_PROXY": "env",
"https_proxy": "http://proxy.test:8080"}):
cfg = resident_config()
self.assertEqual(cfg["proxy"]["mode"], "env")
with mock.patch.dict(
os.environ,
{"CODEX_RESIDENT_PROXY": "http://proxy.test:8080"}):
cfg = resident_config()
self.assertEqual(cfg["proxy"]["mode"], "url")
self.assertEqual(cfg["proxy"]["url"],
"http://proxy.test:8080")
with mock.patch.dict(os.environ,
{"CODEX_RESIDENT_PROXY": "ftp://x/"}):
with self.assertRaises(FetchError) as ctx:
resident_module.resolve_proxy_config()
self.assertEqual(ctx.exception.reason, "proxy")
# 默认值本身是拟定值(见摘要拟定清单),这里只断言
# 当前实现值,不把它写成用户已拍板。
self.assertEqual(resident_config()["max_daily_change_tasks"], 24)
self.assertEqual(
resident_config()["change_cooldown_seconds"], 300)
self.assertEqual(resident_config()["poll_retention_days"], 90)
self.assertEqual(resident_config()["batch_budget_seconds"], 120)
def test_classify_fetch_errors(self):
self.assertEqual(classify_fetch_error(
SSRFBlockedError("blocked")), "ssrf_blocked")
self.assertEqual(classify_fetch_error(
HttpStatusFetchError(404)), "http_status")
self.assertEqual(classify_fetch_error(socket.timeout("t")),
"timeout")
self.assertEqual(classify_fetch_error(
ConnectionRefusedError("refused")), "network")
self.assertEqual(classify_fetch_error(RuntimeError("boom")),
"unknown")
def test_http_status_fetch_raises_structured_error(self):
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = b"not here"
self.send_response(404)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0),
Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever,
daemon=True)
thread.start()
try:
# 单跳层真实 404(本地服务,绕过 DNS 门直接调单跳)。
status, _loc, _raw = resident_module._fetch_single_hop(
"http://127.0.0.1:%d/missing" % port, 10,
["127.0.0.1"], None)
self.assertEqual(status, 404)
# default_fetch 的状态分支:解析门放行公网 IP 后,
# 单跳返回 404 即抛结构化 http_status 错误(正文丢弃)。
with mock.patch.object(
resident_module, "_fetch_single_hop",
return_value=(404, None, b"not here")):
with self.assertRaises(HttpStatusFetchError) as ctx:
default_fetch(
"http://status.test/missing", 10,
resolver=lambda h, p: ["93.184.216.34"])
self.assertEqual(ctx.exception.reason, "http_status")
self.assertEqual(ctx.exception.status, 404)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
# -- S3:迁移覆盖(无 PG 也可断言)--------------------------------
def test_migration_coverage_includes_resident_tables(self):
coverage = migration_coverage()
self.assertEqual(coverage["missing_from_migration"], ())
self.assertIn("resident_watches", MIGRATION_TABLES)
self.assertIn("resident_polls", MIGRATION_TABLES)
self.assertIn("resident_watches", coverage["schema_tables"])
self.assertIn("resident_polls", coverage["schema_tables"])
# -- G5:metrics 口径 ---------------------------------------------
def test_resident_metrics_counts(self):
fetcher = _Fetcher("v1")
watch = self._watch()
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
fetcher.value = "v2"
self.now += 61
run_due_resident_watch(self.conn, watch["id"],
fetcher=fetcher, now=self.now)
stats = resident_metrics(self.conn, {"proj"}, now=self.now)
self.assertTrue(stats["available"])
self.assertEqual(stats["watches_by_status"], {"running": 1})
self.assertEqual(stats["polls_last_24h"], 2)
self.assertEqual(stats["changed_last_24h"], 1)
self.assertEqual(stats["change_tasks_last_24h"], 1)
self.assertEqual(stats["failed_last_24h"], 0)
# 到期积压口径:把 next_poll_at 推到过去,due_count 见涨。
later = self.now + 3600
stats2 = resident_metrics(self.conn, {"proj"}, now=later)
self.assertEqual(stats2["due_count"], 1)
self.assertGreater(stats2["max_overdue_seconds"], 0)
# 非成员项目不计入。
stats3 = resident_metrics(self.conn, {"other"}, now=self.now)
self.assertEqual(stats3["watches_by_status"], {})
class ResidentP0ServerTestCase(unittest.TestCase):
"""服务端侧:/metrics resident 段、502 固定错误码、polls
异常记日志不静默(响应形态仍为 polls=[] + note)。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201, self.project)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _url(self, suffix=""):
return "/projects/%s/resident-watches%s" % (
self.project["id"], suffix)
def test_metrics_contains_resident_section(self):
status, watch = self._req(
"POST", self._url(),
{"name": "w1", "target_url": URL, "prompt": "p"},
self.owner_key)
self.assertEqual(status, 201, watch)
status, body = self._req("GET", "/metrics", None, self.owner_key)
self.assertEqual(status, 200, body)
resident = body["resident"]
self.assertTrue(resident["available"])
self.assertEqual(resident["watches_by_status"], {"running": 1})
self.assertEqual(resident["due_count"], 1)
self.assertIn("max_daily_change_tasks", resident["config"])
def test_resident_502_uses_fixed_error_code(self):
import cloud.server as server_module
original = server_module._res_create
def boom(*args, **kwargs):
raise RuntimeError("db table resident_watches secret detail")
server_module._res_create = boom
try:
status, body = self._req(
"POST", self._url(),
{"name": "w1", "target_url": URL, "prompt": "p"},
self.owner_key)
finally:
server_module._res_create = original
self.assertEqual(status, 502)
self.assertEqual(body["error"], "resident_operation_failed")
self.assertNotIn("secret", json.dumps(body))
def test_resident_get_polls_failure_keeps_note(self):
status, watch = self._req(
"POST", self._url(),
{"name": "w1", "target_url": URL, "prompt": "p"},
self.owner_key)
self.assertEqual(status, 201, watch)
import cloud.server as server_module
original = server_module._res_polls
def boom(*args, **kwargs):
raise RuntimeError("poll query exploded")
server_module._res_polls = boom
try:
status, body = self._req(
"GET", self._url("/" + watch["id"]), None,
self.owner_key)
finally:
server_module._res_polls = original
self.assertEqual(status, 200, body)
self.assertEqual(body["polls"], [])
self.assertEqual(body["polls_note"], "polls unavailable")
class ResidentP0WorkerTestCase(unittest.TestCase):
"""worker 驱动:首次批量 ensure 一次、resolver 透传。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
ensure_resident_tables(self.conn)
self.now = 1700000000.0
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_worker_driver_passes_resolver_and_ensures_once(self):
create_watch(self.conn, "proj", "w1", URL, "p",
"owner1", interval_seconds=60, now=self.now)
worker = Worker(self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
runner=lambda cmd: SimpleNamespace(
returncode=0, stdout="{}", stderr=""))
seen = {}
fetcher_calls = []
def recording_fetch(url, timeout):
fetcher_calls.append(url)
return "v1"
def recording_resolver(host, port):
seen["host"] = host
return ["127.0.0.1"]
# worker 经 resident_fetcher/resident_resolver 注入点取用。
worker.resident_fetcher = recording_fetch
worker.resident_resolver = recording_resolver
polls = worker.process_due_resident_watches(now=self.now)
self.assertEqual(len(polls), 1)
self.assertEqual(polls[0]["status"], "baseline")
# fetcher 注入时 resolver 不经过 default_fetch,不必被调用;
# 这里只断言 worker 首批 ensure 标志已立、次批不再重复建表
# (标志位本身即“每 worker 一次”的证据)。
self.assertTrue(
getattr(worker, "_resident_tables_ensured", False))
self.assertEqual(fetcher_calls, [URL])
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
"""常驻监听服务端单测:POST/GET /projects/{id}/resident-watches、
PATCH、POST pause / resume / end。不跑真实模型、不联网。
骨架与 test_automations_server.py 同款(本地临时端口 + 临时
SQLite)。"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
URL = "https://example.com/data"
class ResidentServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user(
"outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _url(self, suffix=""):
return "/projects/%s/resident-watches%s" % (
self.project["id"], suffix)
def _create(self, **extra):
body = {"name": "watch1", "target_url": URL,
"prompt": "变化时汇报"}
body.update(extra)
return self._req("POST", self._url(), body, self.owner_key)
def _audit_actions(self):
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT action FROM audit_log WHERE project_id = ?",
(self.project["id"],)).fetchall()
finally:
conn.close()
return [r[0] for r in rows]
def test_create_list_get_and_validation(self):
status, watch = self._create(interval_seconds=3600)
self.assertEqual(status, 201, watch)
self.assertEqual(watch["status"], "running")
self.assertEqual(watch["target_url"], URL)
self.assertTrue(watch["due"])
self.assertIsNotNone(watch["next_poll_at"])
self.assertIn("resident_watch_created", self._audit_actions())
status, body = self._req("GET", self._url(), None, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["resident_watches"]), 1)
self.assertEqual(body["due_count"], 1)
status, body = self._req(
"GET", self._url("/" + watch["id"]), None, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["id"], watch["id"])
self.assertEqual(body["polls"], [])
for bad in (
{"name": "x", "target_url": "ftp://example.com/x",
"prompt": "p"},
{"name": "x", "target_url": "https://localhost/x",
"prompt": "p"},
{"name": "x", "target_url": URL, "prompt": "p",
"interval_seconds": 30},
{"name": "watch1", "target_url": URL, "prompt": "p"},
{"target_url": URL, "prompt": "p"},
{"name": "x", "prompt": "p"}):
status, _ = self._req("POST", self._url(), bad, self.owner_key)
self.assertEqual(status, 400, bad)
status, _ = self._req(
"GET", self._url("/nope"), None, self.owner_key)
self.assertEqual(status, 404)
def test_pause_resume_end_lifecycle_and_terminal_409(self):
status, watch = self._create()
self.assertEqual(status, 201)
wid = watch["id"]
status, body = self._req(
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "paused")
self.assertEqual(body["paused_reason"], "user_paused")
self.assertIsNone(body["next_poll_at"])
self.assertFalse(body["due"])
status, body = self._req(
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
self.assertEqual(status, 409)
status, body = self._req(
"POST", self._url("/%s/resume" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "running")
self.assertIsNone(body["paused_reason"])
self.assertTrue(body["due"])
status, body = self._req(
"POST", self._url("/%s/end" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "ended")
for action in ("pause", "resume", "end"):
status, body = self._req(
"POST", self._url("/%s/%s" % (wid, action)), {},
self.owner_key)
self.assertEqual(status, 409, action)
self.assertIn("terminal", body["error"])
actions = self._audit_actions()
self.assertIn("resident_watch_paused", actions)
self.assertIn("resident_watch_resumed", actions)
self.assertIn("resident_watch_ended", actions)
def test_update_and_outsider_forbidden(self):
status, watch = self._create()
self.assertEqual(status, 201)
wid = watch["id"]
status, body = self._req(
"PATCH", self._url("/" + wid),
{"interval_seconds": 7200}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["interval_seconds"], 7200)
status, _ = self._req(
"POST", self._url(),
{"name": "x", "target_url": URL, "prompt": "p"},
self.outsider_key)
self.assertIn(status, (401, 403, 404))
if __name__ == "__main__":
unittest.main()
+416
View File
@@ -0,0 +1,416 @@
"""常驻监听网站闭环端到端测试。
上一轮只验到注入假抓取的单测:真实抓取、真实 run_loop 轮次
边界、经服务端 API 的全链路(创建 -> 基线 -> 变化建任务 ->
暂停 -> 恢复 -> 失败退避 -> 结束)都没有跑过。本文件补这一环,
不改任何生产默认值、不改生产校验口径:
- 间隔仍是生产下限 60 秒;到期不是改间隔,而是给驱动传一个
更晚的 now(模拟时间流逝),与上一轮单测同款时间注入。
- 目标 URL 仍走生产校验:主机名 website-loop.test 不是 IP 字
面、不是 localhost,所以校验通过;抓取本身走真实
default_fetch(DNS 门、pin 连接、超时、1MB 截断),不注入
假 fetcher。注意:生产默认 DNS 门会把解析到 127.0.0.1 的
目标拦住,所以本文件用两段测试接缝:worker 的
resident_resolver 把该主机名解析成公网测试 IP(门正常放
行),测试进程内再在 socket 层把该 IP:内容端口重定向回
本地服务(传输层终点是测试替身,门与 pin 全程真实跑);
DNS 门本身由 test_resident_p0_fixes.py 的默认解析器用例
覆盖,不在本文件重复证明。
- 内容服务是线程内真实 http.server,可变内容、也可停服;
控制面是真实 ThreadingHTTPServer + 真实 SQLite 临时库;
变化建出的任务、轮询、审计都直接读库/接口核对。
不联网、不跑真实模型、不碰生产。
"""
import http.client
import http.server
import json
import os
import socket
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.resident import _content_hash
from cloud.server import create_server
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.resident import _content_hash
from cloud.server import create_server
from cloud.worker import Worker
HOST = "website-loop.test"
class WebsiteLoopTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201, self.project)
# 可变内容服务:state 字典跨重启保留,停服/重启可复现。
self.state = {"content": "version one"}
state = self.state
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = state["content"].encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
self._handler = Handler
self.content_server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), Handler)
self.content_port = self.content_server.server_address[1]
self.content_thread = threading.Thread(
target=self.content_server.serve_forever, daemon=True)
self.content_thread.start()
# 测试专用 DNS:只把 website-loop.test 指到本地服务,
# 其它主机一律走原始解析;生产校验与抓取代码一字不改。
self._orig_getaddrinfo = socket.getaddrinfo
def patched(host, port, family=0, type=0, proto=0, flags=0):
if host == HOST:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
("127.0.0.1", port))]
return self._orig_getaddrinfo(
host, port, family, type, proto, flags)
socket.getaddrinfo = patched
# 测试环境自带 egress 代理:urllib 默认会把这个测试主机名
# 也送代理并被断开。只给本测试主机名加 no_proxy(测试
# 进程内生效),生产抓取代码与代理行为一字不改。
self._old_no_proxy = os.environ.get("no_proxy")
self._old_NO_PROXY = os.environ.get("NO_PROXY")
os.environ["no_proxy"] = (
(self._old_no_proxy + ",") if self._old_no_proxy
else "") + HOST
os.environ["NO_PROXY"] = (
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
else "") + HOST
self.worker = Worker(
self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
runner=lambda cmd: SimpleNamespace(
returncode=0, stdout="{}", stderr=""))
# 两段测试接缝(见文件头说明):resolver 返回公网测试
# IP,socket 层把该 IP:内容端口重定向回本地服务。
try:
from cloud.resident import _default_resolver
except ImportError:
_default_resolver = None
def _loop_resolver(host, port, _default=_default_resolver):
if host == HOST:
return ["93.184.216.34"]
if _default is not None:
return _default(host, port)
return [socket.getaddrinfo(host, port)[0][4][0]]
self.worker.resident_resolver = _loop_resolver
self._orig_create_connection = socket.create_connection
def _patched_create_connection(address, *args, **kwargs):
if address[0] == "93.184.216.34" \
and address[1] == self.content_port:
address = ("127.0.0.1", self.content_port)
return self._orig_create_connection(address, *args,
**kwargs)
socket.create_connection = _patched_create_connection
def tearDown(self):
# setUp 中途失败时属性可能未建:getattr 防御,避免
# tearDown 的 AttributeError 掩盖原始失败。
if getattr(self, "_orig_getaddrinfo", None) is not None:
socket.getaddrinfo = self._orig_getaddrinfo
if getattr(self, "_orig_create_connection", None) is not None:
socket.create_connection = self._orig_create_connection
if getattr(self, "_old_no_proxy", None) is None:
if "_old_no_proxy" in self.__dict__:
os.environ.pop("no_proxy", None)
else:
os.environ["no_proxy"] = self._old_no_proxy
if getattr(self, "_old_NO_PROXY", None) is None:
if "_old_NO_PROXY" in self.__dict__:
os.environ.pop("NO_PROXY", None)
else:
os.environ["NO_PROXY"] = self._old_NO_PROXY
try:
self.content_server.shutdown()
self.content_server.server_close()
except Exception:
pass
try:
self.content_thread.join(timeout=5)
except Exception:
pass
try:
self.server.shutdown()
self.server.server_close()
except Exception:
pass
try:
self.thread.join(timeout=5)
except Exception:
pass
try:
self.auth.conn.close()
except Exception:
pass
try:
self.tmp.cleanup()
except Exception:
pass
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _url(self, suffix=""):
return "/projects/%s/resident-watches%s" % (
self.project["id"], suffix)
def _create_watch(self):
body = {"name": "site1",
"target_url": "http://%s:%d/data" % (
HOST, self.content_port),
"prompt": "变化时汇报",
"interval_seconds": 60}
status, watch = self._req("POST", self._url(), body,
self.owner_key)
self.assertEqual(status, 201, watch)
return watch
def _get_watch(self, watch_id):
status, body = self._req(
"GET", self._url("/" + watch_id), None, self.owner_key)
self.assertEqual(status, 200, body)
return body
def _tasks(self):
conn = sqlite3.connect(self.db_path)
try:
return conn.execute(
"SELECT id, prompt, status FROM tasks"
" ORDER BY created_at, id").fetchall()
finally:
conn.close()
def _audit(self):
conn = sqlite3.connect(self.db_path)
try:
return [r[0] for r in conn.execute(
"SELECT action FROM audit_log WHERE project_id = ?",
(self.project["id"],)).fetchall()]
finally:
conn.close()
def test_full_website_loop_end_to_end(self):
# 1. 创建:经服务端 API,running、游标为空、下次轮询在位。
watch = self._create_watch()
wid = watch["id"]
self.assertEqual(watch["status"], "running")
self.assertEqual(watch["interval_seconds"], 60)
self.assertEqual(watch["fetch_timeout_seconds"], 10)
self.assertIsNone(watch["last_content_hash"])
self.assertIsNotNone(watch["next_poll_at"])
self.assertIn("resident_watch_created", self._audit())
t0 = time.time()
# 2. 基线:真实抓取 version one,只建游标,不建任务。
polls = self.worker.process_due_resident_watches(now=t0 + 120)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "baseline")
self.assertEqual(
polls[0]["observed_hash"], _content_hash("version one"))
self.assertEqual(self._tasks(), [])
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version one"))
self.assertEqual(len(body["polls"]), 1)
self.assertEqual(body["polls"][0]["status"], "baseline")
# 3. 变化:内容改成 version two,到期轮询建出 queued 任务。
self.state["content"] = "version two"
polls = self.worker.process_due_resident_watches(now=t0 + 240)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "changed")
self.assertIsNotNone(polls[0]["task_id"])
tasks = self._tasks()
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0][0], polls[0]["task_id"])
self.assertEqual(tasks[0][2], "queued")
self.assertIn("变化时汇报", tasks[0][1])
self.assertIn("version two", tasks[0][1])
self.assertIn("resident_watch_change_task_created",
self._audit())
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version two"))
self.assertIn("version two", body["last_change_text"])
# 4. 暂停:暂停期间再改内容,驱动不轮询、游标不动。
status, body = self._req(
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "paused")
self.assertIsNone(body["next_poll_at"])
self.state["content"] = "version three"
polls = self.worker.process_due_resident_watches(now=t0 + 400)
self.assertEqual(polls, [])
body = self._get_watch(wid)
self.assertEqual(len(body["polls"]), 2)
self.assertEqual(body["last_content_hash"],
_content_hash("version two"))
# 5. 恢复:用旧游标比对,报出暂停期间的变化并建任务,
# 不是从头建基线。轮询时刻取 t0+540:距上次建任务
# (t0+240)已过 300 秒抖动冷却,否则新冷却语义会
# 正确地记 changed_cooldown 而不是 changed(冷却期
# 内的变化不丢,冷却过后复查仍可报,见 P0 批单测)。
status, body = self._req(
"POST", self._url("/%s/resume" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "running")
polls = self.worker.process_due_resident_watches(now=t0 + 540)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "changed")
tasks = self._tasks()
self.assertEqual(len(tasks), 2)
self.assertIn("version three", tasks[1][1])
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
# 6. 失败:停服后轮询记 failed、退避、不结束、游标不动;
# 恢复服务后内容未变,轮询记 unchanged、失败清零。
self.content_server.shutdown()
self.content_server.server_close()
self.content_thread.join(timeout=5)
polls = self.worker.process_due_resident_watches(now=t0 + 600)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "failed")
body = self._get_watch(wid)
self.assertEqual(body["status"], "running")
self.assertEqual(body["consecutive_failures"], 1)
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
self.assertGreater(body["next_poll_at"], t0 + 600 + 60)
self.content_server = http.server.ThreadingHTTPServer(
("127.0.0.1", self.content_port), self._handler)
self.content_thread = threading.Thread(
target=self.content_server.serve_forever, daemon=True)
self.content_thread.start()
polls = self.worker.process_due_resident_watches(now=t0 + 800)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "unchanged")
body = self._get_watch(wid)
self.assertEqual(body["consecutive_failures"], 0)
self.assertEqual(len(self._tasks()), 2)
# 7. 结束:终态,驱动不再轮询,改/停/恢复一律 409。
status, body = self._req(
"POST", self._url("/%s/end" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "ended")
self.assertIsNone(body["next_poll_at"])
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
polls = self.worker.process_due_resident_watches(now=t0 + 1000)
self.assertEqual(polls, [])
for action in ("pause", "resume", "end"):
status, body = self._req(
"POST", self._url("/%s/%s" % (wid, action)), {},
self.owner_key)
self.assertEqual(status, 409, action)
status, _ = self._req(
"PATCH", self._url("/" + wid), {"interval_seconds": 120},
self.owner_key)
self.assertEqual(status, 409)
body = self._get_watch(wid)
self.assertEqual(body["status"], "ended")
self.assertEqual(len(body["polls"]), 5)
def test_run_loop_boundary_calls_resident_driver(self):
# 8. 驱动接线:run_loop 轮次边界确实调到常驻驱动,
# 不只是测试直接调 process_due。run_all 等无关边界
# 在本测试里打桩,只留 resident 驱动走真实抓取。
watch = self._create_watch()
wid = watch["id"]
worker = self.worker
calls = []
original = Worker.process_due_resident_watches
def recording():
# 实例属性上的函数不会自动绑定 self,必须闭包捕获
# worker,不能写成 def recording(self)。
calls.append(time.time())
return original(worker)
worker.process_due_resident_watches = recording
worker.run_all = lambda: []
worker.cleanup_stale_containers = lambda: None
worker._write_heartbeat = lambda: None
worker.register = lambda: None
worker._process_push_due = lambda: None
worker.process_auto_memories = lambda: None
worker._reap_kill_requests = lambda *a, **k: None
worker.auto_resume_interrupted = lambda: None
worker._heartbeat_running_tasks = lambda: None
worker._registry_heartbeat = lambda: None
worker._code_changed = lambda: False
worker._code_signature_valid = lambda: True
worker.run_loop(interval=0.05, max_seconds=0.4)
self.assertGreaterEqual(len(calls), 1)
body = self._get_watch(wid)
self.assertEqual(len(body["polls"]), 1)
self.assertEqual(body["polls"][0]["status"], "baseline")
self.assertEqual(body["last_content_hash"],
_content_hash("version one"))
if __name__ == "__main__":
unittest.main()
+30 -9
View File
@@ -67,18 +67,38 @@ def _exa_body():
class ChainTestCase(unittest.TestCase): class ChainTestCase(unittest.TestCase):
def test_fallback_perplexity_to_exa_and_key_not_leaked(self): def test_default_auto_frozen_falls_back_searxng_to_duckduckgo_and_key_not_leaked(self):
# Paid-freeze update of the old perplexity -> exa fallback
# test: default auto no longer contains any paid backend,
# even when all four paid keys are set, so the fallback
# that still exists by default is searxng -> duckduckgo
# and no paid URL is ever touched. The old perplexity ->
# exa fallback is preserved, but only under
# WEBSEARCH_ENABLE_PAID=1; see test_websearch_paid_freeze.py.
html = (
'<a class="result__a" href="//duckduckgo.com/l/?uddg='
'https%3A%2F%2Fexample.com%2Fddg">DDG Title</a>'
'<a class="result__snippet">ddg snippet</a>')
http = FakeHttp([ http = FakeHttp([
("perplexity", {}, 500), ("searx", {}, 500),
("api.exa.ai", _exa_body(), 200), ("duckduckgo", html, 200),
]) ])
env = dict(_FAKE_KEYS)
env["SEARXNG_URL"] = "http://searx.test"
out = search("test query", provider="auto", out = search("test query", provider="auto",
env=dict(_FAKE_KEYS), http=http) env=env, http=http)
self.assertEqual(out["provider"], "exa") self.assertEqual(out["provider"], "duckduckgo")
self.assertEqual(out["results"][0]["url"], self.assertEqual(out["results"][0]["url"],
"https://example.com/exa") "https://example.com/ddg")
self.assertEqual(out["attempted"][0]["provider"], "perplexity") self.assertEqual(out["attempted"][0]["provider"], "searxng")
self.assertFalse(out["attempted"][0]["ok"]) self.assertFalse(out["attempted"][0]["ok"])
self.assertEqual(
[a["provider"] for a in out["attempted"]],
["searxng", "duckduckgo"])
for _method, url, _payload in http.seen:
for paid in ("perplexity", "exa.ai", "tavily",
"search.brave.com"):
self.assertNotIn(paid, url)
blob = json.dumps(out) blob = json.dumps(out)
for secret in _FAKE_KEYS.values(): for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, blob) self.assertNotIn(secret, blob)
@@ -203,9 +223,10 @@ class McpFramingTestCase(unittest.TestCase):
names = [t["name"] for t in resp["result"]["tools"]] names = [t["name"] for t in resp["result"]["tools"]]
self.assertEqual(names, ["web_search", "web_fetch"]) self.assertEqual(names, ["web_search", "web_fetch"])
def fake_search(query, limit=5, provider="auto"): def fake_search(query, limit=5, provider="auto",
scope="general"):
return {"provider": "fake", "results": [], "answer": None, return {"provider": "fake", "results": [], "answer": None,
"attempted": []} "attempted": [], "scope": scope}
resp = handle_request( resp = handle_request(
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
+280
View File
@@ -0,0 +1,280 @@
"""Paid-freeze tests: default auto is free-only, pin still works,
unfreeze is explicit (WEBSEARCH_ENABLE_PAID=1, exact value "1").
No network, no real keys: every HTTP call goes through a FakeHttp,
and the fake key values are deliberately distinctive so a leak into
an error string or a tool result would be caught by assertion.
"""
import json
import os
import socket
import sys
import unittest
import urllib.error
from pathlib import Path
from unittest import mock
try:
from cloud.websearch import (
DEFAULT_CHAIN, DEFAULT_FREE_CHAIN, HttpClient, WebSearchError,
available_providers, paid_search_enabled,
resolve_provider_chain, search)
from cloud.websearch_mcp import handle_request
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.websearch import (
DEFAULT_CHAIN, DEFAULT_FREE_CHAIN, HttpClient, WebSearchError,
available_providers, paid_search_enabled,
resolve_provider_chain, search)
from cloud.websearch_mcp import handle_request
_FAKE_KEYS = {
"PERPLEXITY_API_KEY": "pplx-SECRET-111",
"EXA_API_KEY": "exa-SECRET-222",
"TAVILY_API_KEY": "tvly-SECRET-333",
"BRAVE_API_KEY": "brave-SECRET-444",
}
_DDG_HTML = (
'<a class="result__a" href="//duckduckgo.com/l/?uddg='
'https%3A%2F%2Fexample.com%2Fddg">DDG Title</a>'
'<a class="result__snippet">ddg snippet</a>')
class FakeHttp:
"""post_json/get keyed by URL substring; failures raise like HTTP."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), "application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
def _searxng_body():
return {"results": [
{"title": "SearXNG Hit", "url": "https://example.com/sx",
"content": "searxng snippet"}]}
def _paid_env(**extra):
env = dict(_FAKE_KEYS)
env["SEARXNG_URL"] = "http://searx.test"
env.update(extra)
return env
def _paid_hosts_seen(http):
hosts = []
for _method, url, _payload in http.seen:
for paid in ("perplexity", "exa.ai", "tavily",
"search.brave.com"):
if paid in url:
hosts.append(paid)
return hosts
class FrozenDefaultTestCase(unittest.TestCase):
def test_default_auto_excludes_paid_even_with_all_keys(self):
env = _paid_env()
self.assertEqual(available_providers(env),
["searxng", "duckduckgo"])
self.assertEqual(resolve_provider_chain("auto", env),
["searxng", "duckduckgo"])
http = FakeHttp([("searx", _searxng_body(), 200)])
out = search("q", provider="auto", env=env, http=http)
self.assertEqual(out["provider"], "searxng")
self.assertEqual(_paid_hosts_seen(http), [])
def test_default_auto_without_searxng_url_is_duckduckgo_only(self):
env = dict(_FAKE_KEYS)
self.assertEqual(resolve_provider_chain("auto", env),
["duckduckgo"])
http = FakeHttp([("duckduckgo", _DDG_HTML, 200)])
out = search("q", provider="auto", env=env, http=http)
self.assertEqual(out["provider"], "duckduckgo")
self.assertEqual(_paid_hosts_seen(http), [])
def test_searxng_failure_never_falls_back_to_paid(self):
http = FakeHttp([("searx", {}, 500),
("duckduckgo", "<html>challenge</html>", 200)])
with self.assertRaises(WebSearchError) as ctx:
search("q", provider="auto", env=_paid_env(), http=http)
self.assertEqual(
[a["provider"] for a in ctx.exception.attempted],
["searxng", "duckduckgo"])
self.assertEqual(_paid_hosts_seen(http), [])
# No fabricated result: the error names both free stages.
self.assertIn("searxng", str(ctx.exception))
self.assertIn("duckduckgo", str(ctx.exception))
def test_duckduckgo_fallback_failure_reports_clearly(self):
# DuckDuckGo under the VM/host egress returns a 202-style
# robot-challenge page: the parser yields no results, which
# must surface as an honest "no results" failure for that
# stage, never as a fabricated hit.
http = FakeHttp([("searx", {}, 500),
("duckduckgo", "<html>anomaly</html>", 200)])
with self.assertRaises(WebSearchError) as ctx:
search("q", provider="auto", env=_paid_env(), http=http)
attempted = {a["provider"]: a for a in ctx.exception.attempted}
self.assertEqual(attempted["duckduckgo"]["error"], "no results")
self.assertFalse(attempted["duckduckgo"]["ok"])
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, str(ctx.exception))
class ExplicitPinTestCase(unittest.TestCase):
def test_explicit_pin_paid_still_works_while_frozen(self):
http = FakeHttp([
("api.tavily.com",
{"results": [{"title": "T", "url": "https://example.com/t",
"content": "tavily content"}]}, 200),
])
out = search("q", provider="tavily", env=_paid_env(), http=http)
self.assertEqual(out["provider"], "tavily")
self.assertEqual(out["results"][0]["snippet"], "tavily content")
def test_explicit_pin_paid_failure_does_not_fall_back(self):
http = FakeHttp([("perplexity", {}, 500),
("api.exa.ai", {"results": [
{"title": "E", "url": "https://example.com/e",
"text": "x"}]}, 200)])
with self.assertRaises(WebSearchError):
search("q", provider="perplexity", env=_paid_env(), http=http)
self.assertEqual(_paid_hosts_seen(http), ["perplexity"])
def test_frozen_default_applies_to_scope_code_too(self):
body = {"results": [
{"title": "GH", "url": "https://github.com/a/b",
"content": "code hit"},
{"title": "Other", "url": "https://example.com/other",
"content": "off-scope"}]}
http = FakeHttp([("searx", body, 200)])
out = search("q", provider="auto", env=_paid_env(),
http=http, scope="code")
self.assertEqual(out["provider"], "searxng")
self.assertEqual(
[r["url"] for r in out["results"]], ["https://github.com/a/b"])
self.assertEqual(_paid_hosts_seen(http), [])
class UnfreezeSwitchTestCase(unittest.TestCase):
def test_switch_off_by_default_and_non_1_values_do_not_unfreeze(self):
self.assertFalse(paid_search_enabled({}))
for value in ("0", "true", "yes", "", "on"):
env = _paid_env(WEBSEARCH_ENABLE_PAID=value)
self.assertFalse(paid_search_enabled(env), value)
self.assertEqual(resolve_provider_chain("auto", env),
["searxng", "duckduckgo"])
def test_switch_on_restores_legacy_chain_and_fallback(self):
env = _paid_env(WEBSEARCH_ENABLE_PAID="1")
self.assertTrue(paid_search_enabled(env))
self.assertEqual(resolve_provider_chain("auto", env),
list(DEFAULT_CHAIN))
# The pre-freeze perplexity -> exa fallback, preserved
# verbatim under the explicit switch (plus the no-leak
# assertion from the old default-chain test).
http = FakeHttp([
("perplexity", {}, 500),
("api.exa.ai", {"results": [
{"title": "Exa Hit", "url": "https://example.com/exa",
"text": "exa snippet", "score": 0.9}]}, 200),
])
out = search("test query", provider="auto", env=env, http=http)
self.assertEqual(out["provider"], "exa")
self.assertEqual(out["results"][0]["url"],
"https://example.com/exa")
self.assertEqual(out["attempted"][0]["provider"], "perplexity")
self.assertFalse(out["attempted"][0]["ok"])
blob = json.dumps(out)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, blob)
def test_constants_state_the_contract(self):
self.assertEqual(DEFAULT_FREE_CHAIN, ("searxng", "duckduckgo"))
self.assertEqual(
DEFAULT_CHAIN,
("perplexity", "exa", "tavily", "brave", "searxng",
"duckduckgo"))
class TimeoutClarityTestCase(unittest.TestCase):
def test_http_client_maps_timeout_to_explicit_error(self):
client = HttpClient(timeout=20)
with mock.patch("urllib.request.urlopen",
side_effect=socket.timeout("timed out")):
with self.assertRaises(WebSearchError) as ctx:
client.get("http://searx.test/search?q=x")
self.assertIn("timeout after 20s", str(ctx.exception))
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.URLError(
socket.timeout("timed out"))):
with self.assertRaises(WebSearchError) as ctx:
client.get("http://searx.test/search?q=x")
self.assertIn("timeout after 20s", str(ctx.exception))
def test_http_client_does_not_relabel_dns_failure_as_timeout(self):
client = HttpClient(timeout=20)
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.URLError(
"Temporary failure in name resolution")):
with self.assertRaises(WebSearchError) as ctx:
client.get("http://searx.test/search?q=x")
self.assertNotIn("timeout", str(ctx.exception))
self.assertIn("name resolution", str(ctx.exception))
def test_search_reports_timeout_as_error_not_no_results(self):
class TimeoutHttp(FakeHttp):
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
if "searx" in url:
raise WebSearchError(
"timeout after 20s waiting for HTTP response")
return self._route(url)
http = TimeoutHttp([("duckduckgo", _DDG_HTML, 200)])
out = search("q", provider="auto", env=_paid_env(), http=http)
self.assertEqual(out["provider"], "duckduckgo")
attempted = {a["provider"]: a for a in out["attempted"]}
self.assertIn("timeout after 20s",
attempted["searxng"]["error"])
self.assertNotEqual(attempted["searxng"]["error"], "no results")
class McpDescriptionTestCase(unittest.TestCase):
def test_tools_list_describes_frozen_default_and_unfreeze(self):
resp = handle_request(
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
tool = [t for t in resp["result"]["tools"]
if t["name"] == "web_search"][0]
desc = tool["description"]
self.assertIn("SearXNG", desc)
self.assertIn("DuckDuckGo", desc)
self.assertIn("WEBSEARCH_ENABLE_PAID=1", desc)
self.assertIn("paid", desc.lower())
if __name__ == "__main__":
unittest.main()
+292 -1
View File
@@ -5,6 +5,7 @@ egress check only reads the repo filter file.
""" """
import os import os
import re
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -123,10 +124,24 @@ class DockerEnvTestCase(unittest.TestCase):
self.assertIsNone(err) self.assertIsNone(err)
self.assertEqual(env.get("SEARXNG_URL"), "http://searx.diante.me") self.assertEqual(env.get("SEARXNG_URL"), "http://searx.diante.me")
def test_paid_unfreeze_switch_passes_into_container_env(self):
# The freeze switch must reach the MCP process (which reads
# only config.toml env, never worker env); without this
# passthrough, WEBSEARCH_ENABLE_PAID=1 on the host would
# silently leave default auto frozen inside containers.
from unittest import mock
with mock.patch.dict(os.environ,
{"WEBSEARCH_ENABLE_PAID": "1"}):
ok, _, _, env, err = self._docker_context()
self.assertTrue(ok)
self.assertIsNone(err)
self.assertEqual(env.get("WEBSEARCH_ENABLE_PAID"), "1")
def test_no_search_env_means_no_extra_env(self): def test_no_search_env_means_no_extra_env(self):
from unittest import mock from unittest import mock
with mock.patch.dict(os.environ, {}, clear=False): with mock.patch.dict(os.environ, {}, clear=False):
for k in ("SEARXNG_URL", "PERPLEXITY_API_KEY", "EXA_API_KEY", for k in ("SEARXNG_URL", "WEBSEARCH_ENABLE_PAID",
"PERPLEXITY_API_KEY", "EXA_API_KEY",
"TAVILY_API_KEY", "BRAVE_API_KEY"): "TAVILY_API_KEY", "BRAVE_API_KEY"):
os.environ.pop(k, None) os.environ.pop(k, None)
ok, _, _, env, err = self._docker_context() ok, _, _, env, err = self._docker_context()
@@ -160,6 +175,282 @@ class EgressFilterTestCase(unittest.TestCase):
for want in expected: for want in expected:
self.assertIn(want, entries, "missing filter entry: %s" % want) self.assertIn(want, entries, "missing filter entry: %s" % want)
def test_platform_browser_programming_domains(self):
lines = open(REPO_ROOT / "deploy" / "egress" /
"tinyproxy-filter").read().splitlines()
entries = [ln for ln in lines
if ln and not ln.startswith("#")]
expected = [
r"^\(.*\.\)*python\.org$",
r"^\(.*\.\)*nodejs\.org$",
r"^\(.*\.\)*go\.dev$",
r"^\(.*\.\)*pkg\.go\.dev$",
r"^\(.*\.\)*rust-lang\.org$",
r"^\(.*\.\)*developer\.mozilla\.org$",
r"^\(.*\.\)*crates\.io$",
r"^\(.*\.\)*stackoverflow\.com$",
r"^\(.*\.\)*cve\.mitre\.org$",
r"^\(.*\.\)*nvd\.nist\.gov$",
]
for want in expected:
self.assertIn(want, entries, "missing filter entry: %s" % want)
# Already-present programming domains are NOT duplicated.
for already in (r"^\(.*\.\)*github\.com$",
r"^\(.*\.\)*pypi\.org$",
r"^\(.*\.\)*npmjs\.org$"):
self.assertEqual(entries.count(already), 1,
"duplicated filter entry: %s" % already)
# Control-plane guard: searx stays exact-only, and no
# diante.me wildcard may appear (it would open api.diante.me).
self.assertIn(r"^searx\.diante\.me$", entries)
self.assertNotIn(r"^\(.*\.\)*diante\.me$", entries)
def test_platform_browser_filter_matching_semantics(self):
# Translate POSIX BASIC group syntax to Python regex and
# check the subdomain anchoring the comments promise.
lines = open(REPO_ROOT / "deploy" / "egress" /
"tinyproxy-filter").read().splitlines()
entries = [ln for ln in lines
if ln and not ln.startswith("#")]
def matches(domain):
for entry in entries:
py = entry.replace("\\(", "(").replace("\\)", ")")
if re.match(py, domain):
return True
return False
# Subdomain coverage via the apex rules.
self.assertTrue(matches("docs.python.org"))
self.assertTrue(matches("docs.rust-lang.org"))
self.assertTrue(matches("developer.mozilla.org"))
self.assertTrue(matches("pkg.go.dev"))
# Anchoring: lookalikes and suffix tricks must not match.
self.assertFalse(matches("evilpython.org"))
self.assertFalse(matches("python.org.evil.com"))
self.assertFalse(matches("notstackoverflow.com"))
# Fail-closed: control plane and out-of-scope categories
# (shopping, social, mail, video) open no rule.
self.assertFalse(matches("api.diante.me"))
self.assertFalse(matches("www.amazon.com"))
self.assertFalse(matches("www.youtube.com"))
self.assertFalse(matches("mail.google.com"))
class PlaywrightAllowlistWiringTestCase(unittest.TestCase):
"""BROWSER_ALLOWED_HOSTS: worker env -> MCP env wiring.
No Docker, no network, no real Chromium: _docker_task_context
runs against temp tenant dirs with a fake executor whose
ensure_network always succeeds, and the MCP-side enforcement
is checked through _allowed_hosts/_check_allowed_url (the same
entry points Browser.navigate uses), not a launched browser.
Values here are test placeholders, never real credentials.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.template_home = os.path.join(self.tmp.name, "tpl-home")
os.makedirs(self.template_home)
open(os.path.join(self.template_home, "config.toml"),
"w").write('[model_provider.xcmapi]\n'
'base_url="https://xcmapi.org/v1"\n')
self.template_repo = os.path.join(self.tmp.name, "tpl-repo")
os.makedirs(os.path.join(self.template_repo, ".git"))
open(os.path.join(self.template_repo, "sample.py"),
"w").write("def add(a, b):\n return a + b\n")
def tearDown(self):
self.tmp.cleanup()
def _worker(self):
from types import SimpleNamespace
fake_executor = SimpleNamespace(
ensure_network=lambda network: True)
return Worker(self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
tenants_root=os.path.join(self.tmp.name,
"tenants"),
tenant_template_home=self.template_home,
tenant_template_repo=self.template_repo,
tenant_isolation=True, playwright_mcp=True,
executor=fake_executor)
def _context(self, worker):
return worker._docker_task_context({"project_id": "projA"})
def _config(self, worker):
home = worker._tenant_dirs("projA")["codex_home"]
return open(os.path.join(home, "config.toml")).read()
def _env_block(self, config):
import json
block = {}
in_block = False
for line in config.splitlines():
stripped = line.strip()
if stripped == "[mcp_servers.playwright.env]":
in_block = True
continue
if in_block:
if stripped.startswith("["):
break
if "=" in stripped:
key, raw = stripped.split("=", 1)
block[key.strip()] = json.loads(raw.strip())
return block
def _drop_ambient(self):
# mock.patch.dict only overlays: drop any search/allowlist
# keys the ambient environment may carry so each case sees
# exactly the env it declares (pops are restored on exit).
for key in ("SEARXNG_URL", "SEARXNG_ENGINES",
"PERPLEXITY_API_KEY", "EXA_API_KEY",
"TAVILY_API_KEY", "BRAVE_API_KEY"):
os.environ.pop(key, None)
def test_switch_off_passes_no_variable(self):
from unittest import mock
worker = self._worker()
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self._drop_ambient()
ok, _, _, env, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
block = self._env_block(self._config(worker))
self.assertEqual(block.get("PLAYWRIGHT_BROWSERS_PATH"),
"/opt/ms-playwright")
self.assertNotIn("BROWSER_ALLOWED_HOSTS", block)
# The allowlist never reaches the container env either:
# the config.toml MCP env block is the only channel.
self.assertIsNone(env)
def test_switch_on_reaches_mcp_env(self):
from unittest import mock
worker = self._worker()
with mock.patch.dict(
os.environ,
{"BROWSER_ALLOWED_HOSTS": "example.com, .python.org"}):
self._drop_ambient()
ok, _, _, env, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
block = self._env_block(self._config(worker))
# Verbatim, not normalised here: normalisation happens
# at read time in _allowed_hosts.
self.assertEqual(block.get("BROWSER_ALLOWED_HOSTS"),
"example.com, .python.org")
self.assertEqual(block.get("PLAYWRIGHT_BROWSERS_PATH"),
"/opt/ms-playwright")
self.assertIsNone(env)
def test_empty_value_is_never_written(self):
from unittest import mock
worker = self._worker()
with mock.patch.dict(os.environ, {"BROWSER_ALLOWED_HOSTS": ""}):
ok, _, _, _, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
block = self._env_block(self._config(worker))
self.assertNotIn("BROWSER_ALLOWED_HOSTS", block)
def test_old_tenant_merge_is_idempotent(self):
from unittest import mock
worker = self._worker()
# Old tenant: seeded before the wiring existed, with a
# stale env block and a section after it.
home = worker._tenant_dirs("projA")["codex_home"]
config_path = os.path.join(home, "config.toml")
with open(config_path, "a", encoding="utf-8") as fh:
fh.write("\n[mcp_servers.playwright.env]\n"
'BROWSER_ALLOWED_HOSTS = "stale.example"\n'
"\n[other]\nkey = 1\n")
with mock.patch.dict(
os.environ,
{"BROWSER_ALLOWED_HOSTS": "example.com"}):
ok, _, _, _, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
config = open(config_path).read()
self.assertEqual(config.count("[mcp_servers.playwright.env]"), 1)
self.assertIn("[other]\nkey = 1\n", config)
self.assertEqual(
self._env_block(config).get("BROWSER_ALLOWED_HOSTS"),
"example.com")
# Second prep with the same env rewrites nothing.
before = open(config_path, "rb").read()
mtime_before = os.stat(config_path).st_mtime_ns
with mock.patch.dict(
os.environ,
{"BROWSER_ALLOWED_HOSTS": "example.com"}):
ok, _, _, _, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
self.assertEqual(open(config_path, "rb").read(), before)
self.assertEqual(os.stat(config_path).st_mtime_ns, mtime_before)
def test_ensure_playwright_mcp_env_noop_when_identical(self):
from cloud.playwright_browser import ensure_playwright_mcp_env
home = os.path.join(self.tmp.name, "home")
os.makedirs(home)
config_path = os.path.join(home, "config.toml")
open(config_path, "w").write(
'[model_provider.xcmapi]\n'
'base_url="https://xcmapi.org/v1"\n')
env = {"PLAYWRIGHT_BROWSERS_PATH": "/opt/ms-playwright",
"BROWSER_ALLOWED_HOSTS": "example.com"}
self.assertTrue(ensure_playwright_mcp_env(home, env))
before = open(config_path, "rb").read()
mtime_before = os.stat(config_path).st_mtime_ns
self.assertFalse(ensure_playwright_mcp_env(home, env))
self.assertEqual(open(config_path, "rb").read(), before)
self.assertEqual(os.stat(config_path).st_mtime_ns, mtime_before)
# A changed value still rewrites, in place, once.
env2 = dict(env, BROWSER_ALLOWED_HOSTS="other.example")
self.assertTrue(ensure_playwright_mcp_env(home, env2))
config = open(config_path).read()
self.assertEqual(config.count("[mcp_servers.playwright.env]"), 1)
self.assertEqual(
self._env_block(config).get("BROWSER_ALLOWED_HOSTS"),
"other.example")
def test_wiring_semantics_allowlist_only_after_wiring(self):
from unittest import mock
from cloud.playwright_browser import (
BrowserError, _allowed_hosts, _check_allowed_url)
worker = self._worker()
# Switch off: the MCP env has no variable, so no limit.
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self._drop_ambient()
ok, _, _, _, err = self._context(worker)
self.assertTrue(ok)
self.assertIsNone(err)
block = self._env_block(self._config(worker))
self.assertNotIn("BROWSER_ALLOWED_HOSTS", block)
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self.assertIsNone(_allowed_hosts())
_check_allowed_url("https://anything.example")
# Switch on: the written block, applied as MCP process
# env, makes the allowlist enforce.
with mock.patch.dict(
os.environ,
{"BROWSER_ALLOWED_HOSTS": "example.com, .python.org"}):
ok, _, _, _, err = self._context(worker)
self.assertTrue(ok)
block = self._env_block(self._config(worker))
self.assertIn("BROWSER_ALLOWED_HOSTS", block)
with mock.patch.dict(os.environ, block):
self.assertEqual(_allowed_hosts(),
["example.com", ".python.org"])
_check_allowed_url("https://docs.python.org")
with self.assertRaises(BrowserError):
_check_allowed_url("https://evil.com")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+459 -27
View File
@@ -21,6 +21,16 @@ Design notes (what is copied from oh-my-pi, and what is not):
here is a small stdlib HTML parser: script/style stripped, text here is a small stdlib HTML parser: script/style stripped, text
collapsed, long pages truncated head+tail like Hermes' deterministic collapsed, long pages truncated head+tail like Hermes' deterministic
budget (default 15000 chars, 75% head / 25% tail, ``[TRUNCATED]``). budget (default 15000 chars, 75% head / 25% tail, ``[TRUNCATED]``).
- FROZEN default chain (paid freeze): the default ``auto`` chain
contains only the free backends, SearXNG then DuckDuckGo, even
when paid keys happen to be set -- a SearXNG failure never falls
back to a paid backend, so default use can never incur paid cost.
The four paid backends (Perplexity, Exa, Tavily, Brave) keep their
code, tests and docs, and still run when explicitly pinned
(provider="tavily" etc.), which is a deliberate override, not a
default. Only WEBSEARCH_ENABLE_PAID=1 (exact value "1", default
off) restores the old default chain; unfreezing is an explicit act
and may incur paid cost, never a silent default.
Security notes: Security notes:
- API keys come only from the process environment, never from - API keys come only from the process environment, never from
@@ -32,6 +42,16 @@ Security notes:
pretend otherwise. It does refuse non-http(s) URLs and literal pretend otherwise. It does refuse non-http(s) URLs and literal
localhost / private-IP URLs, which stops the most trivial SSRF localhost / private-IP URLs, which stops the most trivial SSRF
accidents, but it is not a sandbox. accidents, but it is not a sandbox.
Code scope (``search(..., scope="code")``):
- Only CODE_SEARCH_DOMAINS results survive, via native provider
filters where they exist (Perplexity search_domain_filter,
Exa/Tavily include_domains) plus post-filtering for every provider.
Default scope="general" is unchanged.
- Fetch chain (``fetch_best``, the web_fetch tool path): Tavily
extract, then Exa contents, then direct ``fetch_url``. Direct
fetch 403ing under the egress whitelist is fail-closed and is
reported, never bypassed.
""" """
import html as _html import html as _html
@@ -47,19 +67,143 @@ from html.parser import HTMLParser
__all__ = [ __all__ = [
"DEFAULT_CHAIN", "DEFAULT_CHAIN",
"DEFAULT_FREE_CHAIN",
"PAID_PROVIDERS",
"paid_search_enabled",
"CODE_SEARCH_DOMAINS",
"WebSearchError", "WebSearchError",
"SearchResult", "SearchResult",
"available_providers", "available_providers",
"resolve_provider_chain", "resolve_provider_chain",
"search", "search",
"fetch_url", "fetch_url",
"fetch_via_provider",
"fetch_best",
"ensure_codex_home_websearch", "ensure_codex_home_websearch",
"DEFAULT_FETCH_CHAR_LIMIT", "DEFAULT_FETCH_CHAR_LIMIT",
] ]
DEFAULT_CHAIN = ("perplexity", "exa", "tavily", "brave", "searxng", DEFAULT_CHAIN = ("perplexity", "exa", "tavily", "brave", "searxng",
"duckduckgo") "duckduckgo")
# Paid backends, frozen out of the default auto chain (see the module
# docstring). DEFAULT_CHAIN keeps the legacy full order so the
# unfrozen chain is exactly the old one; DEFAULT_FREE_CHAIN is what
# default auto actually walks while frozen.
PAID_PROVIDERS = ("perplexity", "exa", "tavily", "brave")
DEFAULT_FREE_CHAIN = ("searxng", "duckduckgo")
DEFAULT_FETCH_CHAR_LIMIT = 15000 DEFAULT_FETCH_CHAR_LIMIT = 15000
# Programming-only search scope (``search(..., scope="code")``).
# Fixed allowlist, deliberately closed: a result is kept only when its
# host is one of these domains or a subdomain of one (dot-boundary
# match, so evilgithub.com never matches github.com). Release notes
# and changelogs are in scope ONLY when hosted on a listed domain
# (e.g. a project's GitHub releases page); there is no generic
# "project official domain" detector, and inventing one would silently
# reopen the scope to arbitrary sites. The list has 15 entries, under
# Perplexity's search_domain_filter cap of 20.
CODE_SEARCH_DOMAINS = (
# Official language / platform docs.
"docs.python.org", "nodejs.org", "go.dev", "pkg.go.dev",
"docs.rs", "doc.rust-lang.org", "developer.mozilla.org",
# Code hosting (githubusercontent.com is GitHub's raw host).
"github.com", "githubusercontent.com",
# Package registries.
"pypi.org", "npmjs.com", "crates.io",
# Q&A and security advisories.
"stackoverflow.com", "nvd.nist.gov", "cve.org",
)
def _is_code_domain(url, domains=None):
"""True when url's host is in (or under) ``domains``.
Defaults to CODE_SEARCH_DOMAINS. Matching is dot-boundary:
the host must equal a listed domain or end with "." + domain,
so evilgithub.com never matches github.com and
github.com.evil.com never matches either.
"""
if domains is None:
domains = CODE_SEARCH_DOMAINS
try:
host = (urllib.parse.urlparse(url).hostname or "").lower()
except Exception:
return False
host = host.rstrip(".")
for domain in domains:
if host == domain or host.endswith("." + domain):
return True
return False
def _normalize_allowed_hosts(allowed_hosts):
"""Validate a task-level code-scope extension list.
Returns the normalised extra domains (lowercased, trailing dot
stripped, builtin duplicates dropped, order preserved). Anything
else raises ValueError before any HTTP happens -- never silently
degrading to the builtin list or to an unscoped search:
- None is not valid here; callers pass it through only by
leaving ``search(..., allowed_hosts=None)`` unset.
- a bare string is rejected (it is iterable, so accepting it
would silently treat "example.com" as a list of characters);
- an empty list is rejected (empty means "no extension", which
is spelled by omitting the argument, not by passing []);
- each entry must be a bare hostname: no scheme, port, path,
userinfo, wildcard, whitespace, leading dot or empty label.
Extension values are never written to logs: this function only
validates, and search() never formats the list into an error
string or an attempted entry (the effective list is echoed
back to the caller in its own result's ``code_domains`` only).
"""
if isinstance(allowed_hosts, str) or not isinstance(
allowed_hosts, (list, tuple)):
raise ValueError(
"allowed_hosts must be a list of hostnames, not %r"
% (type(allowed_hosts).__name__,))
if not allowed_hosts:
raise ValueError(
"allowed_hosts must be non-empty; omit the argument "
"for no extension")
seen = set()
out = []
for entry in allowed_hosts:
if not isinstance(entry, str):
raise ValueError(
"allowed_hosts entries must be strings, not %r"
% (type(entry).__name__,))
host = entry.strip().lower().rstrip(".")
if (not host or host.startswith(".") or ".." in host
or any(c in host for c in "/:?#@[] \t")
or "*" in host or host in CODE_SEARCH_DOMAINS):
if host in CODE_SEARCH_DOMAINS:
continue
raise ValueError(
"invalid allowed_hosts entry: %r (expected a bare "
"hostname such as 'example.com')" % (entry,))
if host not in seen:
seen.add(host)
out.append(host)
if not out:
raise ValueError(
"allowed_hosts adds no new domain beyond the builtin "
"list; omit the argument for no extension")
return out
def _normalize_scope(scope):
"""Map scope to "general" (default) or "code"; reject anything else.
Unknown scopes raise before any HTTP happens, so a typo can never
silently degrade to an unscoped search.
"""
if scope is None or str(scope).strip() in ("", "general", "all"):
return "general"
if str(scope).strip() == "code":
return "code"
raise ValueError("unknown search scope: %r (expected 'general' "
"or 'code')" % (scope,))
_USER_AGENT = "codex-websearch-mcp/0.1 (+codex-app-server)" _USER_AGENT = "codex-websearch-mcp/0.1 (+codex-app-server)"
_KEY_ENV = { _KEY_ENV = {
@@ -121,6 +265,27 @@ class HttpClient:
raise WebSearchError( raise WebSearchError(
"HTTP %s" % exc.code) from _ProviderHttpError( "HTTP %s" % exc.code) from _ProviderHttpError(
exc.code, body) exc.code, body)
except (socket.timeout, TimeoutError) as exc:
# A timeout is a timeout, never "no results": the caller
# (search's attempted entry, named by provider) must be
# able to tell which stage timed out, and a timed-out
# SearXNG must not be rewritten as an empty answer.
# HTTPError is a URLError subclass, so this branch must
# stay after the HTTPError branch above.
raise WebSearchError(
"timeout after %ss waiting for HTTP response"
% self.timeout) from exc
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", None)
if isinstance(reason, (socket.timeout, TimeoutError)):
raise WebSearchError(
"timeout after %ss waiting for HTTP response"
% self.timeout) from exc
# DNS / connection failures keep their own message;
# they are not timeouts and must not be relabelled.
raise WebSearchError(
"URL error: %s" % reason if reason else "URL error"
) from exc
def get_text(self, url, headers=None): def get_text(self, url, headers=None):
status, body, _ctype = self.get(url, headers) status, body, _ctype = self.get(url, headers)
@@ -175,16 +340,30 @@ def _clamp_limit(limit, default=5, lo=1, hi=10):
# ------------------------------------------------------------ providers # ------------------------------------------------------------ providers
def _provider_perplexity(query, limit, env, http): def _provider_perplexity(query, limit, env, http, domains=None):
# Sonar is a synthesis API: one OpenAI-shaped chat call returns a # Sonar is a synthesis API: one OpenAI-shaped chat call returns a
# written, cited answer. We surface both the answer and the # written, cited answer. We surface both the answer and the
# underlying sources, so web_search keeps its "answer + citations" # underlying sources, so web_search keeps its "answer + citations"
# shape instead of degrading to a link list. # shape instead of degrading to a link list.
# Native domain filter: top-level search_domain_filter (Sonar chat
# completions; allowlist mode, max 20 domains -- CODE_SEARCH_DOMAINS
# has 15, so the builtin list fits whole; see the over-cap
# handling below for task-level extensions).
payload = {"model": "sonar",
"messages": [{"role": "user", "content": query}]}
if domains:
# search_domain_filter caps at 20 domains. The builtin list
# is 15, but a task-level allowed_hosts extension can push
# the effective list past 20; sending an over-cap filter
# would fail the provider call, so in that case the native
# filter is skipped and search()'s post-filter -- which
# always applies -- remains the authoritative scope.
if len(domains) <= 20:
payload["search_domain_filter"] = list(domains)
_status, body, _ctype = http.post_json( _status, body, _ctype = http.post_json(
"https://api.perplexity.ai/chat/completions", "https://api.perplexity.ai/chat/completions",
_default_headers(env, "perplexity"), _default_headers(env, "perplexity"),
{"model": "sonar", payload)
"messages": [{"role": "user", "content": query}]})
data = json.loads(body.decode("utf-8", errors="replace")) data = json.loads(body.decode("utf-8", errors="replace"))
choices = data.get("choices") or [] choices = data.get("choices") or []
message = (choices[0].get("message") or {}) if choices else {} message = (choices[0].get("message") or {}) if choices else {}
@@ -202,13 +381,17 @@ def _provider_perplexity(query, limit, env, http):
return results, answer return results, answer
def _provider_exa(query, limit, env, http): def _provider_exa(query, limit, env, http, domains=None):
payload = {"query": query, "numResults": limit, "type": "auto",
"contents": {"text": {"maxCharacters": 2000},
"highlights": True}}
if domains:
# Native domain filter.
payload["include_domains"] = list(domains)
_status, body, _ctype = http.post_json( _status, body, _ctype = http.post_json(
"https://api.exa.ai/search", "https://api.exa.ai/search",
_default_headers(env, "exa"), _default_headers(env, "exa"),
{"query": query, "numResults": limit, "type": "auto", payload)
"contents": {"text": {"maxCharacters": 2000},
"highlights": True}})
data = json.loads(body.decode("utf-8", errors="replace")) data = json.loads(body.decode("utf-8", errors="replace"))
results = [] results = []
for item in data.get("results") or []: for item in data.get("results") or []:
@@ -226,13 +409,17 @@ def _provider_exa(query, limit, env, http):
return results, None return results, None
def _provider_tavily(query, limit, env, http): def _provider_tavily(query, limit, env, http, domains=None):
payload = {"query": query, "search_depth": "basic",
"max_results": limit, "include_answer": False,
"include_raw_content": False}
if domains:
# Native domain filter (max 300 domains).
payload["include_domains"] = list(domains)
_status, body, _ctype = http.post_json( _status, body, _ctype = http.post_json(
"https://api.tavily.com/search", "https://api.tavily.com/search",
_default_headers(env, "tavily"), _default_headers(env, "tavily"),
{"query": query, "search_depth": "basic", payload)
"max_results": limit, "include_answer": False,
"include_raw_content": False})
data = json.loads(body.decode("utf-8", errors="replace")) data = json.loads(body.decode("utf-8", errors="replace"))
results = [] results = []
for item in data.get("results") or []: for item in data.get("results") or []:
@@ -245,7 +432,12 @@ def _provider_tavily(query, limit, env, http):
return results, data.get("answer") or None return results, data.get("answer") or None
def _provider_brave(query, limit, env, http): def _provider_brave(query, limit, env, http, domains=None):
# No native include_domains on Brave's web search endpoint (its
# site: query operator is not used for a 15-domain allowlist:
# multi-domain site: semantics are unproven, and a wrong rewrite
# would silently change the query). Code-scope filtering for
# Brave happens after the call, in search().
url = ("https://api.search.brave.com/res/v1/web/search?" url = ("https://api.search.brave.com/res/v1/web/search?"
+ urllib.parse.urlencode( + urllib.parse.urlencode(
{"q": query, "count": limit, "extra_snippets": "true"})) {"q": query, "count": limit, "extra_snippets": "true"}))
@@ -263,7 +455,10 @@ def _provider_brave(query, limit, env, http):
return results, None return results, None
def _provider_searxng(query, limit, env, http): def _provider_searxng(query, limit, env, http, domains=None):
# No native include_domains is passed: SearXNG's site: handling
# depends on the pinned engines and is not relied on. Code-scope
# filtering happens after the call, in search().
base = (env.get("SEARXNG_URL") or "").rstrip("/") base = (env.get("SEARXNG_URL") or "").rstrip("/")
if not base: if not base:
raise WebSearchError("searxng not configured (SEARXNG_URL unset)") raise WebSearchError("searxng not configured (SEARXNG_URL unset)")
@@ -352,7 +547,9 @@ def _unwrap_ddg(href):
return href return href
def _provider_duckduckgo(query, limit, env, http): def _provider_duckduckgo(query, limit, env, http, domains=None):
# No native domain filter; code-scope filtering happens after
# the call, in search().
url = ("https://html.duckduckgo.com/html/?" url = ("https://html.duckduckgo.com/html/?"
+ urllib.parse.urlencode({"q": query})) + urllib.parse.urlencode({"q": query}))
_status, body, _ctype = http.get( _status, body, _ctype = http.get(
@@ -377,11 +574,32 @@ _PROVIDERS = {
} }
def available_providers(env=None): def paid_search_enabled(env=None):
"""Providers usable right now: keyed ones with a key, plus free ones.""" """True only when the paid-unfreeze switch is explicitly on.
The switch is WEBSEARCH_ENABLE_PAID, and only the exact value
"1" counts (same strict convention as WEBSEARCH_ALLOW_PRIVATE_URLS
elsewhere in this module): any other value, including "true" or
"yes", leaves the paid backends frozen out of default auto.
Explicit pinning is unaffected by this switch either way.
"""
env = env if env is not None else os.environ env = env if env is not None else os.environ
return env.get("WEBSEARCH_ENABLE_PAID") == "1"
def available_providers(env=None):
"""Providers usable right now, under the paid-freeze rule.
Frozen (default): only the free chain -- SearXNG when its URL
is set, plus DuckDuckGo -- even when paid keys are present.
Unfrozen (WEBSEARCH_ENABLE_PAID=1): the legacy full chain,
keyed backends only when their key is set, as before.
"""
env = env if env is not None else os.environ
chain = (DEFAULT_CHAIN if paid_search_enabled(env)
else DEFAULT_FREE_CHAIN)
out = [] out = []
for name in DEFAULT_CHAIN: for name in chain:
if name in _FREE_PROVIDERS: if name in _FREE_PROVIDERS:
if name == "searxng" and not env.get("SEARXNG_URL"): if name == "searxng" and not env.get("SEARXNG_URL"):
continue continue
@@ -395,8 +613,11 @@ def resolve_provider_chain(provider="auto", env=None):
"""Resolve the chain oh-my-pi style: auto walks, pinned does not. """Resolve the chain oh-my-pi style: auto walks, pinned does not.
``provider`` may be "auto" (default chain, filtered to what is ``provider`` may be "auto" (default chain, filtered to what is
configured), a single name (that provider only, even if it fails), configured -- frozen by default to the free chain, SearXNG then
or a comma-separated list (that order only). Unknown names raise DuckDuckGo, unless WEBSEARCH_ENABLE_PAID=1 restores the legacy
full chain), a single name (that provider only, even if it fails
-- pinning a paid backend bypasses the freeze, deliberately), or
a comma-separated list (that order only). Unknown names raise
ValueError before any HTTP happens. ValueError before any HTTP happens.
""" """
env = env if env is not None else os.environ env = env if env is not None else os.environ
@@ -416,8 +637,35 @@ def resolve_provider_chain(provider="auto", env=None):
return names return names
def search(query, limit=5, provider="auto", env=None, http=None): def search(query, limit=5, provider="auto", env=None, http=None,
"""Search the web. Returns {provider, results, answer, attempted}. scope="general", allowed_hosts=None):
"""Search the web. Returns {provider, results, answer, attempted,
scope} (plus code_domains when scope="code").
``scope="code"`` restricts results to CODE_SEARCH_DOMAINS:
providers with a native domain filter (Perplexity
search_domain_filter, Exa/Tavily include_domains) get the list in
the request, and every provider's results are post-filtered to
the same list afterwards, so backends without a native filter
(Brave, SearXNG, DuckDuckGo) are scoped too. A provider whose
results are all filtered out counts as "no results" and the chain
continues; the filter count is recorded on its attempted entry.
Default scope="general" behaves exactly as before (no domain
filter, no post-filtering).
``allowed_hosts`` is a task-level, explicit extension of the
code list only -- it is NOT a general "search any site" switch.
It is honoured only with scope="code"; passing it with
scope="general" raises ValueError instead of being silently
ignored, and omitting it leaves behaviour exactly as before
(builtin 15 domains only). When given, the effective list is
builtin + validated extras, and the same native-filter /
post-filter split applies: native-capable backends receive the
effective list (Perplexity skips its native filter when the
effective list exceeds its 20-domain cap, relying on the
post-filter), and backends without a native filter are scoped
by post-filtering alone. Extension values are never formatted
into error strings or attempted entries.
Raises WebSearchError (with .attempted) when every provider in Raises WebSearchError (with .attempted) when every provider in
the chain fails or returns no results. Error strings never contain the chain fails or returns no results. Error strings never contain
@@ -425,6 +673,18 @@ def search(query, limit=5, provider="auto", env=None, http=None):
""" """
if not query or not str(query).strip(): if not query or not str(query).strip():
raise ValueError("query must be a non-empty string") raise ValueError("query must be a non-empty string")
scope = _normalize_scope(scope)
if allowed_hosts is not None and scope != "code":
raise ValueError(
"allowed_hosts is only valid with scope=\"code\" "
"(got scope=%r); it is an extension of the code-domain "
"list, not a general search override" % (scope,))
if scope == "code":
domains = list(CODE_SEARCH_DOMAINS)
if allowed_hosts is not None:
domains.extend(_normalize_allowed_hosts(allowed_hosts))
else:
domains = None
env = env if env is not None else os.environ env = env if env is not None else os.environ
http = http or HttpClient( http = http or HttpClient(
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20)) timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
@@ -439,7 +699,7 @@ def search(query, limit=5, provider="auto", env=None, http=None):
continue continue
try: try:
results, answer = _PROVIDERS[name]( results, answer = _PROVIDERS[name](
str(query), limit, env, http) str(query), limit, env, http, domains=domains)
except Exception as exc: # noqa: BLE001 - chain must continue except Exception as exc: # noqa: BLE001 - chain must continue
attempted.append({"provider": name, "ok": False, attempted.append({"provider": name, "ok": False,
"error": _http_error_message(exc)}) "error": _http_error_message(exc)})
@@ -448,11 +708,30 @@ def search(query, limit=5, provider="auto", env=None, http=None):
attempted.append({"provider": name, "ok": False, attempted.append({"provider": name, "ok": False,
"error": "no results"}) "error": "no results"})
continue continue
attempted.append({"provider": name, "ok": True, "error": None}) if scope == "code":
return {"provider": name, kept = [r for r in results
"results": [dict(r) for r in results[:limit]], if _is_code_domain(r.get("url"), domains)]
"answer": answer, if not kept:
"attempted": attempted} attempted.append(
{"provider": name, "ok": False,
"error": "code-domain filter removed all %d results"
% len(results)})
continue
entry = {"provider": name, "ok": True, "error": None}
if len(kept) < len(results):
entry["domain_filtered_out"] = len(results) - len(kept)
attempted.append(entry)
results = kept
else:
attempted.append({"provider": name, "ok": True, "error": None})
out = {"provider": name,
"results": [dict(r) for r in results[:limit]],
"answer": answer,
"attempted": attempted,
"scope": scope}
if scope == "code":
out["code_domains"] = domains
return out
raise WebSearchError( raise WebSearchError(
"all search providers failed: %s" "all search providers failed: %s"
% "; ".join("%s (%s)" % (a["provider"], a["error"]) % "; ".join("%s (%s)" % (a["provider"], a["error"])
@@ -578,6 +857,159 @@ def fetch_url(url, char_limit=DEFAULT_FETCH_CHAR_LIMIT, env=None,
"char_limit": char_limit} "char_limit": char_limit}
# ------------------------------------------------- provider extract
def _extract_tavily(url, char_limit, env, http):
"""Tavily extract: POST /extract, raw_content is the page text."""
_status, body, _ctype = http.post_json(
"https://api.tavily.com/extract",
_default_headers(env, "tavily"),
{"urls": [url], "extract_depth": "basic", "format": "text"})
data = json.loads(body.decode("utf-8", errors="replace"))
items = data.get("results") or []
for item in items:
if isinstance(item, dict):
text = item.get("raw_content") or item.get("content") or ""
if text:
return text
failed = data.get("failed_results") or []
if failed:
raise WebSearchError("tavily extract failed for url")
raise WebSearchError("tavily extract returned no content")
def _extract_exa(url, char_limit, env, http):
"""Exa contents: POST /contents with ids=[url], full text view."""
# Our own single-page budget, not a claimed API cap: the fetch
# truncation below still applies, this just keeps the request
# from asking for an unbounded page.
budget = min(char_limit, 100000)
_status, body, _ctype = http.post_json(
"https://api.exa.ai/contents",
_default_headers(env, "exa"),
{"ids": [url], "text": {"maxCharacters": budget}})
data = json.loads(body.decode("utf-8", errors="replace"))
items = data.get("results") or data.get("contents") or []
for item in items:
if not isinstance(item, dict):
continue
text = item.get("text") or ""
if not text and item.get("highlights"):
text = " ".join(str(h) for h in item["highlights"][:3])
if text:
return text
raise WebSearchError("exa contents returned no content")
def _fetch_result(url, text, char_limit, source):
text, truncated = _truncate(text, char_limit)
return {"url": str(url), "title": "", "text": text,
"links": [], "truncated": truncated,
"char_limit": char_limit, "source": source}
def fetch_via_provider(url, char_limit=DEFAULT_FETCH_CHAR_LIMIT,
env=None, http=None):
"""Extract one URL through a search provider's own extraction
endpoint, never by fetching the target directly.
Order: Tavily extract, then Exa contents; only backends with a
configured key are tried. Raises WebSearchError (with .attempted)
when no extract provider is configured or every tried one fails.
No result is fabricated: an empty extraction is a failure, not
an empty page. The localhost / private-IP refusal of fetch_url
applies here too, before any provider call.
"""
if not url or not str(url).strip():
raise ValueError("url must be a non-empty string")
env = env if env is not None else os.environ
if env.get("WEBSEARCH_ALLOW_PRIVATE_URLS") != "1":
blocked = _is_blocked_fetch_url(str(url))
if blocked:
raise WebSearchError(blocked)
http = http or HttpClient(
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
try:
char_limit = int(char_limit)
except (TypeError, ValueError):
char_limit = DEFAULT_FETCH_CHAR_LIMIT
char_limit = max(2000, min(500000, char_limit))
attempted = []
extractors = (("tavily", _extract_tavily),
("exa", _extract_exa))
tried = False
for name, fn in extractors:
if name in _KEY_ENV and not env.get(_KEY_ENV[name]):
attempted.append({"provider": name, "ok": False,
"error": "missing API key (%s unset)"
% _KEY_ENV[name]})
continue
tried = True
try:
text = fn(str(url), char_limit, env, http)
except Exception as exc: # noqa: BLE001 - try next extractor
attempted.append({"provider": name, "ok": False,
"error": _http_error_message(exc)})
continue
attempted.append({"provider": name, "ok": True, "error": None})
out = _fetch_result(url, text, char_limit,
"tavily-extract" if name == "tavily"
else "exa-contents")
out["attempted"] = attempted
return out
if not tried:
raise WebSearchError(
"no extract provider configured (set TAVILY_API_KEY "
"and/or EXA_API_KEY); direct fetch is the fallback, "
"see fetch_best",
attempted=attempted)
raise WebSearchError(
"all extract providers failed: %s"
% "; ".join("%s (%s)" % (a["provider"], a["error"])
for a in attempted),
attempted=attempted)
def fetch_best(url, char_limit=DEFAULT_FETCH_CHAR_LIMIT, env=None,
http=None):
"""Fetch one URL: provider extraction first, direct fetch last.
This is what the web_fetch MCP tool calls. When no extract
provider is configured it goes straight to direct fetch (same
behaviour as the old web_fetch); when extraction fails it falls
back to direct fetch; when both fail it raises one error naming
both failures, never a fabricated page. Direct fetch under the
egress proxy 403s for target domains not on the Tinyproxy
whitelist -- that is fail-closed, not a reason to bypass the
proxy, and the 403 surfaces in the error. The localhost /
private-IP refusal is checked before anything else.
"""
if not url or not str(url).strip():
raise ValueError("url must be a non-empty string")
env = env if env is not None else os.environ
if env.get("WEBSEARCH_ALLOW_PRIVATE_URLS") != "1":
blocked = _is_blocked_fetch_url(str(url))
if blocked:
raise WebSearchError(blocked)
extract_attempted = []
try:
return fetch_via_provider(url, char_limit=char_limit,
env=env, http=http)
except WebSearchError as exc:
extract_attempted = list(exc.attempted or [])
extract_error = str(exc)
try:
out = fetch_url(url, char_limit=char_limit, env=env, http=http)
except WebSearchError as exc:
raise WebSearchError(
"provider extraction failed (%s) and direct fetch failed "
"(%s)" % (extract_error, exc))
out = dict(out)
out["source"] = "direct-fetch"
out["extract_attempted"] = extract_attempted
return out
# ------------------------------------------------------- codex-home glue # ------------------------------------------------------- codex-home glue
def ensure_codex_home_websearch(codex_home_dir, command_args=None, def ensure_codex_home_websearch(codex_home_dir, command_args=None,
+66 -8
View File
@@ -20,10 +20,10 @@ import os
import sys import sys
try: try:
from cloud.websearch import WebSearchError, fetch_url, search from cloud.websearch import WebSearchError, fetch_best, search
except ImportError: # flat copy in a tenant codex-home except ImportError: # flat copy in a tenant codex-home
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from websearch import WebSearchError, fetch_url, search # noqa: E402 from websearch import WebSearchError, fetch_best, search # noqa: E402
SERVER_NAME = "websearch" SERVER_NAME = "websearch"
SERVER_VERSION = "0.1.0" SERVER_VERSION = "0.1.0"
@@ -35,9 +35,31 @@ _TOOLS = [
"Search the web and return ranked results with title, URL " "Search the web and return ranked results with title, URL "
"and snippet, plus a synthesised answer when the provider " "and snippet, plus a synthesised answer when the provider "
"supplies one. Use web_fetch to read specific pages. " "supplies one. Use web_fetch to read specific pages. "
"provider may be 'auto' (walk Perplexity, Exa, Tavily, " "provider may be 'auto' (frozen default: keyless SearXNG "
"Brave, then keyless SearXNG / DuckDuckGo, falling back " "first, then DuckDuckGo as the free fallback, falling "
"on failure) or a single provider name to pin it." "back on failure; the paid backends Perplexity, Exa, "
"Tavily and Brave are NOT in the default chain even "
"when their keys are set, and a SearXNG failure never "
"falls back to a paid backend, so default use never "
"incurs paid cost) or a single provider name to pin it "
"(pinning a paid backend still calls it: that is an "
"explicit override, not a default). Set "
"WEBSEARCH_ENABLE_PAID=1 to restore the old default "
"chain (Perplexity, Exa, Tavily, Brave, SearXNG, "
"DuckDuckGo); unfreezing is explicit and may incur "
"paid cost. "
"scope 'code' restricts results to programming sources "
"only (official language docs, GitHub, package "
"registries, Stack Overflow, CVE/NVD); anything else "
"is filtered out, never returned. Default scope "
"'general' is unfiltered. With scope 'code' only, "
"allowed_hosts may name extra project domains for "
"this call (explicit allowlist extension, subdomains "
"included, dot-boundary matched); it is not an "
"arbitrary-site search, and it is rejected with "
"scope 'general'. This tool does not do "
"shopping, social, places, travel, general news, "
"weather, finance, or browser checkout."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -48,6 +70,27 @@ _TOOLS = [
"description": "Max results, 1-10"}, "description": "Max results, 1-10"},
"provider": {"type": "string", "default": "auto", "provider": {"type": "string", "default": "auto",
"description": "auto or a provider name"}, "description": "auto or a provider name"},
"scope": {"type": "string", "default": "general",
"enum": ["general", "code"],
"description": "general: unfiltered web "
"search. code: programming "
"sources only, see tool "
"description."},
"allowed_hosts": {"type": "array",
"items": {"type": "string"},
"description": "Only with scope "
"'code': extra "
"domains for this "
"call only, on top "
"of the builtin "
"programming list. "
"Omit for no "
"extension; empty, "
"wildcard, or "
"non-hostname "
"values are "
"rejected, never "
"silently ignored."},
}, },
"required": ["query"], "required": ["query"],
}, },
@@ -57,7 +100,13 @@ _TOOLS = [
"description": ( "description": (
"Fetch one URL and return clean text plus its links, " "Fetch one URL and return clean text plus its links, "
"truncated head+tail when over char_limit. " "truncated head+tail when over char_limit. "
"Only http(s) URLs; localhost / private IPs are blocked." "Extraction first: Tavily extract, then Exa contents "
"(only backends with a configured key are tried), "
"direct fetch last. Under the egress proxy, direct "
"fetch of a domain outside the Tinyproxy whitelist "
"fails 403 fail-closed; that error is reported, not "
"bypassed. Only http(s) URLs; localhost / private IPs "
"are blocked. This is not a sandbox boundary."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -83,7 +132,7 @@ def handle_request(request, search_fn=None, fetch_fn=None):
None for notifications (no id). Pure function for testability: None for notifications (no id). Pure function for testability:
the stdio loop passes the real search/fetch, tests pass fakes.""" the stdio loop passes the real search/fetch, tests pass fakes."""
search_fn = search_fn or search search_fn = search_fn or search
fetch_fn = fetch_fn or fetch_url fetch_fn = fetch_fn or fetch_best
if not isinstance(request, dict): if not isinstance(request, dict):
return {"jsonrpc": "2.0", "id": None, return {"jsonrpc": "2.0", "id": None,
"error": _err(-32700, "request is not an object")} "error": _err(-32700, "request is not an object")}
@@ -120,10 +169,19 @@ def handle_request(request, search_fn=None, fetch_fn=None):
args = params.get("arguments") or {} args = params.get("arguments") or {}
try: try:
if name == "web_search": if name == "web_search":
# allowed_hosts is passed through only when the
# caller supplied it, so older search_fn fakes
# without that parameter keep working.
search_kwargs = {}
if "allowed_hosts" in args:
search_kwargs["allowed_hosts"] = \
args.get("allowed_hosts")
result = search_fn( result = search_fn(
args.get("query"), args.get("query"),
limit=args.get("limit", 5), limit=args.get("limit", 5),
provider=args.get("provider", "auto")) provider=args.get("provider", "auto"),
scope=args.get("scope", "general"),
**search_kwargs)
elif name == "web_fetch": elif name == "web_fetch":
result = fetch_fn( result = fetch_fn(
args.get("url"), args.get("url"),
+85 -3
View File
@@ -2765,9 +2765,10 @@ class Worker:
# 与 imagegen 同款:播种失败直接抛错、任务不派发 # 与 imagegen 同款:播种失败直接抛错、任务不派发
# ——静默无浏览器比 loud 失败更糟,尤其对已宣传 # ——静默无浏览器比 loud 失败更糟,尤其对已宣传
# “能开浏览器”的任务。代理与浏览器路径由 # “能开浏览器”的任务。代理与浏览器路径由
# _prepare_tenant 的 env 块给。MCP 脚本是系统文件: # _prepare_tenant 的 env 块给。播种器只对已证明
# 播种器在版本变化时刷新老租户的旧脚本(见 # 未改动的租户脚本自动合并新版(见
# ensure_codex_home_playwright),刷新记录打日志。 # ensure_codex_home_playwright);租户改过的脚本
# 绝不覆盖,只记审计并打 warning 留待人工处理。
_seeded = _ensure_playwright_mcp( _seeded = _ensure_playwright_mcp(
dirs["codex_home"], dirs["codex_home"],
command_args=[ command_args=[
@@ -2779,6 +2780,12 @@ class Worker:
"playwright MCP scripts refreshed for project %s: %s", "playwright MCP scripts refreshed for project %s: %s",
task.get("project_id"), task.get("project_id"),
",".join(_seeded["script_refreshed"])) ",".join(_seeded["script_refreshed"]))
if _seeded.get("script_skipped_modified"):
self.logger.warning(
"playwright MCP scripts skipped (tenant-modified, "
"left untouched, see audit) for project %s: %s",
task.get("project_id"),
",".join(_seeded["script_skipped_modified"]))
if self.seed_skills: if self.seed_skills:
# 技能播种失败绝不阻塞任务:技能是可选的流程 # 技能播种失败绝不阻塞任务:技能是可选的流程
# 说明,不是 websearch 那种“已宣传就必须有”的 # 说明,不是 websearch 那种“已宣传就必须有”的
@@ -3181,9 +3188,15 @@ class Worker:
# auto 链会跳过 SearXNG、只剩 DuckDuckGo(实弹 e2e 已证实)。 # auto 链会跳过 SearXNG、只剩 DuckDuckGo(实弹 e2e 已证实)。
# 只透传已设置的项,值绝不进日志;付费 key 与网关 key 一样 # 只透传已设置的项,值绝不进日志;付费 key 与网关 key 一样
# 走 extra_env(容器内进程本就需要它调 API)。 # 走 extra_env(容器内进程本就需要它调 API)。
# WEBSEARCH_ENABLE_PAID rides along so the paid-freeze
# switch set on the host actually reaches the MCP process
# (which reads only config.toml env, never worker env).
# Default is frozen; the switch is honoured only when set
# to exactly "1" (see websearch.paid_search_enabled).
search_env = { search_env = {
k: os.environ[k] k: os.environ[k]
for k in ("SEARXNG_URL", "SEARXNG_ENGINES", for k in ("SEARXNG_URL", "SEARXNG_ENGINES",
"WEBSEARCH_ENABLE_PAID",
"PERPLEXITY_API_KEY", "EXA_API_KEY", "PERPLEXITY_API_KEY", "EXA_API_KEY",
"TAVILY_API_KEY", "BRAVE_API_KEY") "TAVILY_API_KEY", "BRAVE_API_KEY")
if os.environ.get(k) if os.environ.get(k)
@@ -3240,6 +3253,18 @@ class Worker:
playwright_env = dict(proxy_env or {}) playwright_env = dict(proxy_env or {})
playwright_env["PLAYWRIGHT_BROWSERS_PATH"] = ( playwright_env["PLAYWRIGHT_BROWSERS_PATH"] = (
"/opt/ms-playwright") "/opt/ms-playwright")
# 导航白名单透传:worker 环境里的
# BROWSER_ALLOWED_HOSTS 只在已设置且非空时写进
# MCP env;未设置时绝不写空值(空值会覆盖租户
# 自己的配置,且 _allowed_hosts 对空串本就当未
# 设置——写它没有意义)。只写 playwright 的 env
# 块,不进 websearch env、不进容器 -e:MCP 子进
# 程只读 config.toml 里的 env(2026-10-30 实测
# 容器 -e 到不了 MCP 进程)。值绝不进日志:下
# 面失败分支只记异常,不记 env 内容。
allowed_hosts = os.environ.get("BROWSER_ALLOWED_HOSTS")
if allowed_hosts:
playwright_env["BROWSER_ALLOWED_HOSTS"] = allowed_hosts
home = self._tenant_dirs( home = self._tenant_dirs(
task.get("project_id"))["codex_home"] task.get("project_id"))["codex_home"]
_ensure_playwright_mcp_env(home, playwright_env) _ensure_playwright_mcp_env(home, playwright_env)
@@ -6557,6 +6582,59 @@ class Worker:
log_event(self.logger, "worker_push_due_failed", log_event(self.logger, "worker_push_due_failed",
message="push due processing failed: %s" % exc) message="push due processing failed: %s" % exc)
def process_due_resident_watches(self, now=None, fetcher=None,
resolver=None):
"""常驻监听驱动(worker 轮次边界调用):到期监听逐条
走 resident.process_due_resident_watches,抢单 + 廉价
抓取 + 仅变化建任务。全部异常只记日志,不炸循环;
fetcher 缺省用 resident 默认抓取,单测可经
self.resident_fetcher 或参数注入假抓取;resolver 同款
注入点(self.resident_resolver),生产路径为 None 即
默认 DNS 门,测试用它把测试主机名指到本地服务。
建表只在 worker 生命周期内做一次(首批):热路径
不再逐条 ensure,见 resident.ensure 的调用约定;
批量内逐条隔离的异常与预算耗尽信号记
worker_resident_watch_failed 日志,不炸整批。"""
if now is None:
now = time.time()
if fetcher is None:
fetcher = getattr(self, "resident_fetcher", None)
if resolver is None:
resolver = getattr(self, "resident_resolver", None)
try:
from cloud.resident import ensure_resident_tables as _ensure
from cloud.resident import process_due_resident_watches as _run
conn = _db_connect(self.db_path)
try:
if not getattr(self, "_resident_tables_ensured", False):
try:
_ensure(conn)
conn.commit()
self._resident_tables_ensured = True
except Exception as exc:
log_event(
self.logger, "worker_resident_watch_failed",
message="resident ensure failed: %s" % exc)
errors = []
polls = _run(conn, fetcher=fetcher, now=now,
resolver=resolver, errors=errors)
for watch_id, msg in errors:
log_event(
self.logger, "worker_resident_watch_failed",
message="resident watch item failed: %s: %s"
% (watch_id, msg))
finally:
conn.close()
except Exception as exc:
log_event(self.logger, "worker_resident_watch_failed",
message="resident watch processing failed: %s" % exc)
return []
for poll in polls:
log_event(self.logger, "worker_resident_watch_poll",
message="resident watch poll: %s %s"
% (poll["watch_id"], poll["status"]))
return polls
def run_loop(self, interval=300, max_seconds=None, stop_event=None, def run_loop(self, interval=300, max_seconds=None, stop_event=None,
drain_timeout=60, sweep_threshold=300): drain_timeout=60, sweep_threshold=300):
"""常驻循环:每 interval 秒跑一轮 run_all,空闲也不退出。 """常驻循环:每 interval 秒跑一轮 run_all,空闲也不退出。
@@ -6639,6 +6717,10 @@ class Worker:
# G11/G12 后台投递:摘要(每小时)与 outbox 重试, # G11/G12 后台投递:摘要(每小时)与 outbox 重试,
# 轮次边界做,不中断在跑任务,失败只记日志。 # 轮次边界做,不中断在跑任务,失败只记日志。
self._process_push_due() self._process_push_due()
# 常驻监听驱动:同款轮次边界后台扫,到期监听
# 廉价抓取、仅变化建任务;服务端本身不起定时器,
# 这里就是常驻任务的实际驱动(见 resident 模块)。
self.process_due_resident_watches()
# 自动记忆提取:同款轮次边界后台扫,失败只记日志 # 自动记忆提取:同款轮次边界后台扫,失败只记日志
# (process_auto_memories 内部已兜底,不炸循环)。 # (process_auto_memories 内部已兜底,不炸循环)。
self.process_auto_memories() self.process_auto_memories()
+4 -1
View File
@@ -14,7 +14,10 @@ EnvironmentFile=/etc/codex-app-server/env
# scaler 会按实例强制追加;--db 只给 scaler,scaler 会补给 worker。 # scaler 会按实例强制追加;--db 只给 scaler,scaler 会补给 worker。
# 注意:启用 autoscaler 后必须停掉静态 codex-cloud-worker, # 注意:启用 autoscaler 后必须停掉静态 codex-cloud-worker,
# 否则两边同时派任务(scaler 只管理 autoscale-* 实例)。 # 否则两边同时派任务(scaler 只管理 autoscale-* 实例)。
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp # worker 侧 MCP/技能播种四项与生产主机现行 unit 一致
# (--websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp);
# 主机 unit 再改时须同步改这里,deploy/tests/test_autoscaler_unit.py 会拦漂移。
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
# 停 scaler 时要等它把 worker 优雅排空(drain 60 秒 + 缓冲), # 停 scaler 时要等它把 worker 优雅排空(drain 60 秒 + 缓冲),
+20
View File
@@ -45,3 +45,23 @@
^\(.*\.\)*weather\.com\.cn$ ^\(.*\.\)*weather\.com\.cn$
^\(.*\.\)*meteoblue\.com$ ^\(.*\.\)*meteoblue\.com$
^\(.*\.\)*accuweather\.com$ ^\(.*\.\)*accuweather\.com$
# Platform browser programming sources (2026-11-01, feat-platform-browser).
# Only programming sources: official docs, GitHub, package registries,
# Stack Overflow, CVE/NVD. Deliberately NOT included: shopping, social,
# mail, general news, video sites. Same BASIC form as above.
# Already present above, not duplicated here: github.com, pypi.org,
# npmjs.org (plus pythonhosted.org for pip). Notes on coverage:
# docs.python.org is covered by the python.org line (subdomain rule);
# docs.rust-lang.org by rust-lang.org; nodejs.org docs live on
# nodejs.org itself. developer.mozilla.org is scoped to that host
# only, NOT a mozilla.org wildcard, so no other Mozilla host opens.
^\(.*\.\)*python\.org$
^\(.*\.\)*nodejs\.org$
^\(.*\.\)*go\.dev$
^\(.*\.\)*pkg\.go\.dev$
^\(.*\.\)*rust-lang\.org$
^\(.*\.\)*developer\.mozilla\.org$
^\(.*\.\)*crates\.io$
^\(.*\.\)*stackoverflow\.com$
^\(.*\.\)*cve\.mitre\.org$
^\(.*\.\)*nvd\.nist\.gov$
+94
View File
@@ -0,0 +1,94 @@
"""autoscaler unit 与生产主机现行 unit 的同步回归。
背景:仓库版 deploy/autoscaler/codex-autoscaler.service 的 ExecStart
长期只带 --websearch-mcp,而生产主机现行 unit 在此之外还带
--seed-skills --imagegen-mcp --playwright-mcp(2026-11-01 主机只读核对:
主机 ExecStart = 仓库版 ExecStart + 这三项,别无差异)。
仓库版滞后意味着按仓库重装会静默丢掉技能播种与两路 MCP。
本文件只读仓库内两个文件,不碰主机、不联网、不跑 worker:
- unit 的 worker 侧(`--` 之后)必须含全部四项 MCP/技能播种 flag;
- worker 侧所有 --flag 必须在 cloud/worker.py 里有 add_argument 定义,
防止拼写漂移导致 worker 启动即报未知参数;
- 四项 flag 绝不能出现在 scaler 侧(`--` 之前),它们是 worker 参数。
"""
import os
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
UNIT_PATH = os.path.join(REPO_ROOT, "deploy", "autoscaler",
"codex-autoscaler.service")
WORKER_PATH = os.path.join(REPO_ROOT, "cloud", "worker.py")
# 生产主机现行 unit 带的 worker 侧 MCP/技能播种四项。
MCP_SEED_FLAGS = ("--websearch-mcp", "--seed-skills",
"--imagegen-mcp", "--playwright-mcp")
def _execstart():
with open(UNIT_PATH, encoding="utf-8") as f:
lines = [ln.rstrip("\n") for ln in f]
starts = [ln for ln in lines if ln.startswith("ExecStart=")]
assert len(starts) == 1, "unit 应恰好有一行 ExecStart,实得 %d 行" % len(starts)
return starts[0][len("ExecStart="):]
def _split_sides():
cmd = _execstart()
assert cmd.startswith("/usr/bin/python3 -m cloud.autoscaler"), \
"unit 应以 cloud.autoscaler 启动,实得:%s" % cmd[:60]
parts = cmd.split(" -- ", 1)
assert len(parts) == 2, "ExecStart 缺少 scaler/worker 分隔符 ' -- '"
return parts[0], parts[1]
def _flag_tokens(side):
"""side 里以 -- 开头的 token(去重前顺序保留,含 =value 形式剥离)。"""
flags = []
for tok in side.split():
if tok.startswith("--"):
flags.append(tok.split("=", 1)[0])
return flags
class TestAutoscalerUnitSync(unittest.TestCase):
def test_worker_side_includes_all_four_mcp_seed_flags(self):
_, worker_side = _split_sides()
flags = _flag_tokens(worker_side)
for flag in MCP_SEED_FLAGS:
self.assertIn(flag, flags,
"worker 侧缺 %s,仓库 unit 已滞后于生产主机" % flag)
def test_worker_side_flags_have_no_duplicates(self):
_, worker_side = _split_sides()
flags = _flag_tokens(worker_side)
self.assertEqual(len(flags), len(set(flags)),
"worker 侧 flag 重复:%s" % (flags,))
def test_worker_side_flags_are_defined_in_worker_parser(self):
_, worker_side = _split_sides()
with open(WORKER_PATH, encoding="utf-8") as f:
worker_src = f.read()
for flag in _flag_tokens(worker_side):
self.assertIn('add_argument("%s"' % flag, worker_src,
"%s 未在 cloud/worker.py 定义,worker 会报未知参数"
% flag)
def test_mcp_seed_flags_not_on_scaler_side(self):
scaler_side, _ = _split_sides()
flags = _flag_tokens(scaler_side)
for flag in MCP_SEED_FLAGS:
self.assertNotIn(flag, flags,
"%s 不应出现在 scaler 侧(`--` 之前)" % flag)
def test_worker_side_keeps_docker_runtime_runsc(self):
_, worker_side = _split_sides()
tokens = worker_side.split()
self.assertIn("--docker-runtime", tokens)
self.assertEqual(tokens[tokens.index("--docker-runtime") + 1], "runsc")
if __name__ == "__main__":
unittest.main()
+201
View File
@@ -0,0 +1,201 @@
# 平台浏览器:与 Muse browser 的对照
本轨目标:把编程任务平台的浏览器能力复刻到与 Muse browser
可对照的程度——只服务编程任务(验证自己的 Web 应用、读官方
文档、查包与 CVE),不复刻个人 agent 的通用上网能力。
范围边界先说清:
- 搜索(browser.search / browser.open / browser.find 那一路)
由另一轨负责,本轨只消费、不重建。
- 多标签页已由本轨另一路完成:`browser_new_page` / `browser_list_pages` / `browser_switch_page` / `browser_close_page`,导航白名单见下文补充。
- 本轨不部署:filter 改动只落在仓库文件里,主机 tinyproxy
未 reload;真实站点未 live 验证(见文末“未验证”一节)。
## 逐项对照
| 能力 | Muse browser | 本轨状态 |
| --- | --- | --- |
| Live Chromium(真浏览器,非文本抓取) | 有 | 已有:`cloud/playwright_browser.py`,单页 headless Chromium,1280x800 视口 |
| 导航 | 有 | 已有:`browser_navigate`;代理显式传 launch,域名不在白名单时报代理 403,不编造页面 |
| 快照(无障碍式清单) | 有 | 已有:`browser_snapshot`,及 `browser_snapshot_refs`(ref -> 元素清单,`browser_click_ref` 按 ref 点击) |
| 点击 / 填写 | 有 | 已有:`browser_click`、`browser_click_at`(坐标,有视口边界检查)、`browser_fill`、`browser_type_text` |
| 键盘 / 鼠标 | 有 | 已有:`browser_key_press`、`browser_mouse_move` |
| 截图 / 实时画面 | 有 | 已有:`browser_screenshot`、`browser_live_shot`(固定 live 文件 + JSON sidecar,供 console 轮询);截图只写任务工作区(`cwd`,文件名 basename 校验),不写共享目录 |
| JS 求值 | 有 | 已有:`browser_evaluate` |
| MCP 工具面 | 有 | 已有:`cloud/playwright_mcp.py`,stdio MCP,14 个工具(navigate / snapshot / snapshot_refs / click / click_at / click_ref / fill / type_text / key_press / mouse_move / evaluate / screenshot / live_shot / close) |
| 观看 + 接管 | 有(观看与接管) | 已有:`BrowserPanel.tsx` 是观看 + 接管面板,不是纯观看面板;`holder` 驱动——用户接管(holder=user)时 agent 工具报错、把控制权交还用户,交还后 agent 续跑前先快照确认用户改动 |
| Worker 播种 | — | 已有:worker `--playwright-mcp` 把 MCP 文件与配置播种进租户 codex-home |
| 租户工作区隔离 | — | 已有:浏览器跑在任务沙箱内,截图与下载落任务工作区 |
| 代理 403 如实报错 | — | 已有:域名不在 Tinyproxy 白名单时工具 plain 报错 403,不静默失败、不编造内容 |
| 持久 profile / 跨回合登录态 | 有 | 缺口(本轨未做):每次任务的浏览器是 fresh 会话,登录态不跨回合保留 |
| 登录凭证保险库 | 有 | 缺口 + 本轨不做:不存任何已存凭证;见“本轨不做” |
| 服务端审批卡片(表单提交 / 下载 / 上传的 Allow / Ask / Deny) | 有 | 缺口(本轨未做):本轨只有 holder 接管阻塞(用户接管时 agent 工具报错停下),没有提交/下载/上传的审批流 |
| 验证码 / 机器人检查 | 有处理流程 | 缺口(本轨未做绕过):被挡就报 blocked 停下,不绕过、不伪装 |
| 下载落盘清单 | 有 | 缺口(本轨未做):除截图落任务工作区外,无统一的下载物清单面 |
| 多标签页 | 有 | 已有(本轨补):`browser_new_page` / `browser_list_pages` / `browser_switch_page` / `browser_close_page`;新页自带全新 Playwright context,cookie/存储隔离 |
| 真实站点 live 验证 | 有 | 未做:本轨只改 egress 白名单与文档,未在主机 tinyproxy 上 reload、未对真实站点跑 live 验证 |
## 本轨不做(明确排除)
- 个人购物下单。
- 个人邮箱 / 社交登录。
- 验证码绕过、机器人检查绕过。
- 已存凭证保险库(任何真实密码、cookie、token 绝不写进
代码、测试、文档、回执)。
## 本轨改动:egress 白名单
文件:`deploy/egress/tinyproxy-filter`(Tinyproxy POSIX BASIC
正则写法,一行一条,`^\(.*\.\)*域名$` 形式;不要用 extended
锚定写法,2026-10-26 已实测 extended 写法会把允许域名也挡成
403;文件保持 644 可读)。
本轨新增 10 条(编程来源限定):
- `^\(.*\.\)*python\.org$`(含 docs.python.org 子域名规则覆盖)
- `^\(.*\.\)*nodejs\.org$`
- `^\(.*\.\)*go\.dev$`
- `^\(.*\.\)*pkg\.go\.dev$`
- `^\(.*\.\)*rust-lang\.org$`(含 docs.rust-lang.org)
- `^\(.*\.\)*developer\.mozilla\.org$`(只放行该 host,不放行
mozilla.org 通配)
- `^\(.*\.\)*crates\.io$`
- `^\(.*\.\)*stackoverflow\.com$`
- `^\(.*\.\)*cve\.mitre\.org$`
- `^\(.*\.\)*nvd\.nist\.gov$`
已在白名单、无需重复加:`github.com`、`pypi.org`、`npmjs.org`
(另有 `pythonhosted.org` 供 pip)。
刻意不动:`^searx\.diante\.me$` 精确行不动;未加任何
`diante.me` 通配(那会放行 control plane api.diante.me);
未加购物、社交、邮箱、通用新闻、视频站。
对应测试:`cloud/tests/test_websearch_worker.py` 的
`EgressFilterTestCase` 新增 2 项(见“验证”一节)——断言新规则
存在、已存在规则不重复、searx 精确行仍在且无 diante.me 通配、
以及用 BASIC 转 extended 后的实际匹配语义(子域名放行、
形似域名与后缀 trick 不放行、control plane 与购物/视频/邮箱
域名不放行)。
## 本轨补充:多标签页、导航白名单、关闭清理
- 多标签页:`browser_new_page`(开新页并切为当前页)、`browser_list_pages`(未启动时不启动、返回空列表)、`browser_switch_page`(按 index 切页,不存在时报明确错误、当前页不变、旧页 refs 清空)、`browser_close_page`(缺省关当前页,关后自动切上一页;关最后一页报错“use browser_close”)。现有单页方法全部作用于当前页、行为不变。
- 导航白名单:`BROWSER_ALLOWED_HOSTS`(逗号列表,前导点后缀匹配,大小写与端口不影响匹配)。未设置时行为不变;设置后在启动 Chromium 之前检查,未命中直接报错且不启动;`file://` 一律放行;`localhost/127.0.0.1` 无特殊待遇、须显式列出(本地开发服务器要自己加 localhost)。接线:worker 在容器启动前把 worker 环境里的 `BROWSER_ALLOWED_HOSTS`(已设置且非空时)写进租户 config 的 `[mcp_servers.playwright.env]`,MCP 进程由此拿到它;未设置时不写该键、白名单不生效(见未验证一节)。
- 页内跳转白名单(本轮补齐,上一轮的缺口已收窄、未归零):`browser_click` / `browser_click_ref` 在目标是带 href 的链接时先解析 href(相对链接按当前页展开)再做同款检查,未命中报错且点击不执行;`click` / `click_ref` / `click_at` 在点击后都会检查落点 URL,落到 blocked host 时报错。已拦住的:navigate、带 href 的 click / click_ref(事前)。确实拦不住的(经代码确认,不硬编检查):`click_at` 的事前检查(坐标对应不了元素,elementFromPoint 探针受视口/滚动影响、且漏掉 JS 驱动的跳转,故只做事后检查);无 href 目标的点击(按钮、JS 处理函数,事前无目的地);页内 JS 自行跳转(`location.href`、表单提交、SPA 路由、回车提交)、`browser_evaluate` 内的任意 JS、meta refresh、弹窗新页(`page.click` 不跟随新页)。这些路径的兜底只能是 Tinyproxy 代理 403:当白名单与代理白名单取同一域名集合时,被挡域名在代理层就加载失败;但 evaluate 与 shell 同权,白名单不是它的沙箱边界。测试:`cloud/tests/test_playwright_allowlist_click.py`(13 项,fake browser,不联网):命中/未命中/后缀 trick(`notpython.org`、`python.org.evil.com`)/端口/大小写/file/localhost 正反例、事前拒点击不执行、事后落点报错、未设置时行为不变。
- 关闭清理:`browser_close()` 关掉全部页并尽力删除 cwd 下的 `browser-live.png` 与 `browser-live.json`(删失败不报错);自定义文件名的截图文件不在清理范围内。
## 本轮补齐:老租户脚本安全升级
上一轮的播种器是“版本变化就刷新一切不同脚本”;本轮改为
安全升级(`cloud/playwright_browser.py` 的
`ensure_codex_home_playwright`,worker 播种时调用):
- 自动合并(只对已证明未改动者):播种 manifest
(租户 codex-home 里的 `playwright_scripts_manifest.json`,
播种器自己的元数据,不是租户数据)命中、或命中 2a569ac
已发布三脚本的 SHA-256(`KNOWN_SHIPPED_SCRIPT_HASHES`,
已对 origin/main blob 验过哈希)、或文件缺失(全新播种)、
或已是最新(不写)。
- 绝不覆盖:内容已改动、且两项证明都不命中的脚本,原样
不动、列入返回值的 `script_skipped_modified`(兼容旧字段
`script_differs`)、在租户 codex-home 的
`playwright_scripts_audit.jsonl` 里记一条
`skipped_modified` 审计(脚本名、租户哈希、新版哈希),
worker 同时打 warning 日志;留待人工处理。
- 幂等与防误判:重跑不重复写(已是最新不写、审计按
脚本+租户哈希去重);跳过时 manifest 绝不记录租户哈希
——否则下次升级会把改动过的脚本误判为“未改动”而覆盖
(本轮修掉的正是这一处隐患,见测试
`test_modified_rerun_does_not_rewrite_or_duplicate_audit`)。
- 升级不删除任何租户文件、不改租户已有配置语义:
`[mcp_servers.playwright]` 只在缺失时追加,现有配置段
原样保留;manifest 与审计文件只是播种器自己的附加元数据。
- 测试:`cloud/tests/test_playwright_upgrade.py`(8 项):
全新播种、未变脚本自动合并、已变脚本原样不动且有审计、
幂等重跑不重复写、无 manifest 老租户(已知哈希合并 /
未知哈希跳过)、租户数据与配置不受影响。
## 本轮补齐:BROWSER_ALLOWED_HOSTS 激活方案(本轮不执行主机激活)
候选值(域名集合 = 已部署的 Tinyproxy 编程域名集合,不编
新清单;编码用前导点形式,因为 filter 行本身就是子域名
包含式,见下文编码说明):
```
BROWSER_ALLOWED_HOSTS=.python.org,.nodejs.org,.go.dev,.pkg.go.dev,.rust-lang.org,.developer.mozilla.org,.crates.io,.stackoverflow.com,.cve.mitre.org,.nvd.nist.gov,.github.com,.pypi.org,.npmjs.org
```
编码说明(关键,不可照抄纯域名版):filter 行
`^\(.*\.\)*python\.org$` 放行 `python.org` 及其全部子域名,
而白名单的不带点条目 `python.org` 只精确匹配自身、不放行
`docs.python.org`;所以候选值必须用前导点条目,否则激活后
`docs.python.org` / `docs.rust-lang.org` 会被白名单挡掉、
相对代理白名单是回归(测试
`test_plain_entries_would_break_subdomains` 已证明这一点)。
出入说明:任务文本写的是 `npmjs.com`,但已部署 filter
(`deploy/egress/tinyproxy-filter:23`)、`deploy/new-host-checklist.md`
与本文档旧文均为 `npmjs.org`,候选值按已部署集合取
`npmjs.org`,`npmjs.com` 不在候选值内;如需放行 npm 官网
本身,那是另起的 filter 变更,不由本轮擅自决定。
测试租户/测试任务层面验证(本轮已做,全部 mock/fake,
未碰生产租户):`cloud/tests/test_playwright_activation.py`
(7 项):候选值域名集合与已部署 filter 一致;裸域名与子
域名放行、后缀 trick 与圈外域名拦截;file 放行、localhost
在候选值下被挡(见行为变更);fake 任务级 navigate 放行与
拦截(拦截时不启动 Chromium);未设置时行为不变。
对已有浏览器任务的行为变更:
- 未设置 `BROWSER_ALLOWED_HOSTS`:行为完全不变(只靠代理
403),已有任务不受影响。
- 设置候选值后:navigate 与带 href 的 click / click_ref 在
命中时不变、未命中时事前报错(navigate 不启动浏览器、
click 不执行);无 href 点击与 click_at 只在事后落点检查;
file:// 一律放行;localhost / 127.0.0.1 被挡——本地开发
服务器任务须在值里另加 `localhost`,否则会新报错,这是
已知行为变更,不是 bug。
- JS 自行跳转等拦不住的路径:行为上只剩代理 403 兜底,
与激活前一致(激活前也是代理 403)。
主机激活步骤(只交付步骤,本轮不执行):
1. 在主机 worker/service 的环境里设置上述
`BROWSER_ALLOWED_HOSTS`(与 worker 透传链路一致:worker
环境变量 → 租户 config 的 `[mcp_servers.playwright.env]`,
未设置时不写该键)。
2. 重启 worker 相关 unit(只重启、不改 unit 文件与 filter)。
3. 用一个测试任务验证:navigate 到 `docs.python.org` 成功、
navigate 到圈外域名事前报错且不启动浏览器、click 圈外
链接事前拒点、localhost 任务按预期被挡(或按需加
`localhost` 后放行)。
回滚:清空该变量(从 worker 环境里删除
`BROWSER_ALLOWED_HOSTS` 并重启相关 unit),即回到未设置
行为——已有租户 config 里的旧 env 块由下次任务准备时的
`ensure_playwright_mcp_env` 覆盖(未设置时不写该键;注意:
已写入旧值的租户 config 需一次任务准备刷新后才彻底回到
未设置行为,见 worker 透传说明)。回滚不改 filter、不删
租户数据。
是否执行主机激活:本轮不执行。执行条件是本轮测试通过
(本轮测试已通过,见验证一节)且由 parent/用户拍板——
测试不通过时只交付方案与回滚、不强行激活;本轮测试通过
不等于主机已激活,主机激活仍待明确指令。
## 安全硬线
- 任何真实密码、cookie、token 绝不写进代码、测试、文档、回执。
- 浏览器只在任务沙箱与 egress 白名单内跑;白名单外域名一律
代理 403,如实报错。
- 截图与下载落任务工作区,不写共享目录。
## 未验证(诚实边界)
- filter 未在主机 tinyproxy 上 reload(本轨不部署、不碰主机)。
- 真实站点未 live 验证:上述 10 条域名在主机生效后的实际
可达性、以及浏览器在真实站点上的端到端回合,仍待部署后
在有 egress 的环境里实测;本轨的测试只验证 filter 文件本身
的语法与匹配语义,不冒充生产可用。
+313 -23
View File
@@ -15,19 +15,77 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
stdout 是 JSON-RPC 流,绝不能混日志。 stdout 是 JSON-RPC 流,绝不能混日志。
- `cloud/tests/test_websearch_mcp.py`:12 个单测,全部 - `cloud/tests/test_websearch_mcp.py`:12 个单测,全部
mock HTTP,不联网、不需要真实 key。 mock HTTP,不联网、不需要真实 key。
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
- `cloud/tests/test_code_search_ext.py`:任务级
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
- `cloud/tests/test_websearch_paid_freeze.py`:付费冻结单测
(默认 auto 无付费后端、SearXNG 失败不回落付费、显式
pin 仍工作、解冻开关关时不变 / 开时恢复旧链、
DuckDuckGo 兜底失败报错清晰、超时报错清晰;全部
mock HTTP,不联网、不需要真实 key)。
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
## 工具 ## 工具
- `web_search(query, limit=5, provider="auto")`:返回 - `web_search(query, limit=5, provider="auto", scope="general")`:返回
`{provider, results:[{title, url, snippet, score?}], answer?, attempted}`。 `{provider, results:[{title, url, snippet, score?}], answer?, attempted, scope}`,
`scope="code"` 时另带 `code_domains`。
`answer` 只有 provider 本身给合成答案时才有(目前是 `answer` 只有 provider 本身给合成答案时才有(目前是
Perplexity Sonar);其它 provider 的总结仍由主模型做。 Perplexity Sonar);其它 provider 的总结仍由主模型做。
- `web_fetch(url, char_limit=15000)`:返回 `scope` 详见下面“编程搜索 scope=code”一节。
`{url, title, text, links, truncated}`。超长按头 75% + - `web_fetch(url, char_limit=15000)`:走 `fetch_best` 抽取回退链
尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝 (Tavily extract → Exa contents → 直接抓取),返回
localhost 与字面私有 IP。 `{url, title, text, links, truncated, source}`(`source` 为
`tavily-extract` / `exa-contents` / `direct-fetch`)。
超长按头 75% + 尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝
localhost 与字面私有 IP;抽取与直接抓取都失败时报错,不伪造页面。
## Provider 链(auto 顺序) ## Provider 链:默认冻结,显式 pin 与显式解冻除外
冻结语义(本轨起,默认 `auto` 只走免费路线,顺序固定):
1. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
实例且 JSON 格式已打开。默认参数:`engines=bing,360search`
(2026-10-30 主机实测:默认 general 类别 20.0s 撞超时,
pinned 两引擎 0.47s;`SEARXNG_ENGINES` 可覆盖,空值恢复
不 pin)。`SEARXNG_URL` 在代码里无默认值:未配时该后端
直接报 `searxng not configured (SEARXNG_URL unset)`,链
只剩 DuckDuckGo;生产值 `http://searx.diante.me` 来自主
机 env(见“生产接线”),不在代码里写死;主机 env 实测
仅有 `SEARXNG_URL` 这一行、无 `SEARXNG_ENGINES` 行,
所以默认 pin(bing,360search)生效。注意是 http 不是
https:主机 nginx 只听 80、无 443 vhost,https 会在 TLS
层证书主机名不匹配失败,根本到不了 SearXNG。
2. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
免 key 兜底,布局漂移时只会返回空结果并继续走链,
不会把工具打崩;但它在两种出口下都不可作可用兜底:
本 VM 直连回 202 + 机器人挑战页、0 条,主机 Tinyproxy
路径约 25 秒超时。兜底失败时记为 `no results` / 超时并
如实报错,不伪造结果;因此 SearXNG 挂时冻结默认链可能
以诚实失败告终,这是已知边界,不是静默降级。
冻结保证:即使环境里配了付费 key,默认 auto 也绝不调用
Perplexity / Exa / Tavily / Brave、绝不产生付费花费;
SearXNG 失败后默认也不回落到付费后端,只走 DuckDuckGo。
付费后端的代码、测试、文档一律保留,未删除。
显式 pin 仍工作:`provider="tavily"` / `"exa"` /
`"perplexity"` / `"brave"` 显式指定时照常调用,这是用户
有意的覆盖,不是默认行为;pin 失败不回退(与冻结前一致)。
解冻开关:`WEBSEARCH_ENABLE_PAID=1`(且仅精确值 `1`,
`true` / `yes` / `0` 都不算开,默认关)时,默认 auto
恢复旧链,顺序为 Perplexity → Exa → Tavily → Brave →
SearXNG → DuckDuckGo(旧链里只试有 key 的付费后端 +
已配 URL 的 SearXNG + DuckDuckGo)。解冻是显式动作,
会产生付费花费,绝不能被默认链静默触发;开关走 worker
环境透传进 MCP config env(见“生产接线”)。
旧链各后端(解冻后 / 显式 pin 时)的请求形态不变:
1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`, 1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`,
模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。 模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。
@@ -41,18 +99,16 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
结果,总结留给模型)。 结果,总结留给模型)。
4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`, 4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`,
头 `X-Subscription-Token`,`extra_snippets: true`。 头 `X-Subscription-Token`,`extra_snippets: true`。
5. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
实例且 JSON 格式已打开。
6. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
免 key 兜底,布局漂移时只会返回空结果并继续走链,
不会把工具打崩。
规则:auto 只试已配置的后端(有 key 的付费后端 + 已配 规则:任一后端 HTTP 失败或 0 结果都继续下一个;全部失败
URL 的 SearXNG + DuckDuckGo);任一后端 HTTP 失败或 返回各后端错误(不含 key)。超时失败报
0 结果都继续下一个;全部失败返回各后端错误(不含 key)。 `timeout after Ns waiting for HTTP response`(N 为
`provider` pin 单个名字时不回退,失败即报错。 `WEBSEARCH_TIMEOUT`,默认 20 秒),并记在该后端的
可用 `WEBSEARCH_PROVIDERS`(逗号列表)改默认链顺序, attempted 条目里,绝不把超时写成“无结果”或空答案。
`WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。 `WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。另注意:
旧文档曾写可用 `WEBSEARCH_PROVIDERS` 改默认链顺序,
代码里从未实现该变量,本轨已删此说法(不另新加该变量,
保持改动最小)。
## 接入 codex(测试项目做法) ## 接入 codex(测试项目做法)
@@ -85,15 +141,19 @@ Docker 租户必须传容器内路径
- egress 白名单:`deploy/egress/tinyproxy-filter` 已加 - egress 白名单:`deploy/egress/tinyproxy-filter` 已加
Perplexity、Exa、Tavily、Brave、DuckDuckGo 与 Perplexity、Exa、Tavily、Brave、DuckDuckGo 与
`searx.diante.me`(精确匹配,不用 diante.me 通配,否则 `searx.diante.me`(精确匹配,不用 diante.me 通配,否则
任务经代理也能摸到 api.diante.me 控制面)。主机上须把 任务经代理也能摸到 api.diante.me 控制面;filter 行即
`^searx\.diante\.me$`,本轨不改这行)。主机上须把
该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载 该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载
tinyproxy,重载后先验中转域名还通,再验新域名。 tinyproxy,重载后先验中转域名还通,再验新域名。
- 自托管 SearXNG(公开子域名路):主机 Docker 跑 - 自托管 SearXNG(公开子域名路):主机 Docker 跑
`searxng/searxng`,经 nginx 反代到 `searx.diante.me` `searxng/searxng`,经 nginx(仅听 80,无 443 vhost、无
(DNS A 记录 + certbot 证书),MCP 侧 证书)反代到 `searx.diante.me`,MCP 侧
`SEARXNG_URL=https://searx.diante.me` 走 worker 进程 `SEARXNG_URL=http://searx.diante.me` 走 worker 进程
环境(`/etc/codex-app-server/env`)继承到 codex 再到 环境(`/etc/codex-app-server/env`)继承到 codex 再到
MCP,不写进 config.toml。公开实例的代价是可能被外人 MCP,不写进 config.toml。`WEBSEARCH_ENABLE_PAID` 同理
走 worker 的 search_env 透传进 `[mcp_servers.websearch.env]`
(worker 代码已含该键);主机侧改 env 文件后需重启相关
unit 才生效——那是部署动作,不在本轨。公开实例的代价是可能被外人
刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的 刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的
唯一格式。未加域名进白名单之前,Docker 任务的搜索 唯一格式。未加域名进白名单之前,Docker 任务的搜索
会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。 会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。
@@ -101,11 +161,164 @@ Docker 租户必须传容器内路径
字段与结果映射,未用真实 key 跑过;上生产前须用真实 字段与结果映射,未用真实 key 跑过;上生产前须用真实
key 在测试项目跑一遍,再谈默认链顺序是否合理。 key 在测试项目跑一遍,再谈默认链顺序是否合理。
## 编程搜索 scope=code(v2 新增,只做编程任务搜索)
选型:`web_search` 加 `scope="code"` 参数,不另立
`code_search` 工具。理由:同一条 provider 链、同一套
回退与 key 规则,只多一层域名限定;另立工具会复制
schema、handler 与播种文档两套,且调用方要在两个
工具间二选一。代码(`cloud/websearch.py` 的
`CODE_SEARCH_DOMAINS` / `search(..., scope=...)`)、
MCP 工具描述(`cloud/websearch_mcp.py` 的
`web_search` 描述与 `scope` enum)与本文三处一致,
只有 `general` / `code` 两个值,未知 scope 在发任何
HTTP 之前就报 `ValueError`,绝不静默退化成无限定搜索。
### 允许域清单(实际落代码的 15 个,含子域名)
- 官方文档:`docs.python.org`、`nodejs.org`、`go.dev`、
`pkg.go.dev`、`docs.rs`、`doc.rust-lang.org`、
`developer.mozilla.org`
- 代码托管:`github.com`、`githubusercontent.com`
(GitHub 自己的 raw host,blob 页之外原始文件所用)
- 包仓库:`pypi.org`、`npmjs.com`、`crates.io`
- 问答与安全:`stackoverflow.com`、`nvd.nist.gov`、`cve.org`
匹配规则是域名本身或其子域名(点边界匹配:
`gist.github.com` 算、`evilgithub.com` 不算)。
changelog / release notes 只有落在上述域名时才在
范围内(例如项目在 GitHub 的 Releases 页);没有
“项目官方域名自动识别”——不在清单里的项目官网
(如某框架自己的 docs 站)本版不返回,这是明确的
缺口,不是泛化成任意站的口子。
### 不在范围清单(scope=code 下一律过滤掉)
通用新闻、购物、社交、地点、机票酒店、天气、行情、
视频/图片站、博客聚合与其它任意站。`scope="code"`
不是“优先编程来源”,是硬过滤:非清单域的结果不
返回、不降级展示,只在 `attempted` 里记
`domain_filtered_out` 数量。
### 各后端如何限定(先核实请求字段再写代码)
- 原生支持,直接传:Tavily `include_domains`、
Exa `include_domains`、Perplexity Sonar chat
completions 的顶层 `search_domain_filter`(上限
20 个域,本清单 15 个,整单可传)。
- 原生不支持,不编造参数:Brave 网页搜索端点没有
`include_domains` 参数(多域 `site:` 改写语义未经
证实,不改写查询),SearXNG / DuckDuckGo 同样不传;
这三家只靠 `search()` 返回后的统一事后过滤。
- 双保险:原生限定的后端返回后同样再过一次事后
过滤,所以各后端在 code 作用域下的语义一致。
- 链语义:某后端结果被全部滤掉时,该后端记为
“no results”(`code-domain filter removed all N
results`),链继续走下一个后端;全部后端都空才
报错。
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
可以由调用方在当次调用里显式加域。选型是任务级参数
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
(只动 `search()` 签名与 MCP 透传,不动 worker、
unit、env、播种与主机配置);按调用隔离(某任务要
某项目官网,不会把该域放宽给所有任务——配置级清单
一旦写入就是全局放宽,安全姿态更差);用完即弃
(不持久化,下次调用不带就不生效)。
用法:`search(..., scope="code",
allowed_hosts=["docs.example-project.dev"])`,MCP
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
规则(全部由 `cloud/websearch.py` 的
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
- 只在 `scope="code"` 下生效;`scope="general"` 下传
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
不传时行为与本版之前完全一致(仍是 15 域)。
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
返回结果的 `code_domains` 即为该生效清单。
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
算),`evildocs.example-project.dev` 与
`docs.example-project.dev.evil.com` 都不算。
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
(可迭代但不是域名列表)、`*` 通配、带 scheme /
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
`ValueError`,且此时尚未发任何 HTTP。
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
出现扩展值;扩展值只出现在调用方自己那次结果的
`code_domains` 回显里(调用方自己指定的值回显给
自己,不是日志)。
- 与原生后端限定方式的关系,和现有逻辑一致:原生
支持的后端(Tavily / Exa 的 `include_domains`)直接传
生效清单;Perplexity 的 `search_domain_filter` 上限
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
事后过滤(事后过滤永远是权威,语义不变);原生不
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
参数,只靠 `search()` 返回后的统一事后过滤。
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
按指定 URL 抓取,没有域名白名单可扩展。
### 抓取回退链与 403 语义(web_fetch)
`web_fetch` 实际调 `fetch_best`,顺序固定:
1. Tavily extract(`POST https://api.tavily.com/extract`,
`urls:[url]`,取 `results[0].raw_content`;空结果或
`failed_results` 命中都算失败)——只在配了
`TAVILY_API_KEY` 时试。
2. Exa contents(`POST https://api.exa.ai/contents`,
`ids:[url]` + `text.maxCharacters`,取 `results[0].text`,
无 text 时取 highlights 拼接;空算失败)——只在
配了 `EXA_API_KEY` 时试。
3. 直接抓取(旧 `fetch_url` 的 HTML 清洗与截断)。
抽取与直接都失败时,错误同时写明两边失败,不伪造
页面。无任何抽取 key 时 `fetch_via_provider` 单独调会
明确报“no extract provider configured”,但 `fetch_best`
会直接走直接抓取(与旧 web_fetch 行为一致),不是
报错。localhost / 私有 IP 在抽取之前就拒绝,不会经
抽取商去探内网。
403 语义:抽取走的是搜索商 API 域名(`tavily.com`、
`exa.ai`,已在 Tinyproxy 白名单里),不直接碰目标站,
所以文档站本身不在白名单时抽取仍可通;只有回退到
直接抓取时,目标域不在白名单才会被代理 403 挡住。
这是 fail-closed:报错、不静默绕过、不承诺“任意
文档站都能直接抓”。本轨**没有改**
`deploy/egress/tinyproxy-filter`(也不改主机上的文件):
若将来主机侧要让直接抓取也通,需另行走主机变更,
filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
文件 644,extended 锚定写法会把允许域也挡成 403
(见部署教训);该变更不在本轨授权内。
### 安全与隔离(不变)
- key 只走 MCP 进程环境变量,绝不写进 config.toml、
日志、测试、文档;本轨测试用的 key 全是
`*-SECRET-*` 假值。
- worker 开关默认关;租户播种幂等、已有 section
不重写;Docker 环境变量走 extra_env +
`[mcp_servers.websearch.env]` 路径。
- `web_fetch` 仍只接受 http(s)、拒 localhost 与私有
IP,且它不是沙箱边界(danger-full-access 任务本
来就能 curl)。
- 老租户注意:播种助手从不覆盖内容已变的租户脚本
(既有防覆盖规则),所以 scope 只对新播种的租户
立即生效;老租户拿到新脚本需另行决策,不在本轨
改动该规则。
## 诚实边界 ## 诚实边界
- 本批只做代码 + 单测 + 测试租户目录的配置合并验证, - 本批只做代码 + 单测 + 测试租户目录的配置合并验证,
未推分支、未建 PR、未合并、未部署、未改生产 worker 未推分支、未建 PR、未合并、未部署、未改生产 worker
与 egress 配置、未跑真实 codex 任务。 与 egress 配置、未跑真实 codex 任务。
- 付费冻结本轨同样只在本地提交里:生产默认链要等部署
新代码后才变;解冻开关在主机上默认仍是关(除非主机
env 显式设为 `WEBSEARCH_ENABLE_PAID=1`,且那需要
主机侧改 env + 重启 unit,属部署动作)。
- DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能 - DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能
0 结果;它存在的意义是“没 key 也能走通链路”,不是 0 结果;它存在的意义是“没 key 也能走通链路”,不是
长期依赖的搜索后端。实测(2026-10-28,VM 出口): 长期依赖的搜索后端。实测(2026-10-28,VM 出口):
@@ -115,3 +328,80 @@ Docker 租户必须传容器内路径
- `web_fetch` 不是安全边界:danger-full-access 的任务 - `web_fetch` 不是安全边界:danger-full-access 的任务
本来就能 curl,fetch 只挡了最明显的非 http(s) 与 本来就能 curl,fetch 只挡了最明显的非 http(s) 与
本地私有地址,不应把它当沙箱用。 本地私有地址,不应把它当沙箱用。
- 编程搜索本轨未做(见回执):真实 key 的 live 测试、
主机 unit / filter / env 接线、GitHub API token
(本设计走网页搜索后端限定域名,不调 GitHub API,
所以不需要 token;真要 GitHub 代码内容搜索 API 是
另一功能,不在本轨)、老租户脚本升级。清单外项目
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
未推未部署),仍缺真实 key 的 live 验证。
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
后端:`fetch_best` 固定为 Tavily extract → Exa contents
→ 直接抓取。结论只写在这里,不改代码行为。
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
### 阶段 1:直接抓取能拿到什么、拿不到什么
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
链接(相对链接不进 `links` 列表)。所以:
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
静态正文、绝对链接;首屏静态内容本身就是正文的页面
不受影响。
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
中,直接抓取结果里没有它,也不把数据块原文泄漏进
文本);分页靠 JS 加载的后续项(mock 里第 2 页
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
只有第 1 页静态项,也拿不到该 API 地址本身)。
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
JS 发起的请求;这是结构性缺失,不是解析器 bug,
调解析参数补不回来。直接抓取在 JS 重型站上只能
给出“壳 + 首屏静态部分”,不能谎称是全文。
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
证明链路本身能补:`fetch_via_provider` 返回
`source=tavily-extract` 的完整文本;抽取与直接都失败时
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
mock 补不了、也证明不了的部分:
- 真实 provider 是否真的渲染该站的 JS(取决于各家
extract 实现是否跑无头浏览器、等多久、反爬策略),
mock 响应是“我假设它渲染了”,不是实测证据。
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
翻页或交互;多页内容仍需逐 URL fetch。
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
随后回退直接抓取会返回壳文本——调用方须以 `source`
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
### Live 对照(与 mock 分开写,未验证的不写成已验证)
- 无 key 的 SearXNG 路线:旧 VM 测试曾用
`SEARXNG_URL=https://searx.diante.me` 失败(`URLError:
Remote end closed connection without response`)——该失败
现已归因:https 是错的,主机 nginx 只听 80、无 443
vhost,https 在 TLS 层证书主机名不匹配失败,根本没到
SearXNG,不能据此断言服务挂了。2026-11-01 探针实测:
http 经同一 Tinyproxy 0.34 秒回 200、16 条结果,服务
活着;主机 env 仅 `SEARXNG_URL=http://searx.diante.me`
一行、无 `SEARXNG_ENGINES`,默认 pin 生效。容器内真实
任务派发仍是 blocker(不由本轨创建生产任务),live
对照在该项上仍记为**未完成**;测试文件里的 live 烟囱
测试默认跳过,只在 `WEBSEARCH_EVAL_LIVE_SEARXNG=1`
且配了 `SEARXNG_URL` 时才跑。
- 付费后端 live 效果(Tavily extract / Exa contents /
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
在拿到 key 之前,文档里关于 provider 渲染能力的任何
表述都只是 mock 层面的链路结论。
+201
View File
@@ -0,0 +1,201 @@
# 常驻监听网站闭环:独立架构与代码质量审核报告
审核员非实现者。审核对象:网站闭环 worktree
`~/workspace/codex-app-server-resident-website-loop`,分支
`feat-resident-website-loop`,头 `23725f9`(父 `561b389`,基底
网站版 `0722ca3`,`origin/main 622c51e`)。本审核在独立 worktree
`~/workspace/codex-app-server-resident-audit`(新分支
`feat-resident-website-loop-audit`,从 `23725f9` 起)上做,闭环
worktree、resident-task worktree、泛化 worktree、主 checkout
(feat-repo-picker)一律未动。
## 0. 评分标准(先定标准,再打分)
只评架构与代码质量两项,满分各 10 分。控制台 UI、真实外网、
生产 PG、双进程抢单实测、其它探针是其它维度/后续项,本轮不做,
也不用它们冒充或无故扣这两项的分;但若某项确实卡住架构或代码
质量本身,列为 blocker,不擅自定。
### 架构 10 分标准
- A1 分层干净:resident 模块自有状态机,与任务状态机、自动化
定义层互不干扰;server 只做路由/权限/审计,worker 只做驱动
边界调用;无循环依赖。
- A2 状态机可推理:running/paused/ended、暂停保现场、恢复续跑、
结束终态 409、失败退避、配额暂停不结束的语义在所有路径一致,
且并发下不被竞态破坏(认领、改名、暂停/结束中途轮询)。
- A3 驱动与认领正确:服务端不起定时器是明说的,实际驱动在
worker 轮次边界;claim 条件 UPDATE 是防重复的唯一关口;崩后
不锁死。
- A4 快照/变化语义干净:轮询廉价、仅变化建任务、首次只建基线、
每次轮询可查;变化摘录与游标推进在同一事务边界内。
- A5 安全边界在正确层:URL 校验、超时、1MB 截断、SSRF 防护不
只在创建时校验一次,抓取路径(包括重定向)也要守住;v1 的
DNS 解析后校验缺口必须明说,不得假装已解决。
- A6 可扩展结构:网站是本轮唯一用例,但快照/抓取与状态机/认领
不应缠成一团;未来加探针时状态机与驱动骨架不应重写(本轮
不实现其它探针,只审结构)。
### 代码质量 10 分标准
- Q1 命名、函数长度与复杂度可读;长函数(run_due)分支清晰,
不靠注释掩盖逻辑。
- Q2 无重复、无死代码、无测试值渗入生产代码;复用已有口径
(自动化 claim/配额/审计、环境名唯一索引先例)而不是另起一套。
- Q3 错误处理诚实:失败、配额耗尽、配额检查失败三条路不混淆;
审计与轮询记录不静默丢失;异常不炸驱动循环。
- Q4 测试可读、可隔离、可复现:本地 HTTP/DNS/no_proxy 等测试
手段干净还原、无环境泄漏;断言指向行为而非实现细节摆设。
- Q5 静态检查:居民差分内 ruff E、F 干净(仓库惯用 `%` 格式、
宽泛 except 等不在仓库门禁内的规则不计入;基底已有的 E、F
不计入本轮)。
## 1. 改动前分数
- 架构:9.0 / 10
- 代码质量:9.0 / 10
基底是好的:分层、状态机、claim、配额复用、轮询可查、测试
隔离(DNS/no_proxy 在 loop 测试里干净还原)都属实,定向 15 项
基线全过(见第 4 节)。扣分只来自下面逐项 findings,不是整体
推倒重来。
## 2. Findings 清单(改动前)
### 严重
- F1 名称唯一性是先查后插、无数据库约束:create_watch /
update_watch 先 SELECT 查重再 INSERT/UPDATE,两个并发请求
可同时通过检查、插出同项目同名 running 监听;server 的
environments 已有先例:`CREATE UNIQUE INDEX … WHERE status
!= 'archived'`。本轮用同款部分唯一索引
`WHERE status != 'ended'`(已结束名可重用)+ 完整性错误转
ValueError 修复。
- F2 暂停/结束与进行中轮询竞态:run_due 认领后只读一次状态,
之后若用户暂停/结束,失败/基线/未变化/配额/变化分支的 UPDATE
均不带状态条件,会把 paused/ended 行的 next_poll_at 改回
未来值、并可能在用户已结束之后仍建出变化任务。修复:认领后
复查状态;全部终局 UPDATE 带 `status='running'` 条件,条件
不满足时回滚、不建任务、不记轮询。
### 一般
- F3 抓取路径 SSRF 只守了创建时:default_fetch 用默认 opener,
重定向目标不校验,公开 URL 重定向到内网地址可绕过创建校验;
另 localhost.、单整数/十六进制/前导零等非规范 IP 写法可绕过
字面 IP 检查。修复:重定向处理器逐跳校验(相对跳转先 urljoin
再校验);校验时归一化主机(去尾点、*.localhost 挡掉)并拒绝
非规范数字 IP 写法。DNS 解析后校验仍属 v1 缺口,继续列为已知
偏差,不假装已解决。
- F4 配额门判定靠子串:`\"quota_check_failed\" not in gate` 把
配额门返回文本当协议用,超限文本一旦含该子串即误判。配额门
的真实契约是前缀(`quota_check_failed:` / `quota_exceeded:` /
None),修复为 `startswith` 判定,并补 changed_quota_check_failed
的定向测试(该分支此前无测试)。
- F5 row_get 重复造轮:resident 自带与 automations.row_get 逐字
同语义的 row_get,却已从 automations 导入另外两个函数。修复:
直接导入 automations.row_get,删本地定义。
- F6 server 审计动作名嵌套三元:`_resident_transition` 里
`\"ended\" if … else action + \"d\" if … else \"resumed\"` 可读性差,
且改动时易错。修复为显式动作映射字典,审计字符串不变。
### 建议
- S1 test_resident.py 导入了未用的 `time`(ruff F401,居民差分
内唯一 E、F 问题)。删除。
- S2 退避文档与代码不一致:`_backoff_seconds` 文档写“30、60、
120……秒”,代码实际下次轮询是 `now + interval + backoff`。
行为本身合理(不比间隔更频繁),文档误导。修复文档,行为不动。
- S3 loop 测试 tearDown 不够防御:setUp 中途失败时 tearDown
访问未建属性会掩盖原始失败。修复为 getattr 防御。
### 已核查、不是问题(不扣分)
- 审计行“写在 commit 之后会丢”:核查 `_auto_audit_row` 本身
执行 INSERT 后自己 commit,失败/配额暂停/变化三处审计行在
模拟关闭连接后仍可查,不是缺陷。
- server/worker 全量 ruff E、F 的其余项(如 worker.py:2781/2787
的 F821、server.py:10293 的 E501)经核对均为基底已有或由居民
插入导致的行号位移,非居民新增代码,不计入本轮。
- 仓库默认 ruff 全规则的 39 项(如 UP031 `%` 格式)与仓库惯用
写法冲突,不在仓库门禁内,不计入。
## 3. 修复(全部关闭,每项有对应测试或定向复核)
- F1 名称唯一性:`ensure_resident_tables` 新增部分唯一索引
`idx_resident_watches_name ON resident_watches(project_id, name)
WHERE status != 'ended'`(server environments 同款口径);
create/update 的 INSERT/UPDATE 包完整性错误转 ValueError
(sqlite3.IntegrityError / PostgresIntegrityError,调用方仍
返回 400)。测试:索引存在断言、绕过查重直接插报
IntegrityError、改名撞名报 ValueError。
- F2 暂停/结束中途轮询:认领后复查状态(非 running 直接返回
None,不改行);新增 `_guarded_watch_update`,失败/基线/
未变化/配额检查失败/变化推进游标的终局 UPDATE 全部带
`status='running'` 条件,条件不满足时回滚、不建任务、不记
轮询;变化分支先受保护推进游标、再建任务。测试:抓取回调
里经第二连接暂停监听,run_due 返回 None、无任务、游标与
next_poll_at 保持暂停态、轮询数不变。
- F3 抓取路径 SSRF:新增 `_SafeRedirectHandler`,重定向逐跳
urljoin 后复用 `validate_target_url`,不合格抛 ValueError
不跟随;`default_fetch` 改走 `build_opener`(默认处理器含
ProxyHandler,no_proxy 行为与 urlopen 同款);校验归一化
主机(去尾点、挡 *.localhost)并新增 `_is_noncanonical_
numeric_host` 拒绝单整数/十六进制/前导零八进制等非规范
数字 IP 写法。测试:localhost.、*.localhost、2130706433、
0x7f000001、0177.0.0.1、0x7f.0.0.1 均报 ValueError,规范
公网 IP 与带尾点域名仍放行;相对重定向跟随、私有绝对重
定向报 ValueError(DNS 映射测试主机,同 loop 测试口径)。
- F4 配额门判定:`\"quota_check_failed\" not in gate` 改为
`gate.startswith(\"quota_check_failed\")`,与配额门前缀契约
对齐。测试:注入返回 `quota_check_failed: boom` 的配额门,
变化轮询记 changed_quota_check_failed、状态仍 running、
游标不动、失败数 1、下次 = now + 间隔 + 30、无任务。
- F5 row_get 重复:删 resident 本地定义,直接导入
automations.row_get,内部调用点一字未动,模块依赖说明
同步更新。
- F6 server 审计动作名:嵌套三元改为显式映射字典
{pause: paused, resume: resumed, end: ended},审计字符串
不变,服务端测试的审计断言仍过。
- S1 删 test_resident.py 未用 `time` 导入;S2 `_backoff_seconds`
文档改为“退避增量,下次 = now + 间隔 + backoff”,行为不动;
S3 loop 测试 tearDown 改为 getattr 防御,setUp 中途失败不
再掩盖原始失败。
## 4. 测试数字(全部本轮实测,无删断言、无 skip、无改默认值)
- 定向基线(改动前):`cloud.tests.test_resident` +
`test_resident_server` + `test_resident_website_loop` = 15 项全过。
- 定向改动后:同三文件 = 20 项全过(基线 15 + 新增 5:URL
变体、唯一索引、配额检查失败、中途暂停、重定向),4.450 秒;
ruff E、F 在居民差分 4 文件上全部通过。
- 全量(CI 同款三条 discover,改动后、报告提交前跑出,之后
无任何代码或文案改动):顶层 tests 105 项全过;cloud/tests
1551 项全过、9 跳过(384.252 秒);deploy/tests 44 项全过
(单独重跑确认);合计 1700 项、0 失败、9 跳过。对账:闭环
基线 1695(105 + 1546 + 44)+ 新增 5 = 1700,无测试丢失、
无新失败。
- 覆盖率:本仓库 CI 无覆盖率门禁,不虚构覆盖率变化;新增
5 项定向测试即上述全量的一部分。
## 5. 复评分数
- 架构:10 / 10
- 代码质量:10 / 10
复评依据:F1/F2 两项严重 findings 已由数据库约束与状态条件
UPDATE 从机制上关闭(非仅文档说明),并有定向测试锁定;
F3 安全边界已从“创建时一次校验”补到“抓取重定向逐跳校验”
与非规范 IP 写法;F4/F5/F6/S1/S2/S3 全部关闭且测试全过。
剩余的已知偏差(控制台 UI、全文 hash 抖动、DNS 解析后 SSRF、
真实外网/生产 PG/双进程抢单未验)属其它维度/后续项,已在
第 6 节单独列出,不计入本轮两项分数,也不被用来冒充已完成。
## 6. 剩余 blocker 与未做事项
- 未做:未 push、未合并、未跑 deploy.py、未部署;不做其它探针、
不接控制台 UI、不做生产 PG 与双进程抢单实测。
- 已知偏差(其它维度,不计入本轮两项分数):控制台 UI 未接;
变化判定为全文 hash、无选择器提取,动态页面抖动可误报;
SSRF v1 不做 DNS 解析后校验;真实外网、生产 PG、双进程抢单
未验。
+236
View File
@@ -0,0 +1,236 @@
# 常驻监听 P0+P1 修复批验证摘要(本地实现与测试,未 push、未开 PR、未合并、未部署)
- 起点:`feat-resident-p0-fixes`,从 `origin/main 60997b1`(PR #275 合并头)起;
本批只改本 worktree,绝不动生产审核 worktree、闭环 worktree、主 checkout。
- 审核依据:生产审核报告
`~/workspace/codex-app-server-resident-prod-audit/verification/resident-prod-audit-summary.md`
第 4、5、7 节(动手前已重读);范围只限该报告的 P0 与 P1。
- 不做事项:控制台 UI、选择器/区域提取、其它探针
(server_status/agent_status/relay_cost)、磁盘清理、真实外网抓取
(真实抓取只用本地测试 HTTP 服务验证)、生产 PG、生产双 worker、
真实外网只读演练(仍是需用户拍板的后续窗口,见 blocker)。
- 红线:以下所有数值凡标“拟定默认值”者,均为本批实现工拟定的
默认值、供最终验收改,不是用户已拍板;摘要、代码注释与后续
PR 描述素材里一律按此口径写,不得写成用户已定。
## 1. 拟定默认值清单(逐项标拟定,环境变量可覆盖)
| 口径 | 拟定默认值 | 环境变量 | 说明 |
| --- | --- | --- | --- |
| 每监听每日变化任务上限 | 24 个 | `CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS` | 按 UTC 日切分;只数 `status='changed'` 且 `task_id` 非空的轮询行;超限记 `changed_capped`,游标不推进 |
| 变化抖动冷却 | 300 秒 | `CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS` | 上次建任务 300 秒内的新变化记 `changed_cooldown`,游标不推进,冷却过后复查仍可报 |
| resident_polls 保留期 | 90 天 | `CODEX_RESIDENT_POLL_RETENTION_DAYS` | 批量驱动每批开头 prune 一次;过期删、未过期保留;当日上限计数不受影响(保留期远大于 1 天) |
| 批量总预算 | 120 秒 | `CODEX_RESIDENT_BATCH_BUDGET_SECONDS` | 单批最坏 = 预算 + 单条超时上限 60 秒;超预算的剩余监听保持 due,下批再跑 |
| 代理模式 | 直连(默认) | `CODEX_RESIDENT_PROXY` | 未设/空/`direct` = 直连,不跟随进程代理;`env` = 显式跟随进程环境代理;其它值 = 显式代理 URL;两种代理模式都认进程 `no_proxy`/`NO_PROXY` |
| 重定向上限 | 10 跳 | (固定值) | 超过抛 `redirect` 结构化失败 |
其它沿用旧值不变:间隔下限 60 秒、抓取超时 1–60 秒(默认 10 秒)、
1MB 截断、变化摘录 2000 字、poll detail 500 字、批量单批上限 50 条。
## 2. P0 修复与测试证据
### S1. DNS 级 SSRF + 摘录口径(cloud/resident.py)
- 抓取前解析全部 A/AAAA(`resolve_public_addresses`):任一地址命中
私有/回环/链路本地/保留/组播/未指定即整批拒绝;IPv6 的 IPv4 映射
地址(`::ffff:127.0.0.1`)按映射后 IPv4 再判;解析失败/空结果/
非 IP 结果按 `dns` 失败,不放行。
- 直连建连 pin 住解析结果(`_PinnedHTTPConnection` /
`_PinnedHTTPSConnection`):TCP 对端就是解析时刻的 IP,不再二次
按主机名解析;https 的 SNI 与证书主机名仍是原始主机名(单测断言
`wrap_socket(server_hostname='example.com')` 且建连地址为 pin 的 IP),
TLS 校验不被 pin 破坏。
- 重定向每跳重复字面校验 + 解析门 + pin(相对跳转先 urljoin);
非 200 一律抛 `HttpStatusFetchError`,错误页正文直接丢弃。
- 摘录口径(拟定):摘录只来自已通过校验的公开抓取结果;私网
抓取在抓取层直接失败(`ssrf_blocked`)、不产生摘录、不建任务、
轮询 detail 只含 `reason=ssrf_blocked; …` 异常文本,绝不含抓取正文。
创建监听时仍只做字面校验(DNS 会变,创建时放行不等于抓取时放行),
权威关口在抓取层——见代码注释。
- 残留风险(诚实写明,不假装已封口):
- 代理模式下客户端解析门只是前置检查,真正建连的是代理方、由
代理方二次解析;pin 在代理模式下不生效,须靠代理自身的 egress
allowlist 兜底。生产部署若用代理模式,必须同时给代理配内网拦截。
- 直连 pin 已把解析-建连 TOCTOU 关到“解析结果本身”这一层,但
“解析时刻的 IP 在之后被重新分配给内网用途”的极端情形不在
本次封口承诺内。
- 旧 urllib 默认跟随进程代理的行为已改:默认直连。部署时若宿主
出口必须走代理,须显式配 `CODEX_RESIDENT_PROXY=env` 或代理 URL,
否则公网抓取会直连失败(记失败轮询,不建任务)。
- 测试:`test_resolve_public_rejects_private_and_mixed`(私网/混合/
映射地址/解析失败)、`test_ssrf_blocked_poll_has_no_excerpt_and_no_task`、
`test_pinned_https_uses_hostname_for_sni_not_ip`、
旧重定向测试改写(默认解析器 + getaddrinfo 补丁必拦;注入 resolver
返回公网测试 IP + socket 层重定向到本地服务时相对跳转走通)。
### S2. 驱动与延迟:选批量总预算,不新增独立驱动进程
- 论证:独立驱动进程的运维成本(新 unit、独立 DB 连接、启停监管)
是审核第 7 节留给用户拍板的项,本批不擅自新增;批量总预算是纯
代码改动、本地可测,且 claim 门已保证未来加驱动时多驱动不重复
轮询。预算把旧最坏 `50×60=3000` 秒 bound 到“预算 120 秒 + 单条
超时上限 60 秒”,剩余到期监听保持 due、下批再跑,不丢。
- 间隔只是下限、不是 SLA:`resident_metrics` 的 `due_count` /
`max_overdue_seconds` 直接度量“到期未跑”积压,进 `/metrics`;
任何对外口径仍须写“尽力而为、延迟无界”(模型任务一轮最长
1800 秒仍会拖后轮询,预算只 bound 单批,不承诺送达时限)。
- 测试:`test_batch_budget_stops_batch_and_keeps_rest_due`
(单调时钟注入:50 秒/条、预算 100 秒时跑 2 条即停,剩余 3 条仍 due)。
### G2. 成本上限与保留期
- 每日上限、抖动冷却、保留期见第 1 节清单;三者均持久化
(上限数当日轮询行、冷却用既有 `last_change_at`、保留期靠
`prune_old_polls` 的 DELETE),重启不丢计数。
- 页面每轮都变时:冷却期内只记 `changed_cooldown`、上限满后只记
`changed_capped`,游标不推进(变化不丢,冷却过后/次日仍可报),
任务数有界。
- 测试:`test_daily_cap_and_cooldown_suppress_without_losing_change`
(上限 2、冷却 300 秒的加速版:建任务→冷却抑制→冷却后建任务→
上限抑制,游标始终指向最后一次真实建任务的内容)、
`test_prune_old_polls_keeps_recent`(100 天前行删、当前行保留)。
## 3. P1 修复与测试证据
### S3. resident 表进 PG schema 与迁移清单
- `cloud/db.py` 的 `POSTGRES_SCHEMA` 新增 `resident_watches` /
`resident_polls`(含 `failure_reason` 列)与三条索引(含部分唯一索引
`WHERE status != 'ended'`);`cloud/migrations.py` 的
`MIGRATION_TABLES` 同步新增两表(TEXT 主键,无身份列,无需序列拨动)。
- 测试:`test_migration_coverage_includes_resident_tables`
(`migration_coverage()["missing_from_migration"] == ()`);
本地 PG 实测见第 5 节。
### G1. ensure 移出热路径 + 批量逐条隔离 + 死锁重试一次
- 热路径(`due_watches` / `claim_due_watch` / `run_due` /
批量循环)不再调 `ensure_resident_tables`;建表只在 server
`init_db`(SQLite 分支新增 `_res_ensure`,PG 分支走 schema)、
worker 生命周期首次批量、API 函数(create/list/get/update/
transition)做。热路径只在表确实缺失时 ensure 后重试一次。
- 批量入口逐条隔离:锁错误(PG `locked=True`、SQLite
`database is locked`)回滚后重试一次,仍失败记入 `errors`
列表并继续下一条;非锁异常同样隔离;预算耗尽信号以
`watch_id=None` 的条目记入 `errors`,供 worker 记日志。
- 测试:`test_batch_isolates_item_errors_and_retries_deadlock`
(死锁始终失败时只影响本条、其余照常;第一次死锁第二次成功时
errors 为空)、`test_hot_path_batch_executes_no_ddl`
(计数连接断言批量内零 CREATE 语句)。
### G3. 改目标 URL 强制走一次 baseline
- `update_watch` 改 `target_url` 时 `last_content_hash` 置 NULL,
下一轮只建基线、不建任务;改间隔/提示/名字不重置游标,
已排好的 `next_poll_at` 不变(语义保留,见代码注释)。
- 测试:`test_update_target_resets_baseline_but_interval_does_not`。
### G4. 显式代理配置 + 结构化失败原因
- 代理口径见第 1 节清单与 `resident_config()` 注释;默认直连
与环境变量的关系写在代码与本摘要里,不只靠隐式进程环境。
- 失败原因进 `resident_polls.failure_reason` 列(ensure 与 PG
schema 同步补列),detail 前缀同样写 `reason=<原因>`;
原因集:`dns / proxy / timeout / http_status / ssrf_blocked /
tls / redirect / network / invalid_url / unknown`。
- 测试:`test_proxy_config_defaults_direct_and_env_opt_in`、
`test_classify_fetch_errors`、`test_http_status_fetch_raises_structured_error`
(单跳层真实 404 + default_fetch 状态分支)。
### G5. resident 指标进 /metrics
- `GET /metrics` 新增 `resident` 段(成员项目过滤,与任务计数
同款):`watches_by_status`、`due_count`、`max_overdue_seconds`、
近 24h `polls_last_24h / failed_last_24h / changed_last_24h /
change_tasks_last_24h`、当前拟定 `config`;表缺失时
`{"available": False}`,不炸 `/metrics`。
- 测试:`test_resident_metrics_counts`(含到期积压与非成员过滤)、
`test_metrics_contains_resident_section`(HTTP 层)。
### P2 建议项(顺手修,不改语义)
- resident 四个 handler 的 502 统一为固定错误码
`resident_operation_failed`,详情只记服务端日志(`_server_log`),
不再透传可能含表名/列名的异常文本;测试
`test_resident_502_uses_fixed_error_code`(响应不含注入的细节串)。
- `_resident_get` 的 polls 异常记服务端日志(`_server_log.warning`),
响应仍保持 `polls=[] + polls_note="polls unavailable"` 的兼容形态;
测试 `test_resident_get_polls_failure_keeps_note`。
- 未做(列为后续,不硬修):改间隔立即生效的语义(仍为“暂停后
恢复才立即复查”,已写进注释与本摘要);选择器/区域提取;
控制台 UI。
## 4. 测试结果
- 定向(最后一次纯文案修订后重跑,见时间关系说明):
`python3 -m unittest cloud.tests.test_resident
cloud.tests.test_resident_server cloud.tests.test_resident_website_loop
cloud.tests.test_resident_p0_fixes` —— 38 项全过(旧 20 项 + 新 18 项)。
- ruff E,F(被碰文件):与改动前基线一致的 12 项,全是旧代码
(含 `cloud/worker.py` 的 F821、`cloud/server.py` 旧中文注释超长行等),
本批新增 0 项(新增的 3 项在自查中已修掉)。
- CI 同款三条 discover 全量(均在本批代码定稿后跑出):
- `python3 -m unittest discover -s cloud/tests`:1569 项,OK(跳过 9 项),393 秒;
- `python3 -m unittest discover -s deploy/tests`:44 项,OK,12 秒
(尾部三行中文传输日志为某测试退出时打印,不影响结果);
- `python3 -m unittest discover -s tests`:105 项,OK,12 秒。
- 时间关系:定向 38 项先于全量跑出;全量三条跑完后,本摘要
填入全量数字(纯文案修订,不碰代码与测试),随后按任务要求
重跑定向——重跑结果仍为 38 项全过,与全量结论一致;摘要定稿
在定向重跑之后,不再有代码或测试改动。
## 5. 本地 PG 16 验证单独记录(一次性库,测完已删库删脚本)
- 方式:`initdb --auth=trust` 一次性库(Unix socket,psycopg2
适配层),脚本放 `/tmp`(不进 worktree),测完停库、删库、
删脚本;结论只代表本地 PG,不代表生产 PG。
- 结果(全部通过):
- `init_postgres_schema` 后 `resident_watches` / `resident_polls` 齐全;
- 索引在位:`idx_resident_watches_project`、
`idx_resident_watches_name`(部分唯一,
`WHERE (status <> 'ended'::text)`)、`idx_resident_polls_watch`;
- 二次 `init_postgres_schema` + `ensure_resident_tables` 幂等;
- `migration_coverage` 无缺失;SQLite→PG 迁移 resident 两表各 1 行;
- 基线→变化→queued 任务、已结束名重用全部通过;
- 双连接并发:胜者 1 条 baseline 轮询、败者 0 条且 `errors` 为空、
全程无死锁异常、无整批 abort、poll 总数 1(G1 修复后败者干净返回;
旧死锁形态未再出现,但“双连接”不等于“生产双 worker”,见偏差)。
- 已清理:集群目录、socket 目录、日志与 `/tmp` 脚本均已删除,
`ls /tmp/resident_pg*` 确认无残留。
## 6. 已知偏差
- 本地 PG 结论不是生产 PG 结论;本地 HTTP 结论不是真实外网结论;
双连接结论不是生产双 worker 结论;真实外网只读抓取演练仍未做。
- 代理模式下 pin 不生效(见 S1 残留风险),须靠代理 egress allowlist。
- 旧闭环测试第 5 步轮询时刻由 `t0+400` 改为 `t0+540`:新冷却语义下
160 秒间隔的变化会被正确抑制,测试改按冷却期外复查(注释已写明),
不是为通过而删断言。
- 旧重定向测试与闭环测试的本地服务接法改为“resolver 返回公网测试
IP + socket 层重定向”:门与 pin 全程真实跑,只有传输层终点是
测试替身;门本身由默认解析器用例独立覆盖。
- `resident_config()` 每次调用读环境变量:改环境变量即生效,
但已启动进程的既有默认值不会跨进程同步(与其它环境变量配置同款)。
- 无效环境变量数值(非整数/<=0)回落默认值,不炸驱动(配置错误
按默认跑,运维侧须自行核对配置是否生效,见 /metrics 的 config 段)。
## 7. blocker(需用户拍板,本批不擅自定)
- 拟定默认值清单(第 1 节全部):上限 24、冷却 300 秒、保留期 90 天、
批量预算 120 秒、默认直连代理——供最终验收改。
- 是否新增独立驱动进程(本批选批量总预算,见 S2 论证;独立驱动
仍是可选项,需用户定运维成本)。
- SSRF 修复后的摘录口径最终版:本批拟定为“只来自已通过校验的
公开抓取结果”;是否进一步收紧为只留 hash/长度/脱敏摘录,仍由
用户定。
- 生产 PG 双 worker 实测窗口、真实外网只读抓取演练窗口、
代理模式下的代理 egress allowlist 配置(若生产用代理模式)。
- 控制台 UI 是否随修复批上(属产品范围,不计入本轮)。
## 8. 未做事项(本批明确不做)
- 未 push、未开 PR、未合并、未部署;工作区未提交(由 parent 定)。
- 未碰生产审核 worktree、闭环 worktree、主 checkout(feat-repo-picker)。
- 未做控制台 UI、选择器/区域提取、其它探针、磁盘清理、真实外网抓取。
- 未改生产默认值以外的任何行为口径;旧测试除上述两处时序/接法
调整外无删断言、无加 skip。
+69
View File
@@ -0,0 +1,69 @@
# 常驻监听任务(resident watch)验证摘要
## 基底与交付物(代码就绪,local only)
- 工作区:`~/workspace/codex-app-server-resident-task`
- 分支:`feat-resident-task`,基底 `origin/main` = `622c51e`(Merge PR #274,2026-11-02 fetch 到的头,与任务书一致)。
- 主 checkout(`~/workspace/codex-app-server`,`feat-repo-picker`,有脏文件)与其它现存 worktree 一律未动。
- 本轮提交前状态:`cloud/server.py` +187 行、`cloud/worker.py` +31 行,新增 `cloud/resident.py`、`cloud/tests/test_resident.py`、`cloud/tests/test_resident_server.py`、本摘要。
- **未做**:未 push、未合并到 main、未跑 `deploy.py`、未部署、未建生产任务、未动生产/主机 env/系统 key/sub2api/生产网关/仓库可见性/GitHub App 权限、未杀生产容器。
## 基底核实(先调查结论:main 上常驻监听不存在,不是重复建设)
- 任务状态机仍是 `queued -> running -> completed / failed / interrupted / merged / merge-failed`;`/pause` 只是把进行中回合任务标 `interrupted` 并置 `paused_by_user=1`(`cloud/server.py` `_pause_task`),不是可续跑的常驻暂停态——本实现不复用它,另建常驻态。
- 云端自动化(`cloud/automations.py`)定义层只有 `active / cancelled`,含 `automations / automation_runs` 表与 `run_due_automation` 的 claim 抢单、配额门;服务端本身不起定时器,到期触发靠外部触发器。本实现复用它的 claim 口径与配额函数,不另起冲突语义。
- goal 循环(`host.py` `run_goal_loop`,`max_rounds=0` 不限轮次)结束条件是模型宣告达成,不是“一直监听外部变化”;定时自动化每次到期建一个新任务跑一轮就结束。两者都不是常驻监听,本摘要不把它们说成已实现。
- main 上与暂停相关的已有机制(MCP `pause_task` 工具、长任务中断自动恢复)针对的都是回合任务,不是常驻监听;grep `pause|resident` 在 `cloud/*.py` 与 `host.py` 中未发现常驻监听实现。
## 设计抉择
- 新层不改任务表:`cloud/resident.py`(标准库 + 复用 `cloud.automations` 的 `_audit_row`、`_automation_quota_exceeded`)新增 `resident_watches` / `resident_polls` 两张表,避免改动任务状态机牵连 worker 认领、sweep、自动恢复。
- 状态机:`running -> paused -> running`;`running / paused -> ended`(终态)。`ended` 后 pause / resume / end / update 一律 409,恢复已结束的监听必须新建任务(文档与测试双锁定,见下)。
- 暂停保留现场:暂停只改 `status / paused_reason / next_poll_at`,`last_content_hash / last_poll_at / last_change_at / last_change_text` 不动;暂停期间 `due=False`,驱动不轮询。恢复时 `next_poll_at=now` 立刻复查,用旧游标比对——暂停期间的变化在恢复后第一次轮询即报出,不丢。
- 驱动(明说):服务端本身不起定时器,与自动化同款结论。实际驱动是 `cloud/worker.py` 的 `run_loop` 轮次边界调 `process_due_resident_watches()`(新增方法,异常只记日志、不炸循环;`self.resident_fetcher` 可注入假抓取供测试)。任何外部触发器也可直接调 `run_due_resident_watch / process_due_resident_watches`,两者都走 claim 门。
- 防重复轮询:`claim_due_watch` 条件 UPDATE(`status='running'` 且 `next_poll_at <= now`),抢到才把 `last_poll_at / last_claimed_at` 写成 now 并预置 `next_poll_at=now+interval`(崩在抓取中途时下个间隔仍重试,不因认领锁死永久停摆;完成后按成败重算)。
- 变化才花钱:轮询 = 廉价抓取 + 空白折叠后 sha256 比对,不跑模型。`baseline`(首次,只建游标)、`unchanged`(不建任务)、`changed`(才建一个 queued 任务,prompt = 用户处理提示 + 变化摘录)三种产出都记 `resident_polls`,可查。
- 配额边界:配额门只在“变化要建模型任务”那一刻过(复用自动化同款 `_automation_quota_exceeded`)。耗尽时**不结束、不丢变化**:转 `paused`(`paused_reason='quota_exceeded'`)、游标不推进、记 `changed_quota_paused`;配额恢复后恢复,同一变化仍能报出。配额检查本身失败时按瞬时失败退避,不转暂停,游标同样不推进。
- 安全边界:间隔下限 60 秒(与自动化同款)、上限 30 天;抓取超时 1–60 秒(默认 10 秒)、读满 1MB 即断;失败退避 30 / 60 / 120……秒、上限 3600 秒;目标 URL 仅 http/https、无 userinfo、字面 localhost/私有/回环/链路本地 IP 拒绝。
- API(`cloud/server.py`,权限与自动化同款:写 `create_task`、读 `read`,全部记审计):
- `POST /projects/{id}/resident-watches`(name / target_url / prompt 必填;interval_seconds 默认 3600;fetch_timeout_seconds 可选;创建不走配额门)
- `GET /projects/{id}/resident-watches`(附 `due_count`)、`GET /projects/{id}/resident-watches/{wid}`(附最近轮询 `polls`)
- `PATCH /projects/{id}/resident-watches/{wid}`(改名/提示/目标/间隔/超时;ended 409;改间隔不立即触发,下次轮询完成后按新间隔重算)
- `POST /projects/{id}/resident-watches/{wid}/pause|resume|end`
- 状态查询返回常驻态、游标(`last_content_hash`)、最后变化(`last_change_at / last_change_text`)、下次轮询(`next_poll_at`)、失败次数与暂停原因。
## 验收清单逐项结果
1. 能新建常驻任务(目标 URL、轮询间隔、变化处理提示):✅ 模块与服务端测试覆盖;非空壳——新建即到期,驱动下一轮即可认领。
2. 三态清晰(运行中/已暂停/已结束终态):✅ 状态机与 HTTP 409 语义双覆盖。
3. 暂停保留现场、暂停期间停轮询、恢复从游标续跑:✅ 测试 `test_pause_preserves_cursor_and_resume_reports_pause_period_change`(暂停期间变了,恢复后第一次轮询即报出)。
4. 结束终态、结束后不再静默重启、结束后恢复被拒绝:✅ `ended` 后 due 查询只认 `running`,pause / resume / end / update 一律 409(服务端错误文本含 terminal 并提示新建);测试覆盖。
5. 真正有驱动、claim 防重复、心跳与 stale 回收:✅ 驱动为 worker `run_loop` 轮次边界(不是服务端定时器,明说);claim 单胜者测试覆盖;stale 口径为“认领预置 next_poll_at,崩后下个间隔重试”+ worker 既有每轮心跳/sweep(常驻监听本身不另起心跳表,见已知偏差)。
6. 变化才花钱、每次轮询与变化可查:✅ baseline / unchanged 不建任务、changed 才建任务;`resident_polls` 逐轮记录。
7. 间隔下限、抓取超时、失败退避、除配额耗尽外不擅自结束、配额耗尽只转可恢复暂停并明示原因:✅ 全部测试覆盖;配额耗尽测试断言状态为 `paused` 且明确 `!= ended`。
8. 权限与审计沿用任务/自动化同款、pause / resume / end 记审计:✅ 服务端测试断言 `resident_watch_created / paused / resumed / ended` 审计行;跨项目行一律 404(不泄露存在性);局外人写操作被拒。
9. API 含暂停、恢复、结束与状态查询:✅ 见上;控制台 UI 本轮未接,列为已知偏差,不写成已完成。
## 真实测试数字
- 定向(改动后):`python3 -m unittest cloud.tests.test_resident cloud.tests.test_resident_server` —— 13 项(模块 10 + 服务端 3),全部通过。
- 全量(CI 同款三条 discover,改动后、仅最后一次文案注释修订前跑出;该修订只改 `update_watch` 的 docstring,不改任何代码逻辑,修订后定向 13 项与自动化相关 31 项重跑全部通过):
- `discover -s tests`:105 项,0 失败,0 跳过。
- `discover -s cloud/tests`:1544 项,0 失败,9 跳过。
- `discover -s deploy/tests`:44 项(单独重跑确认),0 失败。
- 合计 1693 项、0 失败、9 跳过。失败未靠删断言、加 skip、改默认值蒙混;新增测试 13 项全部计入上述 cloud 套件。
- 新模块定向覆盖率(`coverage run --source=cloud.resident`,仅定向 13 项):`cloud/resident.py` 312 语句、85% 覆盖。未覆盖主因:默认真实 HTTP 抓取(`default_fetch`,单元测试一律注入假抓取,不联网)与个别异常分支(PG 方言补列、配额检查失败分支);本仓库 CI(`.github/workflows/host-verify.yml`)本身没有覆盖率门禁,故不报“覆盖率变化”——全量覆盖率基线未在本轮重算,不虚构数字。
## 已知偏差与未做事项
- 控制台 UI 未接:`cloud/console.html` 是打包产物,本轮没有常驻监听面板;用户现阶段只能走 API。接 UI 是后续一轮,不在本轮验收内。
- 变化判定 v1 较粗:空白折叠后全文 sha256,没有选择器/区域提取;动态页面(广告、时间戳、随机推荐)本身会抖动成“变化”并建任务。缓解在后续:加 CSS 选择器或正文提取口径、或变化阈值。
- SSRF 防护 v1 只挡字面 localhost/私有 IP,不做 DNS 解析后校验(解析后重定向到内网、DNS rebinding 不在覆盖内);`websearch.fetch_url` 的同款字面口径已对齐,但不要把 v1 当成完整 SSRF 方案。
- 常驻监听没有独立心跳表:stale 回收靠认领预置的 `next_poll_at` + worker 既有心跳/sweep;驱动进程整体死亡时,已认领未完成的当次轮询要等到下个间隔才补跑(与自动化 claim 的同款取舍)。
- 未验证:真实外网抓取(单元测试不联网)、生产 PG 上的表迁移(`PRAGMA` 补列走自动化同款适配写法,但未在 PG 实测)、worker 常驻循环里的长时驱动(仅方法级测试,未跑真实 `run_loop` 长循环)、多 worker 并发抢单(仅条件 UPDATE 单胜者测试,未做双进程实测)。
- 未做:未 push、未合并、未部署;UI、选择器提取、DNS 级 SSRF、PG 实测、双进程抢单实测留待后续轮次。
## Blocker
- 无本轮 blocker(全部工作均为本地代码与本地测试,未触及只有用户能给的账号/密钥/商户/商店账号/宿主/维护窗口,也未发生花真钱、删生产数据、改生产可见性的动作)。
- 上线前仍需用户侧决策(不是本轮 blocker,不擅自定):控制台 UI 是否随本批上、默认轮询间隔与抓取超时是否按本轮默认值(60 秒下限/10 秒超时)拍板为生产默认值、PG 实测与部署窗口。
@@ -0,0 +1,125 @@
# 常驻监听网站闭环验证摘要
## 起点与交付物(代码就绪,local only)
- 工作区:`~/workspace/codex-app-server-resident-website-loop`
- 分支:`feat-resident-website-loop`,起点为上一轮网站版
`feat-resident-task` 的 `0722ca3`(基底 `origin/main 622c51e`)。
- 实现提交:`561b389`(仅新增端到端测试文件
`cloud/tests/test_resident_website_loop.py`,364 行)。
本摘要为其后提交,工作区提交后干净。
- 范围:只做网站用例。不做 server_status、agent_status、
relay_cost 等其它探针;不把 resident-watches 改名/泛化成
通用任务;不改 API、表名与任何生产默认值。
- 泛化 worktree(`~/workspace/codex-app-server-resident-generic`)
本轮一律未碰;resident-task worktree、主 checkout
(`feat-repo-picker`)及其它 worktree 一律未动。
- **未做**:未 push、未合并到 main、未跑 `deploy.py`、未部署、
未建生产任务、未动生产/主机 env/系统 key/sub2api/
生产网关/仓库可见性/GitHub App 权限、未杀生产容器、
未连真实外部网站。
## 闭环逐环证据(全部真实链路,不是注入假抓取)
测试文件:`cloud/tests/test_resident_website_loop.py`,2 项。
内容服务是线程内真实 `http.server`(可变内容、可停服重启);
控制面是真实 `ThreadingHTTPServer` + 真实 SQLite 临时库;
驱动走真实 `default_fetch`(urllib、超时、1MB 截断)。
生产 URL 校验一字不改:测试主机名 `website-loop.test` 不是
IP 字面、不是 localhost,所以校验通过;测试进程内只把该
主机名的 DNS 指到本地服务(相当于 /etc/hosts 的一条测试
条目),并给它加 `no_proxy`(见“测试中发现的两处假断点”)。
1. 创建(API):`POST /projects/{id}/resident-watches`
(name=site1、target_url=`http://website-loop.test:<本地端口>/data`、
prompt=“变化时汇报”、interval_seconds=60)返回 201,
状态 running、interval 60、fetch_timeout 10(生产默认)、
last_content_hash 为空、next_poll_at 在位;审计含
`resident_watch_created`。状态查询 `GET` 单条可看到
running、游标、下次轮询。
2. 基线轮询:worker 驱动 `process_due_resident_watches`
(fetcher=None,即真实 default_fetch)抓到 “version one”,
记 `baseline` 轮询、游标为其 sha256,任务数为 0;
状态查询的 polls 第 1 条就是 baseline。
3. 变化建任务:内容改成 “version two”,到期轮询记
`changed`、建出 1 个 queued 任务,任务 prompt 含用户提示
“变化时汇报”与摘录 “version two”,任务行与审计行
`resident_watch_change_task_created` 均可查;游标推进到
version two,last_change_text 含 version two。
4. 暂停:API pause 后状态 paused、next_poll_at 为空;暂停
期间内容再改成 “version three”,驱动返回空、轮询数仍为
2、游标仍为 version two——暂停期间的变化没有被轮询上报。
5. 恢复:API resume 后状态 running;下一次驱动用旧游标比
对,记 `changed` 并建第 2 个任务(prompt 含 version three),
不是从头建基线;游标推进到 version three。
6. 失败环:停掉内容服务后驱动记 `failed` 轮询、
consecutive_failures=1、next_poll_at 大于 now+60+30
(间隔 + 首次退避 30 秒)、状态仍 running、游标仍为
version three——驱动不崩、监听不擅自结束。恢复服务后
(内容仍为 version three)驱动记 `unchanged`、失败清零、
任务数仍为 2。
7. 结束:API end 后状态 ended、next_poll_at 为空、游标仍为
version three;驱动返回空;pause / resume / end / PATCH
更新一律 409;状态查询显示 ended 与最后游标/变化,
polls 总数为 5(baseline、changed、changed、failed、
unchanged)。
8. 驱动接线:单独一测试把 `run_all` 等无关边界打桩,只留
resident 驱动走真实抓取,调真实 `Worker.run_loop`
(interval=0.05、max_seconds=0.4);记录到 run_loop 轮次
边界至少调了 1 次 `process_due_resident_watches`,且状态
查询里出现 1 条 baseline 轮询、游标为 version one。
这证明接线在 run_loop 里,不只是测试直接调驱动函数。
## 断点结论:生产代码无断点,无生产改动
本轮逐环均通,生产代码(`cloud/resident.py`、`cloud/server.py`、
`cloud/worker.py`)一字未改,`git diff` 为空、新增文件只有
端到端测试。第一次跑出的 2 个失败都是测试脚手架问题,
不是生产断点,修法与证据如下,不许读成“修了生产代码”:
- 假断点 1——首次真实抓取 failed:测试环境自带 egress 代理,
urllib 默认把测试主机名也送代理并被代理断开。实测对照:
不加 no_proxy 时 `default_fetch` 报 RemoteDisconnected,
加 no_proxy 后返回 “version one”。修法只在测试 setUp 里
给 `website-loop.test` 加 no_proxy(测试进程内生效,撕掉
时还原),生产抓取与代理行为一字不改。
- 假断点 2——run_loop 接线测试 TypeError:实例属性上的
recording 函数不会自动绑定 self。修法改为闭包捕获 worker,
不改 worker 代码。
## 真实测试数字
- 定向(全量后重跑,含本轮 2 项):`python3 -m unittest
cloud.tests.test_resident cloud.tests.test_resident_server
cloud.tests.test_resident_website_loop` —— 15 项全部通过。
其中上一轮 13 项在新 worktree 动工前已先复核全过。
- 全量(CI 同款三条 discover,定向之后、全量之前无生产改动,
全量后又重跑定向 15 项全过,时间关系如实写明):
- `discover -s tests`:105 项,0 失败,0 跳过。
- `discover -s cloud/tests`:1546 项,0 失败,9 跳过
(上一轮 1544 + 本轮新增 2,对账无差)。
- `discover -s deploy/tests`:44 项,0 失败。
- 合计 1695 项、0 失败、9 跳过。失败未靠删断言、加 skip、
改默认值蒙混;生产间隔下限 60 秒、抓取超时默认 10 秒、
URL 校验口径一律未动,到期只用驱动 now 注入模拟时间流逝。
## 已知偏差与未做事项(未写成已完成)
- 控制台 UI 不在本轮闭环内:只能走 API,无常驻面板。
- 真实外网抓取未验:只验了本地 HTTP 服务 + 测试 DNS 映射,
不要把本轮说成真实外网验证;生产环境下的代理/DNS/证书
行为仍属后续验证项。
- 生产 PG 表迁移未验(只验 SQLite 临时库)、双进程抢单未验
(只验条件 UPDATE 单胜者的上一轮单测与本轮单驱动链路)。
- 其它探针本轮明确不做:server_status、agent_status、
relay_cost、通用任务改名一律不在本轮范围内。
- 变化判定仍是上一轮 v1 全文 hash,无选择器/区域提取;
SSRF 仍是 v1 只挡字面私有 IP,未做 DNS 解析后校验。
- 未做:未 push、未合并、未部署;架构与代码质量的 10 分
审核是父级后续独立步骤,本轮不自评、不给自己打 10 分。
## Blocker
- 本轮无 blocker(纯本地代码与本地测试,未触及只有用户能给
的账号/密钥/商户/商店账号/宿主/维护窗口,也未发生
花真钱、删生产数据、改生产可见性的动作)。