feat(desktop): add the browser operation ledger and browser contract

Problem: the desktop browser needs a durable record of every agent write
so that a crash between dispatch and receipt leaves an "unknown" outcome
that is shown to the user and never replayed, and the browser panel,
surface manager, executor and tools need one written contract before the
phase D implementation starts.

Fix: add desktop/internal/browserops with a versioned JSON ledger
(reserve before side effect, settle exactly once, reserved-at-crash becomes
unknown on reopen, duplicate IDs rejected forever, atomic writes) and the
DESKTOP_BROWSER documents in English and Chinese covering surfaces, trust,
capabilities, take-over, unknown writes, host calls and the remote path.

Verification: cd desktop && go test ./internal/browserops; go run ./tools/repolint clean.
This commit is contained in:
SivanCola committed 2026-09-09 01:12:10 +08:00
1 parent ec82b0e701
commit e79d256721
6 files changed
+604 -4989

No files matched your search

-4982
View File
File diff suppressed because it is too large. Load diff
-7
View File
@@ -1,7 +0,0 @@
allowBuilds:
esbuild: true
overrides:
dompurify: 3.4.13
mdast-util-gfm-autolink-literal: 2.0.0
undici: 7.29.0
+214
View File
@@ -0,0 +1,214 @@
// Package browserops records every agent write to a hosted browser before the
// write happens and settles it afterwards. A process death between the two
// leaves the operation "unknown", which is never retried automatically: the
// page may or may not have accepted the submission, and only a fresh snapshot
// can tell. The ledger is a versioned JSON file that the previous desktop
// shell never reads, so it can grow without touching core session formats.
package browserops
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"time"
)
const ledgerVersion = 1
// State is the outcome recorded for one operation.
type State string
const (
StateReserved State = "reserved"
StateExecuted State = "executed"
StateNotExecuted State = "not_executed"
StateUnknown State = "unknown"
)
var operationIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,100}$`)
// Operation is one reserved browser write and its settlement.
type Operation struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Generation string `json:"generation"`
TabID string `json:"tabId"`
Epoch uint64 `json:"epoch"`
DocumentToken string `json:"documentToken"`
Action string `json:"action"`
Digest string `json:"digest"`
State State `json:"state"`
ReservedAt time.Time `json:"reservedAt"`
SettledAt time.Time `json:"settledAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type ledgerFile struct {
Version int `json:"version"`
Operations map[string]*Operation `json:"operations"`
}
// Ledger is the durable operation log for one desktop data home.
type Ledger struct {
path string
mu sync.Mutex
file ledgerFile
now func() time.Time
}
var (
ErrDuplicateOperation = errors.New("browser operation id already recorded")
ErrInvalidOperationID = errors.New("browser operation id must match [A-Za-z0-9_-]{1,100}")
ErrUnknownOperation = errors.New("browser operation not reserved")
ErrAlreadySettled = errors.New("browser operation already settled")
)
// Open loads or creates the ledger. Operations left reserved by a previous
// process are marked unknown before anything else can run.
func Open(path string) (*Ledger, error) {
l := &Ledger{path: path, now: func() time.Time { return time.Now().UTC() }}
data, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
l.file = ledgerFile{Version: ledgerVersion, Operations: map[string]*Operation{}}
return l, nil
case err != nil:
return nil, err
}
if err := json.Unmarshal(data, &l.file); err != nil {
return nil, fmt.Errorf("browser ledger %s: %w", path, err)
}
if l.file.Version != ledgerVersion {
return nil, fmt.Errorf("browser ledger %s: unsupported version %d", path, l.file.Version)
}
if l.file.Operations == nil {
l.file.Operations = map[string]*Operation{}
}
recovered := false
for _, op := range l.file.Operations {
if op.State == StateReserved {
op.State, op.SettledAt, op.Reason = StateUnknown, l.now(), "process exited before settlement"
recovered = true
}
}
if recovered {
if err := l.persistLocked(); err != nil {
return nil, err
}
}
return l, nil
}
// Reserve durably records the operation before any side effect. A repeated
// ID fails even when the earlier attempt is unknown: the caller must read the
// page again and mint a new ID instead of replaying.
func (l *Ledger) Reserve(op Operation) error {
if !operationIDRe.MatchString(op.ID) {
return ErrInvalidOperationID
}
l.mu.Lock()
defer l.mu.Unlock()
if _, exists := l.file.Operations[op.ID]; exists {
return ErrDuplicateOperation
}
op.State = StateReserved
op.ReservedAt = l.now()
op.SettledAt = time.Time{}
stored := op
l.file.Operations[op.ID] = &stored
if err := l.persistLocked(); err != nil {
delete(l.file.Operations, op.ID)
return err
}
return nil
}
// Settle records the outcome of a reserved operation exactly once.
func (l *Ledger) Settle(id string, state State, reason string) error {
if state == StateReserved {
return fmt.Errorf("browser operation %s: cannot settle to reserved", id)
}
l.mu.Lock()
defer l.mu.Unlock()
op, ok := l.file.Operations[id]
if !ok {
return ErrUnknownOperation
}
if op.State != StateReserved {
return ErrAlreadySettled
}
previous := *op
op.State, op.SettledAt, op.Reason = state, l.now(), reason
if err := l.persistLocked(); err != nil {
*op = previous
return err
}
return nil
}
// Lookup returns a copy of the recorded operation.
func (l *Ledger) Lookup(id string) (Operation, bool) {
l.mu.Lock()
defer l.mu.Unlock()
op, ok := l.file.Operations[id]
if !ok {
return Operation{}, false
}
return *op, true
}
// Unsettled lists operations whose outcome is unknown, oldest first, so the
// UI can show the user what may have reached a website.
func (l *Ledger) Unsettled() []Operation {
l.mu.Lock()
defer l.mu.Unlock()
var out []Operation
for _, op := range l.file.Operations {
if op.State == StateUnknown {
out = append(out, *op)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ReservedAt.Before(out[j].ReservedAt) })
return out
}
func (l *Ledger) persistLocked() error {
data, err := json.MarshalIndent(&l.file, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
return err
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return err
}
tmp := l.path + "." + hex.EncodeToString(suffix) + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, l.path)
}
+135
View File
@@ -0,0 +1,135 @@
package browserops
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func newOp(id string) Operation {
return Operation{ID: id, SessionID: "s1", Generation: "g1", TabID: "tab1", Epoch: 3, DocumentToken: "doc", Action: "click", Digest: "abc"}
}
func TestReserveThenSettle(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops.json")
l, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-1")); err != nil {
t.Fatal(err)
}
got, ok := l.Lookup("op-1")
if !ok || got.State != StateReserved || got.ReservedAt.IsZero() {
t.Fatalf("after reserve: %+v ok=%v", got, ok)
}
if err := l.Settle("op-1", StateExecuted, ""); err != nil {
t.Fatal(err)
}
if err := l.Settle("op-1", StateExecuted, ""); !errors.Is(err, ErrAlreadySettled) {
t.Fatalf("second settle: %v", err)
}
if err := l.Settle("missing", StateExecuted, ""); !errors.Is(err, ErrUnknownOperation) {
t.Fatalf("settle unknown id: %v", err)
}
reopened, err := Open(path)
if err != nil {
t.Fatal(err)
}
got, _ = reopened.Lookup("op-1")
if got.State != StateExecuted || got.SettledAt.IsZero() {
t.Fatalf("after reopen: %+v", got)
}
}
func TestDuplicateAndInvalidIDs(t *testing.T) {
l, err := Open(filepath.Join(t.TempDir(), "ops.json"))
if err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-1")); err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-1")); !errors.Is(err, ErrDuplicateOperation) {
t.Fatalf("duplicate: %v", err)
}
if err := l.Settle("op-1", StateUnknown, "receipt lost"); err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-1")); !errors.Is(err, ErrDuplicateOperation) {
t.Fatalf("duplicate after unknown must still be rejected: %v", err)
}
for _, bad := range []string{"", "has space", "x/y", string(make([]byte, 101))} {
if err := l.Reserve(newOp(bad)); !errors.Is(err, ErrInvalidOperationID) {
t.Errorf("id %q: %v", bad, err)
}
}
}
func TestReservedBecomesUnknownAfterCrash(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops.json")
l, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-1")); err != nil {
t.Fatal(err)
}
if err := l.Reserve(newOp("op-2")); err != nil {
t.Fatal(err)
}
if err := l.Settle("op-2", StateNotExecuted, "element missing"); err != nil {
t.Fatal(err)
}
reopened, err := Open(path)
if err != nil {
t.Fatal(err)
}
unknown := reopened.Unsettled()
if len(unknown) != 1 || unknown[0].ID != "op-1" || unknown[0].Reason == "" {
t.Fatalf("unsettled after crash: %+v", unknown)
}
if got, _ := reopened.Lookup("op-2"); got.State != StateNotExecuted {
t.Fatalf("settled op must be untouched: %+v", got)
}
}
func TestUnsupportedVersionAndCorruptFile(t *testing.T) {
dir := t.TempDir()
v2 := filepath.Join(dir, "v2.json")
if err := os.WriteFile(v2, []byte(`{"version":2,"operations":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Open(v2); err == nil {
t.Fatal("version 2 must be rejected")
}
corrupt := filepath.Join(dir, "corrupt.json")
if err := os.WriteFile(corrupt, []byte(`{`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Open(corrupt); err == nil {
t.Fatal("corrupt file must be rejected, never silently reset")
}
}
func TestPersistIsAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ops.json")
l, err := Open(path)
if err != nil {
t.Fatal(err)
}
l.now = func() time.Time { return time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC) }
if err := l.Reserve(newOp("op-1")); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "ops.json" {
t.Fatalf("temp files must not survive a successful write: %v", entries)
}
}
+143
View File
@@ -0,0 +1,143 @@
# Desktop browser
[简体中文](DESKTOP_BROWSER.zh-CN.md)
The desktop browser is a native Chromium surface inside the Reasonix window
that the user and the agent operate together. Websites render in Electron
`WebContentsView`s owned by the shell; every agent capability goes through the
Go desktop service so that local and remote agents, approvals, cancellation,
evidence and operation records share one implementation. This document is the
contract between the browser panel, the shell's surface manager, the Go
`BrowserExecutor` and the tools the agent sees.
```text
agent tool call ─▶ Go BrowserExecutor ─▶ ledger.reserve ─▶ host/browser.* ─▶ WebContentsView
▲ │ │
└── result/evidence ┴──────────── ledger.settle ◀─────────┘
user input on the page ─▶ guest preload ─▶ shell: epoch++ ─▶ desktop/event browser:takeover
```
## Surfaces and trust
- The application window is trusted. Website views are not: sandbox on,
context isolation on, no Node integration, no application preload, no
`reasonix://` access. Their only preload observes trusted user input to
request a take-over and exposes nothing to the page.
- Partitions: `persist:browser` is the shared login partition for one
Reasonix data home; `temp:<id>` partitions are in-memory and discarded when
their last tab closes. Remote Serve windows and MCP App frames use their
own partitions and never share the browser partition.
- The `BrowserSurfaceManager` in the shell owns creation, visibility, bounds,
focus and destruction. The React panel submits a layout rectangle; the shell
validates it against the window and applies it. Any application overlay
(dialogs, menus, command palette) sets a single overlay state that hides
every native website view so a page can never paint over the app.
- One task owns its tabs. A tab carries `{tabID, taskID, sessionID, epoch,
partition}`; switching the visible tab never retargets a running plan.
## Panel
The right workspace gains a browser panel: tab strip per task, address bar,
back/forward, reload, zoom, load errors with retry, download list, DevTools
toggle. Restored tabs keep only `{url, title}` for safe navigation entries;
no form state, credentials or replayable submissions are persisted. Tab
metadata and the operation log are new versioned files under the desktop
state directory (`browser/tabs-v1.json`, `browser/operations-v1.json`).
## Agent capabilities
Tools are registered through the existing capability registry as one
`browser` capability with these operations. Every write goes through the
normal approval policy (`ask`, `allow`, `deny`), the normal cancellation
context and the evidence trajectory; there is no separate browser approval
system.
| Tool | Reads/Writes | Purpose |
| --- | --- | --- |
| `browser_tabs` | read | list the task's tabs with URL, title, loading state |
| `browser_open` | write | open a tab (shared or temporary partition) at a URL |
| `browser_navigate` | write | navigate the bound tab (URL, back, forward, reload) |
| `browser_snapshot` | read | structural snapshot with element references |
| `browser_screenshot` | read | PNG of the viewport or an element, returned as an image |
| `browser_click` | write | click a referenced element (trusted mouse events at its centre) |
| `browser_type` | write | type text into a referenced element with trusted key events; optional submit |
| `browser_press` | write | press a key or chord |
| `browser_scroll` | write | scroll the viewport or an element |
| `browser_select` | write | choose options in a select |
| `browser_upload` | write | attach task files to a file input |
| `browser_download` | read | wait for or list downloads of the tab |
| `browser_close` | write | close a tab |
Snapshot format: an accessibility-style tree (`role "name" [state] ref=e12`)
produced in an isolated world of the main frame and each reachable frame.
References are bound to `{tabID, frameID, documentVersion}`; a navigation,
page replacement or take-over invalidates every earlier reference, and an
action with a stale reference returns `not_executed: stale reference` rather
than guessing. Inputs and clicks are dispatched as trusted input events
through the shell, never by assigning element values, so React-controlled
inputs, custom widgets and dynamic pages behave as they would for a user.
Screenshots and downloads never travel through control frames: the shell
writes them into the task's temporary directory that Go names in the request
and returns the path; Go turns the file into an image or file result through
the existing channels. Uploads read only files the task owns; remote tasks
stage files through the existing SFTP transfer and the task temporary
directory, so a remote path is never treated as a local one.
## Ownership, take-over and unknown writes
- A grant `{sessionID, taskID, runtimeGeneration, tabIDs, expiresAt}` is
minted by Go when the task starts using the browser and revoked when the
service restarts, the session changes, the connection generation changes or
the task ends. The shell rejects `host/browser.*` calls whose grant is not
current.
- User keyboard, mouse or touch input on a page increments that tab's epoch
immediately in the shell. Pending and queued actions for the old epoch are
cancelled, Go receives `browser:takeover`, the agent's next call must read
the page again. Login pages, captchas and passkeys are always a user
hand-over: while the tab is in `human` mode the agent cannot read or act on
it.
- Every write reserves an operation `{operationID, sessionID, generation,
tabID, epoch, documentToken, action, digest}` in the ledger before the
shell executes it. The shell reports `executed` or `not_executed` with a
reason; a lost reply, a crash or a service restart leaves the operation
`unknown`. Unknown operations are shown to the user and are never replayed
automatically; a reused `operationID` is rejected forever.
- Renderer crash of a website view cancels only that tab's actions and
reloads the last safe URL in `human` mode. Application renderer crash
pauses all browser actions until the UI re-attaches.
## Host calls
| Method | Purpose |
| --- | --- |
| `host/browser.grant` `revoke` | install or revoke a grant |
| `host/browser.tabs.list` `open` `close` `activate` `navigate` | tab lifecycle bound to a grant |
| `host/browser.snapshot` | structural snapshot for a tab, returns `documentToken` |
| `host/browser.act` | one reserved action; returns `{executed, reason, documentToken}` |
| `host/browser.screenshot` | capture to a task-owned file path |
| `host/browser.downloads` | list or wait for downloads of a tab |
| `host/browser.layout` | apply the panel rectangle and overlay state |
Events from the shell: `browser:tabs` (tab list changes), `browser:takeover`
(`{tabID, epoch, reason}`), `browser:download` (progress), `browser:crash`.
## Remote agents
A remote Reasonix agent reaches the local browser through the existing SSH
connection and forward manager as a restricted host RPC carrying the same
`BrowserExecutor` contract. Grants are bound to the remote connection
generation, session and task; disconnect, reconnect or session switch revokes
them. Browser grants and provider-proxy credentials are separate; no shared
token. Older remote Serve builds negotiate capabilities and simply do not
advertise the browser, keeping every existing remote feature.
## Acceptance
Iframes, dynamic DOM, controlled inputs, popups, upload and download,
navigation history, temporary partitions, shared and isolated logins;
take-over before approval, after approval before dispatch, lost receipt after
execution, restart after crash, duplicate operation IDs; remote SSH drop,
generation change, stale grant, cross-session misrouting. The real tasks in
[the migration record](DESKTOP_SHELL_MIGRATION.md#acceptance-gates) close
the phase.
+112
View File
@@ -0,0 +1,112 @@
# 桌面浏览器
[English](DESKTOP_BROWSER.md)
桌面浏览器是 Reasonix 窗口内由用户与 Agent 共同操作的原生 Chromium 表面。网站在壳
拥有的 Electron `WebContentsView` 中渲染;Agent 的每项能力都经过 Go 桌面服务,因此
本地与远程 Agent、审批、取消、证据与操作记录共享同一实现。本文是浏览器面板、壳的
表面管理器、Go `BrowserExecutor` 与 Agent 可见工具之间的契约。
```text
Agent 工具调用 ─▶ Go BrowserExecutor ─▶ ledger.reserve ─▶ host/browser.* ─▶ WebContentsView
▲ │ │
└── 结果 / 证据 ──────┴─────────── ledger.settle ◀──────────┘
用户在页面输入 ─▶ guest preload ─▶ 壳:epoch++ ─▶ desktop/event browser:takeover
```
## 表面与信任
- 应用窗口可信。网站视图不可信:sandbox 开启,context isolation 开启,无 Node
integration,无应用 preload,不能访问 `reasonix://`。它们唯一的 preload 只观察可信
用户输入以请求接管,不向页面暴露任何东西。
- 分区:`persist:browser` 是同一 Reasonix 数据目录下共享的登录分区;`temp:<id>` 分区
只存在于内存,最后一个标签关闭即丢弃。远程 Serve 窗口与 MCP App 框架使用各自分区,
从不与浏览器分区共享。
- 壳中的 `BrowserSurfaceManager` 拥有创建、可见性、边界、焦点与销毁。React 面板提交
布局矩形,壳按窗口校验后应用。任何应用覆盖层(对话框、菜单、命令面板)设置单一
覆盖状态,隐藏所有原生网站视图,页面永远不能盖住应用。
- 一个任务拥有自己的标签。标签携带 `{tabID, taskID, sessionID, epoch, partition}`;
切换可见标签不会把运行中的计划转到别的标签。
## 面板
右侧工作区加入浏览器面板:任务内标签栏、地址栏、前进/后退、刷新、缩放、可重试的
加载错误、下载列表、DevTools 开关。恢复的标签只保留安全导航条目 `{url, title}`;不
持久化表单状态、凭据或可重放提交。标签元数据与操作日志是桌面状态目录下新增的带
版本文件(`browser/tabs-v1.json`、`browser/operations-v1.json`)。
## Agent 能力
工具通过现有 capability 注册表注册为一个 `browser` 能力,包含下列操作。每个写操作
都走正常审批策略(`ask`、`allow`、`deny`)、正常取消上下文与证据轨迹;没有独立的
浏览器审批系统。
| 工具 | 读/写 | 用途 |
| --- | --- | --- |
| `browser_tabs` | 读 | 列出任务标签及 URL、标题、加载状态 |
| `browser_open` | 写 | 在共享或临时分区打开标签到某 URL |
| `browser_navigate` | 写 | 导航绑定标签(URL、后退、前进、刷新) |
| `browser_snapshot` | 读 | 带元素引用的结构快照 |
| `browser_screenshot` | 读 | 视口或元素的 PNG,作为图像返回 |
| `browser_click` | 写 | 点击引用元素(在其中心派发可信鼠标事件) |
| `browser_type` | 写 | 用可信键盘事件向引用元素输入文本;可选提交 |
| `browser_press` | 写 | 按键或组合键 |
| `browser_scroll` | 写 | 滚动视口或元素 |
| `browser_select` | 写 | 选择下拉选项 |
| `browser_upload` | 写 | 把任务文件附加到文件输入 |
| `browser_download` | 读 | 等待或列出标签的下载 |
| `browser_close` | 写 | 关闭标签 |
快照格式:在主框架及每个可达框架的隔离世界中生成的无障碍风格树
(`role "name" [state] ref=e12`)。引用绑定 `{tabID, frameID, documentVersion}`;导航、
页面替换或接管使所有早先引用失效,带过期引用的动作返回 `not_executed: stale reference`
而不是猜测。输入与点击由壳以可信输入事件派发,从不给元素赋值,因此 React 受控输入、
自定义控件与动态页面的行为与真实用户一致。
截图与下载从不经过控制帧:壳把它们写入 Go 在请求中指定的任务临时目录并返回路径;
Go 通过现有通道把文件转成图像或文件结果。上传只读取任务拥有的文件;远程任务通过
现有 SFTP 传输与任务临时目录中转,远程路径永远不会被当作本机路径。
## 所有权、接管与未知写入
- 授权 `{sessionID, taskID, runtimeGeneration, tabIDs, expiresAt}` 在任务开始使用浏览器
时由 Go 签发,并在服务重启、会话变化、连接世代变化或任务结束时撤销。壳拒绝授权
不是当前的 `host/browser.*` 调用。
- 用户在页面上的键盘、鼠标或触控输入立即在壳中递增该标签的 epoch。旧 epoch 的待执行
与排队动作被取消,Go 收到 `browser:takeover`,Agent 的下一次调用必须重新读取页面。
登录页、验证码与 passkey 始终交由用户:标签处于 `human` 模式时 Agent 不能读取或操作它。
- 每个写操作在壳执行前先在账本中预留 `{operationID, sessionID, generation, tabID, epoch,
documentToken, action, digest}`。壳报告 `executed` 或带原因的 `not_executed`;回复丢失、
崩溃或服务重启使操作保持 `unknown`。未知操作展示给用户,绝不自动重放;重复的
`operationID` 永久被拒绝。
- 网站视图渲染进程崩溃只取消该标签的动作,并以 `human` 模式重载最后的安全 URL。应用
渲染进程崩溃暂停所有浏览器动作,直到界面重新挂载。
## 宿主调用
| 方法 | 用途 |
| --- | --- |
| `host/browser.grant` `revoke` | 安装或撤销授权 |
| `host/browser.tabs.list` `open` `close` `activate` `navigate` | 绑定授权的标签生命周期 |
| `host/browser.snapshot` | 标签结构快照,返回 `documentToken` |
| `host/browser.act` | 一个已预留的动作;返回 `{executed, reason, documentToken}` |
| `host/browser.screenshot` | 捕获到任务拥有的文件路径 |
| `host/browser.downloads` | 列出或等待标签的下载 |
| `host/browser.layout` | 应用面板矩形与覆盖状态 |
壳发出的事件:`browser:tabs`(标签列表变化)、`browser:takeover`(`{tabID, epoch, reason}`)、
`browser:download`(进度)、`browser:crash`。
## 远程 Agent
远程 Reasonix Agent 通过现有 SSH 连接与转发管理器,以承载同一 `BrowserExecutor` 契约的
受限 Host RPC 使用本机浏览器。授权绑定远程连接世代、会话与任务;断线、重连或切换会话
即撤销。浏览器授权与 provider 代理凭据分开;没有共享 token。旧的远程 Serve 构建通过
能力协商不宣告浏览器,保留全部现有远程功能。
## 验收
iframe、动态 DOM、受控输入、弹窗、上传与下载、导航历史、临时分区、共享与隔离登录;
审批前接管、审批后派发前接管、执行后回执丢失、崩溃后重启、重复 operation ID;远程
SSH 断连、世代变化、过期授权、跨会话误路由。[迁移记录](DESKTOP_SHELL_MIGRATION.zh-CN.md#验收门槛)
中的真实任务关闭本阶段。