本地控制面 深度解析
本地控制面 模块拆解:从真实源码行号看 agentacct 如何把本地证据、隐私边界和控制面分开。
导入链与契约
control plane 子页面看 dashboard 为什么是产品投影而不是进程控制台。src/agent_chronicle/control_web.py:50 到 src/agent_chronicle/control_web.py:178 复制任务、合同、agent、workspace、attempt 和 approval 的安全字段,同时明确 external_codex 与 external_claude_code 只是 observed_only。tests/test_control_api.py:81 到 tests/test_control_api.py:120 验证路径、argv、PID、nonce、manifest id 都不出现在 API 响应里。 tests/test_control_api.py:123,这里再看 tests/test_control_api.py:151
50def sanitize_control_projection( 51 projection: ControlProjection, 52 *, 53 observed_tasks: Sequence[Mapping[str, Any]] = (), 54) -> dict[str, Any]: 55 """Return the complete product projection without execution authority. 56 57 In particular this omits workspace roots/store paths, argv templates, 58 process ids/groups/birth times, executable/cwd, ownership nonce hashes, 59 manifest ids, event payloads/hashes, and arbitrary failure strings. 60 """ 61 62 tasks = [item.to_dict() for item in sorted(projection.tasks.values(), key=lambda row: row.task_id)] 63 contracts = [ 64 { 65 "task_id": item.task_id, 66 "revision": item.revision, 67 "objective": item.objective, 68 "workspace_id": item.workspace_id, 69 "mutation_mode": str(item.permission_envelope.get("mutation_mode") or "not_recorded"), 70 "launch_approval_required": contract_requires_launch_approval(item.permission_envelope), 71 "budget_policy_ids": list(item.budget_policy_ids), 72 "success_checks": list(item.success_checks), 73 "created_at": item.created_at, 74 } 75 for item in sorted(projection.contracts.values(), key=lambda row: row.task_id) 76 ] 77 agents = [ 78 { 79 "agent_id": item.agent_id, 80 "display_name": item.display_name, 81 "adapter": item.adapter, 82 "execution_backend": item.execution_backend, 83 "capabilities": list(item.capabilities), 84 "command_registered": bool(item.argv_template), 85 "enabled": item.enabled, 86 "revision": item.revision, 87 "updated_at": item.updated_at, 88 } 89 for item in sorted(projection.agents.values(), key=lambda row: row.agent_id) 90 ] 91 workspaces = [ 92 { 93 "workspace_id": item.workspace_id, 94 "enabled": item.enabled, 95 "revision": item.revision, 96 "updated_at": item.updated_at, 97 } 98 for item in sorted(projection.workspaces.values(), key=lambda row: row.workspace_id) 99 ] 100 attempt_created_at = { 101 item.target_id: item.occurred_at 102 for item in projection.events 103 if item.action == "attempt_created" 104 } 105 attempts = [ 106 _safe_attempt(item, created_at=attempt_created_at.get(item.attempt_id)) 107 for item in sorted( 108 projection.attempts.values(), 109 key=lambda row: _stamp(attempt_created_at.get(row.attempt_id) or row.started_at or row.ended_at), 110 reverse=True, 111 ) 112 ] 113 approvals = [ 114 { 115 "approval_id": item.approval_id, 116 "task_id": item.task_id, 117 "attempt_id": item.attempt_id, 118 "kind": item.kind, 119 "requested_action": item.requested_action, 120 "state": item.state, 121 "expires_at": item.expires_at, 122 "decided_at": item.decided_at, 123 "consumed_at": item.consumed_at, 124 "revision": item.revision, 125 } 126 for item in sorted(projection.approvals.values(), key=lambda row: row.expires_at) 127 ] 128 budgets = [item.to_dict() for item in sorted(projection.budget_policies.values(), key=lambda row: row.policy_id)] 129 schedules = [item.to_dict() for item in sorted(projection.schedules.values(), key=lambda row: row.schedule_id)] 130 events = [ 131 { 132 "event_id": item.event_id, 133 "occurred_at": item.occurred_at, 134 "action": item.action, 135 "target_type": item.target_type, 136 "target_id": item.target_id, 137 "prior_state": item.prior_state, 138 "next_state": item.next_state, 139 } 140 for item in sorted(projection.events, key=lambda row: row.occurred_at, reverse=True)[:24] 141 ] 142 observed = [ 143 { 144 "task_id": str(item.get("task_id") or ""), 145 "title": str(item.get("title") or "Observed Task"), 146 } 147 for item in observed_tasks 148 if str(item.get("task_id") or "").startswith("task_") 149 ] 150 active_execution = {"pending", "launching", "running", "cancel_requested"} 151 summary = { 152 "task_count": len(tasks), 153 "attempt_count": len(attempts), 154 "active_attempt_count": sum(row["execution_state"] in active_execution for row in attempts), 155 "running_attempt_count": sum(row["execution_state"] in {"launching", "running", "cancel_requested"} for row in attempts), 156 "pending_approval_count": sum(row["state"] == "pending" for row in approvals), 157 } 158 return { 159 "schema_version": CONTROL_WEB_SCHEMA_VERSION, 160 "summary": summary, 161 "tasks": tasks, 162 "contracts": contracts, 163 "agents": agents, 164 "workspaces": workspaces, 165 "attempts": attempts, 166 "approvals": approvals, 167 "budget_policies": budgets, 168 "schedules": schedules, 169 "events": events, 170 "issues": [{"code": item.code, "line_number": item.line_number} for item in projection.issues], 171 "observed_tasks": observed, 172 "authority_boundary": { 173 "owned_execution": "controllable", 174 "external_codex": "observed_only", 175 "external_claude_code": "observed_only", 176 "external_orchestrators": "observed_only", 177 }, 178 }
核心实现,逐段拆
▸安全投影:没有执行权柄
src/agent_chronicle/control_web.py:181 到 src/agent_chronicle/control_web.py:205 展示第一层实现。这里不是抽象概念,而是把边界条件写成代码路径:调用方只能沿这些函数和常量进入,异常和漂移被计数或拒绝。 src/agent_chronicle/control_web.py:184,这里再看 src/agent_chronicle/control_web.py:190,这里再看 src/agent_chronicle/control_web.py:203
181def _hidden_fields(csrf_token: str, *, expected_revision: int | None = None) -> str: 182 revision = ( 183 f'<input type="hidden" name="expected_revision" value="{int(expected_revision)}">' 184 if expected_revision is not None 185 else "" 186 ) 187 return ( 188 f'<input type="hidden" name="csrf_token" value="{_esc(csrf_token)}">' 189 f'<input type="hidden" name="idempotency_key" value="web:{secrets.token_hex(20)}">' 190 + revision 191 ) 192 193 194def _badge(value: Any) -> str: 195 text = str(value or "unknown").replace("_", " ") 196 normalized = str(value or "unknown").lower() 197 if normalized in {"ready", "running", "succeeded", "verified", "approved", "consumed"}: 198 css = "status-ready" 199 elif normalized in {"failed", "lost", "rejected", "control_failure", "finding"}: 200 css = "status-error" 201 elif normalized in {"pending", "launching", "cancel_requested", "awaiting_approval", "policy_hold"}: 202 css = "status-finding" 203 else: 204 css = "status-missing" 205 return f'<span class="status {css}">{_esc(text.title())}</span>'
▸表单只创建待审合同
src/agent_chronicle/control_web.py:208 到 src/agent_chronicle/control_web.py:259 是第二层实现,重点在失败路径和原子性。页面关注的是这些分支如何避免把未验证输入提升为 authority。 src/agent_chronicle/control_web.py:212,这里再看 src/agent_chronicle/control_web.py:220,这里再看 src/agent_chronicle/control_web.py:256
208def _task_form(payload: Mapping[str, Any], csrf_token: str) -> str: 209 agents = [ 210 row 211 for row in payload.get("agents", []) 212 if isinstance(row, Mapping) 213 and row.get("enabled") 214 and row.get("adapter") == "local_argv" 215 and row.get("execution_backend") == "subprocess" 216 ] 217 workspaces = [row for row in payload.get("workspaces", []) if isinstance(row, Mapping) and row.get("enabled")] 218 if not agents or not workspaces: 219 return """<section class="section control-setup"> 220 <div class="section-header"><div><h2>Register owned execution</h2><p>agentacct needs one explicit workspace and one fixed argv adapter before it can start anything.</p></div></div> 221 <div class="control-command-grid"> 222 <div><strong>1 · Register this workspace</strong><code>agentacct control register-workspace --store-dir .agent-sentinel/state --root . --workspace-id project</code></div> 223 <div><strong>2 · Register a local argv adapter</strong><code>agentacct control register-agent --store-dir .agent-sentinel/state --agent-id local-agent --display-name "Local agent" --argv-json '["/absolute/command","arg"]'</code></div> 224 </div> 225 <p class="section-note">Registration does not adopt a running Codex, Claude Code, or orchestrator process. It creates a separate agentacct-owned launch definition.</p> 226 </section>""" 227 task_options = ['<option value="">Create a new planned Task</option>'] 228 for row in payload.get("observed_tasks", []): 229 if isinstance(row, Mapping): 230 task_options.append( 231 f'<option value="{_esc(row.get("task_id"))}">{_esc(row.get("title") or "Observed Task")}</option>' 232 ) 233 agent_options = "".join( 234 f'<option value="{_esc(row.get("agent_id"))}">{_esc(row.get("display_name") or row.get("agent_id"))}</option>' 235 for row in agents 236 ) 237 workspace_options = "".join( 238 f'<option value="{_esc(row.get("workspace_id"))}">{_esc(row.get("workspace_id"))}</option>' 239 for row in workspaces 240 ) 241 budget_options = '<option value="">No agentacct budget policy</option>' + "".join( 242 f'<option value="{_esc(row.get("policy_id"))}">{_esc(row.get("policy_id"))} · {_esc(row.get("action"))}</option>' 243 for row in payload.get("budget_policies", []) 244 if isinstance(row, Mapping) and row.get("enabled") 245 ) 246 return f"""<section class="section control-create"> 247 <div class="section-header"><div><h2>Plan an owned attempt</h2><p>Create a reviewable contract first. Nothing starts until you press Launch.</p></div></div> 248 <form class="control-task-form" method="post" action="/control/tasks"> 249 {_hidden_fields(csrf_token)} 250 <label><span>Task</span><select name="task_id">{''.join(task_options)}</select><small>Choose an observed Task only when you explicitly want its next attempt governed here.</small></label> 251 <label class="control-wide"><span>Objective</span><input name="objective" maxlength="4000" required placeholder="What should this attempt accomplish?"></label> 252 <label><span>Workspace</span><select name="workspace_id" required>{workspace_options}</select></label> 253 <label><span>Agent adapter</span><select name="agent_id" required>{agent_options}</select></label> 254 <label><span>Mutation boundary</span><select name="mutation_mode"><option value="read_only">Read only</option><option value="workspace_write">Workspace write · approval required</option></select></label> 255 <label><span>Budget policy</span><select name="budget_policy_id">{budget_options}</select></label> 256 <label class="control-wide"><span>Success checks</span><textarea name="success_checks" maxlength="4000" placeholder="One objective check per line"></textarea></label> 257 <div class="control-wide control-form-action"><p>agentacct freezes this contract and local launch identity. Agent-native permissions still apply.</p><button class="button" type="submit">Create pending attempt</button></div> 258 </form> 259 </section>"""
关键流程
▸实现判断
这里的设计重心是“先冻结合同,再启动 owned subprocess”。UI 里出现的 button 并不意味着它能控制外部 Codex;只有由 agentacct 注册的 local_argv adapter 才进入可启动集合。 这条路径还可以和主页面的 src/agent_chronicle/source_discovery.py:37、src/agent_chronicle/capture_runtime.py:41、src/agent_chronicle/control_web.py:50 连起来看:agentacct 的策略是收集证据但不收集敏感正文,展示状态但不泄露执行权柄。
边界 / 失败模式
src/agent_chronicle/control_web.py:50附近的入口如果收到不可信输入,只能产出降级或拒绝,不得写入 raw prompt/response。src/agent_chronicle/control_web.py:181的分支必须和测试夹具保持同步,避免客户端格式漂移后静默误归因。src/agent_chronicle/control_web.py:259附近的失败路径需要持续保留计数或错误码,方便 dashboard 暴露健康状态。