Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791773b2d1 | ||
|
|
82d0e84371 | ||
|
|
b391fe9320 | ||
|
|
60997b138f | ||
|
|
6525bcafd0 | ||
|
|
23725f92e6 | ||
|
|
561b3891fb | ||
|
|
0722ca3fd8 | ||
|
|
622c51e7fb | ||
|
|
3a56f83114 | ||
|
|
0602d06136 | ||
|
|
e9fa43dd8b | ||
|
|
125b436ab3 | ||
|
|
1141d0d0f8 | ||
|
|
2a569ac6e3 | ||
|
|
847d1280c4 | ||
|
|
da7a9950fb | ||
|
|
397a46f850 | ||
|
|
27f48fe1ed | ||
|
|
08484302ac |
No files matched your search
@@ -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,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 /></>)}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+212
@@ -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 模块
|
||||||
|
|||||||
@@ -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 即丢);没有登录保险库(账号密码仍不进浏览器工具)。
|
||||||
|
需要这些时不要假装已有,先如实说明缺口。
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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/")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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 秒 + 缓冲),
|
||||||
|
|||||||
@@ -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$
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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 层面的链路结论。
|
||||||
@@ -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、双进程抢单
|
||||||
|
未验。
|
||||||
@@ -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。
|
||||||
@@ -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(纯本地代码与本地测试,未触及只有用户能给
|
||||||
|
的账号/密钥/商户/商店账号/宿主/维护窗口,也未发生
|
||||||
|
花真钱、删生产数据、改生产可见性的动作)。
|
||||||
Reference in new issue
Block a user