DSH / Atlas
2026-07-23implementedfeature

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 as an answerable card; 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: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution.

中文

问题

Web 承载层启动的是一个不受限的 agent(智能体):bootHost 组合了 dsh-bash-localdsh-fs-local,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 shell/fs + 审批 + 预设)。Web 协议约定其实早已预留了对应位置——approval/requested/approval/resolved 的 mux 帧、携带 ApprovalResponsePayloadPOST /api/respond、client 侧的 pendingBuffers——但 host 的 respond 只是一个 stub,没有应答者把 ctx.approval 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。

决策

Web 承载层组合与 acp-agent 相同的沙箱化产品路径:dsh-sandbox-localdsh-sandbox-policydsh-bash-sandboxdsh-fs-sandboxdsh-user-approvaldsh-permission-presets,由 BootHostOptions.sandbox 提供部署默认值(mode,默认 workspace-writeapprovalPolicy,默认 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.permissionssession.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 新增了 permissionssetPermission,审批应答则依托运行时的 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),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 BootHostOptions.sandbox 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。