Web UI permission presets and approval answering
Web UI 权限预设与审批应答
The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined shell/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/
English
Problem
The web host booted an unconfined agent: bootHost composed dsh-bash-local and dsh-fs-local, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined shell/fs + approval + presets) for months. The web wire contract had already reserved the seats — approval/requested/approval/resolved mux frames, POST /api/respond with ApprovalResponsePayload, client-side pendingBuffers — but the host respond was a stub, no answerer bridged ctx.approval to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable.
Decision
The web host composes the same sandboxed product path as the acp-agent composition: dsh-sandbox-local, dsh-sandbox-policy, dsh-bash-sandbox, dsh-fs-sandbox, dsh-user-approval, and dsh-permission-presets, with BootHostOptions.sandbox supplying the deployment defaults (mode, default workspace-write; approvalPolicy, default ask).
createApiProxy owns the approval pending registry. Its approval/request waterfall answerer reads the approval id from the session's just-appended approval/asked audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable approval/requested frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. respond routes by the echoed rpcId, validates ApprovalResponsePayload with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled.
The permission select rides two new unary RPCs, session.permissions and session.setPermission, projecting ctx.permissionPresets into a protocol-owned PermissionOption DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on agent/pre-step, because knob events must stay turn-enclosed for durable replay; the shared hasOpenTurn fold moved to dsh-session and replaced the private copies in dsh-user-approval, the ACP bridge, and the proxy.
Client-side, Session gained permissions and setPermission, and approval answering rides the runtime's PendingWait carrier. Per the designer draft, a pending approval takes over the composer: ApprovalPanel registers as a selector-routed entry of the conversation-declared conversation.composer chain (the ui-user-questions pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the PendingApproval domain face in ui-conversation's contract owns the ApprovalResponsePayload wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Pending questions take over through ui-user-questions, including the plan-review decision shape. The sidebar mirrors every blocked interaction with an amber warning dot that outranks the running ring, including during search: the manager tracks per-session approval and question request identities rather than reading Session instances, classifies only requests satisfying the plan-review composer's binary rendering constraints as plan reviews, and presents the first pending question ahead of concurrent approvals to match composer routing. Pre-instantiation buffering retains each live request identity, replaces replay duplicates, and removes resolved requests so sidebar status never outlives the answerable PendingWait; tracking clears per connection generation so reopen replay is authoritative. Sessions never instantiated still light their dot. The composer's bottom-row chip hosts the PermissionSelect control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session.
Alternatives considered
Reuse the ACP session/set_config_option shape on the web wire. Rejected: the web contract's unary method registry (RpcMethodMap + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature.
A session event for pending interactions instead of the live registries. Rejected: answerable requests are transient interaction state, not durable session data — approval's approval/asked/decided audit pair already logs its durable half. Persisting requested frames would re-ask dead questions on replay.
Registering the answerer only when a mux subscriber exists. Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window.
Optimistic card removal on click. Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead.
Consequences
Web sessions start confined (workspace-write + ask by default), and a sandbox-denial escalation reaches the browser through the approval channel. The deployment can widen or narrow the default through BootHostOptions.sandbox without touching the assembly. Question answering uses the same registry pattern (ui-user-questions over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage includes proxy registry and permission RPC suites, session-object and fixture suites, and the keyless Web smoke for approval answering and preset switching.
中文
问题
Web 承载层启动的是一个不受限的 agent(智能体):bootHost 组合了 dsh-bash-local 与 dsh-fs-local,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 shell/fs + 审批 + 预设)。Web 协议约定其实早已预留了对应位置——approval/requested/approval/resolved 的 mux 帧、携带 ApprovalResponsePayload 的 POST /api/respond、client 侧的 pendingBuffers——但 host 的 respond 只是一个 stub,没有应答者把 ctx.approval 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。
决策
Web 承载层组合与 acp-agent 相同的沙箱化产品路径:dsh-sandbox-local、dsh-sandbox-policy、dsh-bash-sandbox、dsh-fs-sandbox、dsh-user-approval 与 dsh-permission-presets,由 BootHostOptions.sandbox 提供部署默认值(mode,默认 workspace-write;approvalPolicy,默认 ask)。
createApiProxy 拥有审批 pending 注册表。它的 approval/request waterfall(瀑布式事件)应答者从会话刚追加的 approval/asked 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 approval/requested 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是约定早已承诺的刷新恢复基线。respond 按回显的 rpcId 路由,用既有的 zod schema 校验 ApprovalResponsePayload,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 approval/resolved;ask 的中断信号会以 cancelled 撤回该问题。
权限选择依托两个新的一元 RPC,session.permissions 与 session.setPermission,把 ctx.permissionPresets 投影为一个由协议拥有的 PermissionOption DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 agent/pre-step 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 hasOpenTurn 折叠迁入 dsh-session,取代了 dsh-user-approval、ACP bridge 与 proxy 中各自的私有副本。
在 client 侧,Session 新增了 permissions 与 setPermission,审批应答则依托运行时的 PendingWait 载体。按照设计师草稿,处于 pending 的审批会接管 composer:ApprovalPanel 注册为由会话声明的 conversation.composer 链中一个按选择器路由的条目(即 ui-user-questions 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 约定中的 PendingApproval 领域面拥有 ApprovalResponsePayload 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-user-questions 接管 composer,包括 plan-review 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 PendingWait 存续得更久;跟踪以连接代次为单位清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 PermissionSelect 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。
曾考虑的替代方案
在 Web 协议上复用 ACP 的 session/set_config_option 形状。 不予采纳:Web 约定的一元方法注册表(RpcMethodMap + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。
用一个会话事件承载 pending 交互,而非实时注册表。 不予采纳:可应答请求是瞬态的交互状态,而非持久的会话数据——审批的 approval/asked/decided 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。
仅在存在 mux 订阅者时才注册应答者。 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。
点击即乐观移除卡片。 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。
后果
Web 会话从受限状态启动(默认 workspace-write + ask),sandbox 拒绝升级会通过审批通道抵达浏览器。部署方可以通过 BootHostOptions.sandbox 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖包括 proxy 注册表与权限 RPC 单元测试套件、会话对象与 fixture 单元测试套件,以及针对审批应答与 preset 切换的无密钥 Web 冒烟测试。