diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2861aecc..78fd290dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.13.0-beta.1 - 2026-10-22 +## 0.13.0-beta.2 - 2026-10-23 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. @@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop ### Added -- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) - Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) - Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576)) - Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) ### Improved +- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611)) - Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580)) -- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411)) - Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424)) - Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) - Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) @@ -35,18 +42,31 @@ paseo host add 'relay://...' --label laptop ### Plugins -- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478)) - Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) - Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630)) - Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### Fixed - Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) - Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index ac7c00cbe7..6c82382605 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,6 +1,6 @@ # 更新日志 -## 0.13.0-beta.1 - 2026-10-22 +## 0.13.0-beta.2 - 2026-10-23 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 @@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop ### 新增 -- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) - 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) - 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576)) - 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) ### 改进 +- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611)) - 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580)) -- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411)) - Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) - 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) - 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) @@ -35,18 +42,31 @@ paseo host add 'relay://...' --label laptop ### 插件 -- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478)) - 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) - 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630)) - 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### 修复 - 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) - 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538)) diff --git a/CLAUDE.md b/CLAUDE.md index 79bdef92f8..3f1731597c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | | [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | diff --git a/SECURITY.md b/SECURITY.md index ea23984fa0..e17118459e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. -3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. +3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes. +4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping. The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. @@ -34,15 +34,15 @@ The daemon requires a valid cryptographic handshake before processing any comman - **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone - **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. -- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake -- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected -- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. +- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode +- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode +- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated. ### Trust model The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. -When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available. +When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon. ## Local daemon trust boundary diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ffcec92ddd..535a6dd712 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -79,7 +79,30 @@ Users can also detach an existing subagent from the subagents track. Detach is d Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches. -The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope. +The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt +crash can lose queued prompts. A durable send receipt remains pending until actual dispatch +acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued +admission within their original timeout and cancellation scope. + +Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module +persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses +`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt +acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch +accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same +caller, child, and host into one watch. + +Bootstrap restores these watches after repairing stale running records to `closed` and before opening +ingress. Reading a closed child's result does not load its provider. A finish notification reports that +the child's run has settled; the parent decides whether its task is complete. Recovery never resumes +child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop +reporting after detachment. + +Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure +retries the captured notification; it does not change the child's reported outcome. Remote host loss +follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining +outstanding intent. A crash after successful dispatch but before removal commits can repeat the +notification. Clean shutdown can also flush an already queued callback while retaining its intent, +so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. diff --git a/docs/architecture.md b/docs/architecture.md index c98f3825af..29f9c7f817 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do not retain non-Git directories. +Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store. +Workspace descriptors, checkout diff subscriptions, and script metadata read the last known +snapshot from that store, so reconnecting clients receive it before refresh completes. +Refcounted git watches feed the store through snapshot-updated events; their lifetime controls +refresh only. Session cleanup and workspace archive retain the data. Removing the final active +workspace for a directory clears it. The store lives in memory and resets on daemon restart. + **Key modules:** | Module | Responsibility | @@ -175,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 +- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag - The relay is zero-knowledge — it routes encrypted bytes and cannot read content -- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) +- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`) - Pairing via QR code transfers the daemon's public key to the client - New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot. - Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 1844456007..20c86c1295 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -44,10 +44,25 @@ connection candidates, including direct connections and routes discovered throug Adding a direct connection to a discovered host adds another candidate. If no candidate connects, the destination workspace keeps its existing disconnected or reconnecting behavior. -Finish notifications use the existing idle wait, including a pending initial run or an already idle -child. They name the server and agent. A daemon restart or route loss ends an outstanding notification -wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain -no additional turn attribution or delivery guarantee. +Finish notifications name the destination server and child agent. The caller's daemon owns durable +subscription intent and restores observation through the same routed wait and permission observation +used during ordinary operation. Restarting the caller's daemon does not imply that a remote child +stopped. Three failed remote observation attempts end the subscription with a notification that the +child could not be observed and its status is unknown. This does not prove the host is offline: remote +RPC failures also prevent observation. Attempts use capped backoff; a connection still +in progress does not spend that budget. A confirmed removed registration reports host unavailability +immediately through the same path. Neither notice claims the child finished. Intent remains stored +until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The +parent decides whether to inspect the child or delegate again. + +A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt +response retains intent because the destination may have admitted it; observation then follows the +same retry policy. A confirmed missing child reports unavailability. + +Remote creation and local subscription persistence are separate commits. A failed local write reports +failure even if the destination already created the child; inspect it before retrying creation. +See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment, +and duplicate-delivery semantics. Concurrent prompts gain no turn attribution. ## Connections and recovery diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 7207d14f87..a1778b47b8 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. -Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag +Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside -Close, so the title keeps its width. The sheets present -from the shared workspace's `SheetHostScope`, so their panels read the navigator and every -workspace provider an ordinary pane has without a context bridge -([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). +Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local +host sits behind the app-level left sidebar. The workspace environment bridge preserves existing +editing sessions, caches, file-link resolution and supported navigation context without moving +their providers. Keep this capability at the workspace boundary; new sheet callers must not +assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts +forward the same Gorhom modal queue; neither creates another stack. See +[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 39ef49fae8..f17c59c7c2 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -115,11 +115,10 @@ quietly relying on: Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the `isPaneFocused` prop already exists and flips on pane switches; pass `visible={isYourOwnVisible && isPaneFocused}`. -- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift - SharedValue, and `useKeyboardShiftStyle()` only adapts that value into - translate/padding styles. The composer and chat content must both read that - provider-owned value. A portal'd popover is outside the composer tree — it - does not get that transform unless you apply it yourself. +- **Transforms.** The composer moves with the keyboard through a + `KeyboardTranslateView` transform. A portal'd popover is outside that view, so + it does not move with the composer unless it renders its own + `KeyboardTranslateView` with the same translation. - **Layering.** The default root host renders after app content, so it sits above compact sidebars. Content overlays that must sit below sidebars should use the current `FloatingPanelPortalHost`. @@ -158,7 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture. The dock's private viewport and capacity model are extracted from chat. The app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams, terminal, explorer, and dialogs. Import only its public entry point: the provider, -shift hooks, shift style, translate view, settled stream inset, and explorer padding +shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider` at its root mount point outside the portal host when changing dock hosts. @@ -256,11 +255,27 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native animation-end events because the controller can retain a nonzero value after the keyboard closes. -Measure portal anchors while the dock is at rest. If a popover opens during -motion, re-measure when `isMoving` settles. `measureInWindow` includes the -dock's current layout and transform. Snapshot the shift at measurement time and -apply only the subsequent shift delta to the animated `bottom`. Adding the full -shift moves the portal twice and can place it over the composer controls. +Anchor a portal to content inside a `KeyboardTranslateView` with +`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the +translation scope is React context and does not cross it (Gotcha 7). It returns: + +- `measureAtRest(element)`: the element's window rect with the keyboard + translation left out, or null once the anchor unmounts. Lay the popover out + from this rect. It assumes the translate view's parent does not move with the + keyboard, which holds for the composer dock. +- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so + the popover moves with the anchor on the same driver, including centered + clearance and disabled translation. +- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's + height with it; never animate height per frame. + +Do not measure an anchor with `measureInWindow` and correct it by the keyboard +shift. On iOS the translate view uses the native driver, and `measureInWindow` +includes its transform only when the view happened to re-render since the +keyboard moved. Both results were observed, so any correction double-lifts the +popover or leaves it behind the keyboard. `measureAtRest` measures through +Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving` +settles; the composer's resting position changes as the draft grows. The provider also reconciles iOS from the controller's native `onEnd` event. The controller's stock iOS shared values update at move start and during an @@ -273,7 +288,8 @@ not be required to repair the offset. The generic anchored-overlay rule is: -1. Measure the anchor with `measureInWindow`. +1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it + moves with the keyboard (Gotcha 3). 2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`. 3. Position with anchor coordinates relative to the host: @@ -347,10 +363,10 @@ track phase. All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on close. This includes native centered dialogs: separate React Native Modals cannot present a -root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree -(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their -panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the -portal host and container measurement while the sheet still joins the same stack. That export is +root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host +and container measurement while forwarding the same modal queue. Compact +[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars; +wide layouts keep local geometry. That export is what `patches/@gorhom+bottom-sheet+*.patch` adds. A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every @@ -405,8 +421,8 @@ Before you write a new one, ask: 3. **Is the panel rendered in a Portal host?** Measure the host too. Never use raw window coordinates as local Portal coordinates. 4. **Does the panel sit above something that moves with the keyboard?** If - yes, slave a Reanimated transform to the same SharedValue (Gotcha 3). - If no, you can probably skip the transform entirely. + yes, anchor it with `useKeyboardAnchor()` and wrap it in a + `KeyboardTranslateView` with the anchor's translation (Gotcha 3). 5. **Will the panel's content height vary?** If yes, you need both `anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return null until anchor, then opacity-0 until contentSize). If no — content has diff --git a/docs/glossary.md b/docs/glossary.md index 644c109d44..d2c871f74b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time. - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 375da30708..917ebf9b53 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -53,7 +53,18 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft tab, and New workspace. It checks empty-content taps and header taps at the cap without changing the input height. Each host also opens command/file autocomplete, the model selector, the attachment -menu, and the forge picker from both keyboard states. It then submits a long +menu, and the forge picker from both keyboard states. Autocomplete opens with +the keyboard closed, before the keyboard opens, and after it settles. Each time +the expected best match must be drawn whole between the header and the composer, +above the IME frame, and tapping it there must produce the exact composer text. +Accessibility reports rows behind the keyboard too, so +`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares +bounds. Android leaves the chat and composer out of the accessibility tree while +suggestions are open, so the composer bounds come from a snapshot with an empty +input in the same keyboard state. The queries are partial names with known best +matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts +`/release` finds this repository's `release-beta` skill for the first Claude model, +and `@app` finds `packages/app`. It then submits a long fixture from each host and checks native stream displacement twice: after the response settles, and after returning to the bottom and letting the scrollbar fade. The image comparison excludes the scrollbar; keyboard dismissal alone @@ -64,6 +75,25 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in `packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard; the headless input helper cannot verify the [visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). +To rerun only the autocomplete, model selector, attachment menu, and forge picker +checks on each host, skipping the chat baseline and the growth, dismissal, and +stream scenarios: + +```bash +PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android +``` + +`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same +autocomplete checks on iOS for chat, a workspace draft tab, and New workspace, +with commands, files, and files under a grown draft. Suggestions open while the +keyboard opens (`fill` taps and types at once), after it settles, with it closed, +and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert +accessibility state and cannot compare bounds. Run it where `agent-device` +reaches the simulator, with the development app connected to the fixture host +and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`, +`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`, +`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to +`.dev/agent-device-artifacts/composer-keyboard-ios`. The Android sidebar scroll-retention flow requires a connected host with enough workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and diff --git a/docs/plugins.md b/docs/plugins.md index bf83669d55..2a223bfe85 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,7 +570,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's -editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: +editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place: `packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel; make that panel faster instead. Source highlights a large file on native in idle-time slices, the lines around the opening line first. diff --git a/docs/relay-endpoint-security.md b/docs/relay-endpoint-security.md new file mode 100644 index 0000000000..0fd6a1899f --- /dev/null +++ b/docs/relay-endpoint-security.md @@ -0,0 +1,88 @@ +# Relay endpoint security + +The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`. + +Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice. + +## Two modes + +| Mode | Construction | Replay protection | +| ----------- | ------------------------------------------------ | -------------------------------- | +| `legacy` | X25519 + NaCl `box`, random nonces | none | +| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral | + +Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged. + +The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction. + +## Noise mapping + +- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity. +- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`. +- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`. +- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters. +- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated. +- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake. +- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies. + +Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon. + +## Records + +Each application frame becomes one record of one or more Noise transport messages: + +``` +chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data +wire record = (u16 BE ciphertextLength || ciphertext)+ +``` + +`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`. + +The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this. + +Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated. + +## Retries and failures + +- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored. +- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart. +- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted. +- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`. +- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing. +- Keys are cleared on close as a best effort. + +## Negotiation and admission + +The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it. + +The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it. + +The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online. + +Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted. + +The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update. + +The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known. + +## Enforcement flag + +`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart. + +- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused. +- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake. +- Direct and local connections are outside the flag. + +Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date. + +## Known limitation + +While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon. + +## Testing + +- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level. +- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule. +- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts. +- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag. +- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d06b479e80..d345683e19 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg= +sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= diff --git a/package-lock.json b/package-lock.json index 9e0a02eb17..c93f050b23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -8805,6 +8805,45 @@ "license": "MIT", "optional": true }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -35656,7 +35695,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37922,12 +37961,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/server": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/server": "0.13.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38174,10 +38213,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38189,7 +38228,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38433,7 +38472,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42085,7 +42124,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42114,15 +42153,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42132,8 +42171,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42155,7 +42194,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42182,8 +42221,11 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" @@ -42400,16 +42442,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/highlight": "0.13.0-beta.1", - "@getpaseo/plugin": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/highlight": "0.13.0-beta.2", + "@getpaseo/plugin": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43330,7 +43372,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44940,7 +44982,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 9a8a3322e5..583a88c406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index f66b963d5f..5770c3be9d 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => { } }); }); + +test.describe("Compact Add project context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("canceling Add project preserves the sidebar", async ({ page }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + }); + test("successful Add project reveals the new workspace page", async ({ + page, + projectPickerFixture, + }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); + await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery); + await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName); + await page.keyboard.press("Enter"); + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: projectPickerFixture.projectName, + projectPath: projectPickerFixture.projectPath, + }); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 4875cc31c1..8d4e773259 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -21,8 +21,10 @@ async function completeSubmittedTurn( const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); await expect(userMessage).toBeVisible(); + await expectAgentReadyToInterrupt(page); const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); + await expectAgentIdle(page); await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expect(userMessage).toHaveAttribute("aria-busy", "false"); return userMessage; diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5964dcc978..2f16db6f09 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures"; import { openCommandCenter } from "../support/helpers/command-center"; import { delayDirectorySuggestionResponses, + holdDirectorySuggestionResponses, expectOneLineTruncatedFileResult, expectStableCommandCenterLayout, failDirectorySuggestionRequests, startCommandCenterLayoutObservation, } from "../support/helpers/command-center-file-search"; +import { + scrollCommandCenterAwayFromSelection, + expectCommandCenterScrollToStayAt, + expectKeyboardNavigationToResume, +} from "../support/helpers/command-center-scroll"; import { expectFileTabOpen } from "../support/helpers/file-explorer"; import { gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; @@ -137,3 +143,94 @@ test("unscoped command search keeps commands visible and reports file-search fai await Promise.allSettled(seeded.map((workspace) => workspace.cleanup())); } }); + +test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({ + page, +}) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-delayed-scroll-", + title: "Delayed file search scroll", + repo: { files: paseoSizedFiles() }, + }); + try { + const responses = await holdDirectorySuggestionResponses(page); + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible(); + await panel.getByTestId("command-center-input").fill("index"); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeVisible(); + + responses.hold(); + await panel.getByTestId("command-center-input").fill("index."); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible(); + const offset = await scrollCommandCenterAwayFromSelection(page, panel); + await responses.release(); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expectCommandCenterScrollToStayAt(panel, offset); + await expectKeyboardNavigationToResume(page, panel, offset); + + const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel); + await panel.getByTestId("command-center-input").fill("index.ts"); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expect + .poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop)) + .toBeLessThan(nextOffset); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeInViewport({ ratio: 1 }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Meta+P"); + await expect(panel.getByTestId("command-center-input")).toHaveValue(""); + await panel.getByTestId("command-center-input").fill("index"); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeInViewport({ ratio: 1 }); + } finally { + await seeded.cleanup(); + } +}); + +test.describe("Compact file search context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("a file result opens a sheet above the left sidebar", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-file-", + title: "Compact file lookup", + repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] }, + }); + try { + await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("lookup.ts"); + await search.getByRole("button", { name: "lookup.ts", exact: true }).click(); + await expect(page.getByTestId("sheet-tab-body")).toBeVisible(); + await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts"); + await page + .getByTestId("sheet-tab-header") + .getByRole("button", { name: "Close", exact: true }) + .click(); + await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index f6762bb4cc..0a66b59856 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -1,7 +1,21 @@ +import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { + seedSearchableCommandCenter, + observeCommandCenterRowRenders, + searchCommandCenter, + warmCommandCenterRows, + expectOnlyCommandCenterRowRendered, + expectCommandCenterAgentWindow, + scrollToOldestCommandCenterAgent, +} from "../support/helpers/command-center-performance"; import { gotoAppShell } from "../support/helpers/app"; import { expectCommandCenterToRemainAtTheTop, + scrollCommandCenterAwayFromSelection, + expectCommandCenterScrollToStayAt, + expectKeyboardNavigationToResume, expectPrimaryCommandCenterActions, expectTightTwoLineResultSpacing, expectVisibleKeyboardNavigationNotToScroll, @@ -16,12 +30,15 @@ test.use({ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", }); -test("opening the command center keeps its first result fully visible", async ({ page }) => { +test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({ + page, +}) => { const seeded = await seedWorkspace({ repoPrefix: "command-center-scroll-", title: "Command Center Scroll 00", }); + let newestWorkspaceId = seeded.workspaceId; try { for (let index = 1; index <= 8; index += 1) { const created = await seeded.client.createWorkspace({ @@ -36,6 +53,7 @@ test("opening the command center keeps its first result fully visible", async ({ if (!created.workspace) { throw new Error(created.error ?? `Failed to seed workspace ${index}`); } + newestWorkspaceId = created.workspace.id; } await gotoAppShell(page); @@ -48,6 +66,52 @@ test("opening the command center keeps its first result fully visible", async ({ // The empty query lists the most recent workspaces, so the last one seeded leads it. await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08"); await expectWorkspaceResultsToBeLimitedUntilSearch(panel); + const offset = await scrollCommandCenterAwayFromSelection(page, panel); + await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed"); + await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1); + await expectCommandCenterScrollToStayAt(panel, offset); + await expectKeyboardNavigationToResume(page, panel, offset); + } finally { + await seeded.cleanup(); + } +}); + +test("live search updates render only the changed row and keep agents virtualized", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedSearchableCommandCenter(); + try { + await observeCommandCenterRowRenders(page); + await gotoAppShell(page); + const panel = await openCommandCenterWithKeyboard(page); + await searchCommandCenter(panel, "Search agent"); + await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); + + await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" }); + await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `agent:${getServerId()}:${seeded.agents[59].id}`, + ); + + await searchCommandCenter(panel, "Search workspace"); + await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised"); + await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `workspace:${getServerId()}:${seeded.workspaces[39].id}`, + ); + + await searchCommandCenter(panel, "Search agent"); + await warmCommandCenterRows(page); + await scrollToOldestCommandCenterAgent(panel); + await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible(); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index 4b01b0c67d..38b2e33cff 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts"; import { expectAppRoute } from "../support/helpers/route-assertions"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const PRIMARY_HOST_LABEL = "Primary Host"; @@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries"; test.describe("Command center workspaces", () => { test.describe.configure({ timeout: 180_000 }); + test("agent and workspace results retain their icons and overlay live status", async ({ + page, + }) => { + const workspace = await seedRunningMockAgentWorkspace({ + repoPrefix: "command-center-status-", + title: "Review checkout retries", + model: "five-minute-stream", + initialPrompt: "Keep working on checkout retries", + }); + try { + await openAgentRoute(page, workspace); + await expectAgentTabActive(page, workspace.agentId); + const panel = await openCommandCenter(page); + const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${workspace.workspaceId}`, + ); + await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible(); + await workspace.client.cancelAgent(workspace.agentId); + await expect(agent.getByRole("progressbar")).toHaveCount(0); + await expect(row.getByRole("progressbar")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("workspace results show their title, host, and branch and open the workspace", async ({ page, }) => { @@ -156,3 +185,74 @@ test.describe("Command center workspaces", () => { } }); }); + +test.describe("Compact sidebar inspection", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + async function openSidebar(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + async function expectSidebarPreserved(page: import("@playwright/test").Page) { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + test("canceling Search retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await openCommandCenter(page); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + test("canceling Import session retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await page + .getByTestId("sidebar-project-empty-state") + .getByRole("button", { name: "Import session", exact: true }) + .click(); + await expect(page.getByTestId("import-session-sheet")).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByTestId("import-session-sheet")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + + test("Search reveals the same workspace and a selected root agent", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-destination-", + title: "Compact destination", + }); + try { + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Compact root agent", + }); + await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("Compact destination"); + await search + .getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`) + .click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const agentSearch = await openCommandCenter(page); + await agentSearch.getByTestId("command-center-input").fill("Compact root agent"); + await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message agent...", exact: true }), + ).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 788a6f93ec..c6d34f38f2 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = await test.step("the selected row imports and opens the hydrated transcript", async () => { await flow.importSession(scenario.importSessionId); await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready."); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); await capture(page, testInfo, "08-mobile-agent-after-import.png"); }); diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 9159a60644..460faeb4ef 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") { await expect(option).toHaveAttribute("aria-selected", "true"); } +async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") { + const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true }); + await button.click(); + await page.reload(); + if (view === "Source") { + await expectMarkdownEditor(page, "Edited by Rich Markdown"); + } else { + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + } +} + /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ async function pdfViewer(page: Page): Promise { const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; @@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); + await rememberMarkdownViewAcrossReload(page, "Preview"); + await rememberMarkdownViewAcrossReload(page, "Source"); + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the // cursor and vim mode in the panel's toolbar. await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 8b06e14f67..92e4d7a095 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 177ddd563e..b557b73ccb 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -6,6 +6,7 @@ import { claudeAndCodexReports, expectPinnedUsage, openCompactSidebar, + expectCompactSidebarOpen, } from "../support/helpers/usage-sidebar-item"; import { gotoAppShell } from "../support/helpers/app"; import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; @@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy await openAddProjectFlow(page); await page.keyboard.press("Escape"); await expect(addProjectFlow(page)).toBeHidden(); + await expectCompactSidebarOpen(page); await page.setViewportSize({ width: 1440, height: 900 }); await openSidebarNavSettings(page); await setFooterItemVisible(page, "usage", true); await leaveSettings(page); await page.setViewportSize({ width: 390, height: 844 }); - await openCompactSidebar(page); + await expectCompactSidebarOpen(page); await expectFooterSeparator(page, true); await expectFooterIconRow(page); await footerScreenshot(page, "footer-compact-with-rows"); diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts index a2f8d14348..e406ba5a3f 100644 --- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts @@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => { await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index 6129d37a63..f6bf4d3f1b 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -1,9 +1,10 @@ -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; async function rowTestIds(rows: Locator) { @@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond( await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); } -test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({ +async function dragWithoutReordering(rows: Locator): Promise { + const before = await rowTestIds(rows); + const source = await visibleBoundingBox(rows.nth(0)); + const target = await visibleBoundingBox(rows.nth(1)); + const page = rows.page(); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); + await page.mouse.up(); + await expect.poll(() => rowTestIds(rows)).toEqual(before); +} + +test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({ page, }) => { const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); @@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; - await quickDragFirstRowAfterSecond( - page.locator( - `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, - ), - pressWorkspaceRow, + const ordinaryRows = page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, ); + await expect + .poll(() => rowTestIds(ordinaryRows)) + .toEqual([secondWorkspaceTestId, firstWorkspaceTestId]); + await dragWithoutReordering(ordinaryRows); await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true); await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true); @@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra await secondProject.cleanup(); } }); + +async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await page.getByRole("menuitem", { name, exact: true }).click(); +} + +async function reconnectSidebar(page: Page): Promise { + await page.reload(); + await waitForSidebarHydration(page); +} + +async function expectNoWorkspaceSortChoice(page: Page): Promise { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} + +async function promptWorkspace(seed: Awaited>, agentId: string) { + await seed.client.sendAgentMessage(agentId, "sidebar ordering check"); + expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle"); +} + +test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({ + page, +}) => { + test.setTimeout(120_000); + const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" }); + try { + const newer = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "Newer workspace", + }); + if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed"); + const agent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Ordering agent", + }); + const newerAgent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: newer.workspace.id, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Newer agent", + }); + await gotoAppShell(page); + await waitForSidebarHydration(page); + const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`; + const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`; + const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectWorkspaceSort(page, "Last prompt"); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await promptWorkspace(seed, newerAgent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await seed.client.archiveAgent(agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectSidebarStatusGrouping(page); + await expectNoWorkspaceSortChoice(page); + } finally { + await seed.cleanup(); + } +}); + +async function seedCompactProject() { + const seed = await seedWorkspace({ + repoPrefix: "sidebar-visibility-", + title: "Oldest workspace", + }); + const ids = [seed.workspaceId]; + for (let index = 2; index <= 12; index += 1) { + const created = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: `Workspace ${index}`, + }); + if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed"); + ids.unshift(created.workspace.id); + } + const agent = await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: "Overflow agent", + }); + return { seed, ids, agent }; +} + +async function expectWorkspaceOrder(rows: Locator, ids: string[]) { + await expect + .poll(() => rowTestIds(rows)) + .toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`)); +} + +async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") { + await list.getByLabel(action, { exact: true }).click(); +} + +async function selectProjectGrouping(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Group/ }).click(); + await page.getByTestId("sidebar-grouping-project").click(); +} + +type CompactProject = Awaited>; + +function compactProjectRows(page: Page, seed: CompactProject["seed"]) { + const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`); + return { + rows: list.locator('[data-testid^="sidebar-workspace-row-"]'), + group: list.locator(".."), + project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`), + }; +} + +async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) { + const { rows } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + + await seed.client.sendAgentMessage(agent.id, "show active overflow"); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.waitForFinish(agent.id, 15_000); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectRevealSurvivesSortingAndCollapse( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows, group, project } = compactProjectRows(page, seed); + const retainedRows = await rows.elementHandles(); + await revealWorkspaces(group, "Show more"); + await expectWorkspaceOrder(rows, ids); + for (const [index, retained] of retainedRows.entries()) { + expect( + await rows.nth(index).evaluate((current, previous) => current === previous, retained), + ).toBe(true); + } + await project.click(); + await expect(rows).toHaveCount(0); + await project.click(); + await expectWorkspaceOrder(rows, ids); + await seed.client.sendAgentMessage(agent.id, "expanded overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids); + await selectWorkspaceSort(page, "Last prompt"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]); + const revealedRow = await rows.nth(8).elementHandle(); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]); + await selectWorkspaceSort(page, "Created"); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectStatusBucketsRetainActiveOverflow( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows } = compactProjectRows(page, seed); + await selectSidebarStatusGrouping(page); + const done = page.getByTestId("sidebar-status-group-rows-done"); + const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]'); + await expect(doneRows).toHaveCount(7); + await revealWorkspaces(done, "Show more"); + await expect(doneRows).toHaveCount(12); + await seed.client.sendAgentMessage(agent.id, "status overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await expect( + page + .getByTestId("sidebar-status-group-rows-attention") + .getByText("Oldest workspace", { exact: true }), + ).toBeVisible(); + await expect(doneRows).toHaveCount(11); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expect(doneRows).toHaveCount(12); + await revealWorkspaces(done, "Show less"); + await expect(doneRows).toHaveCount(7); + const activeAgents: Array<{ id: string }> = []; + for (const workspaceId of ids.slice(0, 8)) { + activeAgents.push( + await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId, + title: "Active overflow agent", + }), + ); + } + await Promise.all( + activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")), + ); + await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000))); + const attention = page.getByTestId("sidebar-status-group-rows-attention"); + await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8); + await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0); + for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId); + await expect(doneRows).toHaveCount(7); + await selectProjectGrouping(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +test("compact groups retain active overflow and reveal the current ordered rows", async ({ + page, +}) => { + test.setTimeout(120_000); + const fixture = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await test.step("Active overflow appears without intervening Done rows", () => + expectActiveOverflowTransitions(page, fixture)); + await test.step("Reveal follows current sort and survives collapse", () => + expectRevealSurvivesSortingAndCollapse(page, fixture)); + await test.step("Status reveals Done rows and keeps every active row", () => + expectStatusBucketsRetainActiveOverflow(page, fixture)); + } finally { + await fixture.seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts new file mode 100644 index 0000000000..ba210131db --- /dev/null +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -0,0 +1,84 @@ +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { randomUUID } from "node:crypto"; +import { metroTest as test, expect } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; + +test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => { + test.setTimeout(180_000); + const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, { + publishedVersion: "0.8.0", + }); + try { + const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port }); + try { + const second = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath, projectId: project.projectId }, + title: "Second old workspace", + }); + expect(second.workspace).toBeTruthy(); + await openPublishedHost(page, daemon, project.workspaceId); + await openWorkspace(page, daemon.serverId, second.workspace!.id); + await expectSortingRequiresHostUpdate(page); + await selectSidebarStatusGrouping(page); + await expectStatusHasNoSorting(page); + } finally { + await project.cleanup(); + } + } finally { + await daemon.close(); + } +}); + +async function openPublishedHost( + page: Page, + daemon: { serverId: string; port: number }, + workspaceId: string, +) { + const host = buildSeededHost({ + serverId: daemon.serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript((seededHost) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem( + "sidebar-view", + JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }), + ); + }, host); + await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId)); +} + +async function openWorkspace(page: Page, serverId: string, workspaceId: string) { + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(new RegExp(workspaceId)); +} + +async function expectSortingRequiresHostUpdate(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeDisabled(); + await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function expectStatusHasNoSorting(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount( + 0, + ); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index 23127bbdd9..ccf29179b8 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) { async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { await page.setViewportSize({ width: 390, height: 844 }); await openAgentRoute(page, workspace); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openMobileAgentSidebar(page); } diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 023bf12b9d..d5a0071189 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,6 +1,10 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; +import { holdFileUploadResponse } from "../support/helpers/file-upload-gate"; +import { installLocalDesktopHost } from "../support/helpers/local-desktop-host"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; @@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => { setTimeout(finish, 700); `; +// A TUI stand-in: turns on bracketed paste, reads every quoted path in each +// bracketed paste, and echoes typed lines, so the PTY output proves what reached +// the terminal and where the file was read from. +const FILE_RECEIVER_SCRIPT = String.raw` +const fs = require("node:fs"); +const crypto = require("node:crypto"); +let buffer = ""; +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n"); +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); + if (!paste) break; + for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) { + const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); + const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local"; + process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n"); + } + buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length); + } + if (buffer.includes("\x03")) { + process.stdout.write("INTERRUPTED\r\n"); + buffer = buffer.replaceAll("\x03", ""); + } + const enter = buffer.indexOf("\r"); + if (enter >= 0 && !buffer.includes("'")) { + process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n"); + buffer = buffer.slice(enter + 1); + } +}); +`; + +function pngBytes(seed: number): Buffer { + return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]); +} + +function fileRead(bytes: Buffer, location: "upload" | "local"): string { + const digest = createHash("sha256").update(bytes).digest("hex"); + return `FILE_READ:${digest}:${location}`; +} + +// Inactive terminal tabs retain their surfaces, and another page opening a terminal in +// the same workspace adds a tab here too. Only the selected tab's surface is visible. +function terminalSurface(page: Page) { + return page.getByTestId("terminal-surface").filter({ visible: true }); +} + +function terminalInput(page: Page) { + return terminalSurface(page).locator(".xterm-helper-textarea"); +} + +async function pasteImage(page: Page, bytes: Buffer): Promise { + await terminalInput(page).evaluate( + (element, data) => { + (element as HTMLTextAreaElement).focus(); + const clipboardData = new DataTransfer(); + clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" })); + element.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }), + ); + }, + [...bytes], + ); +} + +interface DroppedFile { + name: string; + type: string; + bytes: Buffer; +} + +async function dragTransfer(page: Page, files: DroppedFile[]) { + return await page.evaluateHandle( + (entries) => { + const dataTransfer = new DataTransfer(); + for (const entry of entries) { + dataTransfer.items.add( + new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }), + ); + } + return dataTransfer; + }, + files.map((file) => ({ ...file, bytes: [...file.bytes] })), + ); +} + +async function dragFilesOver(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("dragenter", { dataTransfer }); + await terminalInput(page).dispatchEvent("dragover", { dataTransfer }); + await dataTransfer.dispose(); +} + +async function dropFiles(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("drop", { dataTransfer }); + await dataTransfer.dispose(); +} + +/** The shared "Drop files here" overlay of the zone around the terminal. */ +function terminalDropOverlay(page: Page) { + return terminalSurface(page) + .locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]") + .getByTestId("file-drop-backdrop"); +} + +/** + * Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped + * files resolve to paths inside `directory` instead of uploading. + */ +async function typeLine(page: Page, text: string): Promise { + await terminalInput(page).pressSequentially(text); + await terminalInput(page).press("Enter"); +} + test.describe("Terminal protocol queries", () => { let harness: TerminalE2EHarness; test.beforeAll(async () => { harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" }); await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT); + await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT); }); test.afterAll(async () => { @@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => { await harness.killTerminal(terminalInstance.id); } }); + + async function openFileReceiver(page: Page, name: string) { + const terminal = await harness.createTerminal({ + name, + command: process.execPath, + args: ["file-receiver.cjs"], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY"); + return terminal; + } + + // Reads the PTY itself, so output from one pane cannot satisfy another's check. + async function terminalOutput(terminalId: string): Promise { + const capture = await harness.client.captureTerminal(terminalId); + return capture.lines.join("").replace(/\s/g, ""); + } + + async function expectTerminalOutput(terminalId: string, text: string): Promise { + await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); + } + + test("pastes images from two panes into their own terminals as uploaded host files", async ({ + page, + }) => { + const secondPage = await page.context().newPage(); + const first = await openFileReceiver(page, "image-paste-a"); + const second = await openFileReceiver(secondPage, "image-paste-b"); + try { + await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]); + + await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload")); + await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload")); + } finally { + await secondPage.close(); + await harness.killTerminal(first.id); + await harness.killTerminal(second.id); + } + }); + + test("uploads dropped files of any type when the host is another machine", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-drop-remote"); + const files = [ + { name: "shot.png", type: "image/png", bytes: pngBytes(3) }, + { name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") }, + ]; + try { + await dragFilesOver(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1"); + await expect(terminalDropOverlay(page)).toHaveText("Drop files here"); + + await dropFiles(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0"); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("pastes dropped files' own paths when the host is this machine", async ({ page }) => { + const files = [ + { name: "local-shot.png", type: "image/png", bytes: pngBytes(9) }, + { name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") }, + ]; + for (const file of files) { + await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes); + } + await installLocalDesktopHost(page, harness.tempRepo.path); + const terminal = await openFileReceiver(page, "file-drop-local"); + try { + await dropFiles(page, files); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local")); + expect(await terminalOutput(terminal.id)).not.toContain(":upload"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("lands image pastes in gesture order while typing stays live", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-order"); + try { + await pasteImage(page, pngBytes(4)); + await upload.waitUntilHeld(); + await pasteImage(page, pngBytes(5)); + await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…"); + + await typeLine(page, "describe"); + await expectTerminalOutput(terminal.id, "TYPED:describe"); + + upload.release(); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload")); + const output = await terminalOutput(terminal.id); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan( + output.indexOf(fileRead(pngBytes(5), "upload")), + ); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded"); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-cancel"); + try { + await pasteImage(page, pngBytes(6)); + await upload.waitUntilHeld(); + + await terminalInput(page).press("Control+c"); + await expectTerminalOutput(terminal.id, "INTERRUPTED"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + upload.release(); + + await pasteImage(page, pngBytes(7)); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload")); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("a disconnect during upload reports one failure and input keeps working", async ({ + page, + }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-disconnect"); + try { + await pasteImage(page, pngBytes(8)); + await upload.waitUntilHeld(); + + await upload.disconnect(); + await expect(page.getByTestId("app-toast-message")).toHaveText( + "Failed to paste file: Host is not connected", + ); + + await typeLine(page, "after"); + await expectTerminalOutput(terminal.id, "TYPED:after"); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); }); diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts index 4853e9daa4..909672c489 100644 --- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar"; import { expectSidebarWorkspaceSelected, switchWorkspaceViaSidebar, @@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s } await page.setViewportSize({ width: 1440, height: 1100 }); await gotoAppShell(page); + await revealSidebarProjectWorkspaces(page, project.projectKey); const order = await readVisibleWorkspaceOrder(page); await test.step("next crosses the ninth workspace and previous returns to it", async () => { @@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s workspaceId: order[8], }); }); + await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => { + await expectHiddenWorkspaceShortcuts(page, order); + }); } finally { await project.cleanup(); } }); + +async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) { + await page.getByLabel("Show less", { exact: true }).click(); + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(7); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await page.keyboard.press("Alt+9"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); + await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( + 0, + ); + await page.keyboard.press("Alt+]"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); + await expect(rows).toHaveCount(7); +} diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad index c94e24914d..359e89c5c9 100644 --- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0 env APP_ID=sh.paseo.debug # Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible, -# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms). +# unselected PINNED workspace row. Ordinary workspace rows are not draggable. +# Hold past drag arming (180ms), then release. open "${APP_ID}" press "label=\"Open menu\"" wait "id=\"${WORKSPACE_ROW}\"" 10000 diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 49c907dba9..809483ceff 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four' LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')" BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')" AGENT_TITLE="Keyboard dismiss QA $(date +%s)" +# `overlays` runs only each host's autocomplete, model selector, attachment menu, and +# forge picker checks; `all` also runs the chat baseline and the full host scenario. +SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}" +if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then + echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2 + exit 1 +fi if [[ -z "${SERVER_ID}" ]]; then if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then @@ -173,8 +180,10 @@ sleep 3 # Create the fixture while the client is connected so its directory replica observes the insert. ad wait 'id="message-input-root"' 90000 +# The compact header names the workspace, not the agent, so the chat waits below find +# the fixture by its prompt bubble. run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \ - npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \ + npm run --silent cli -- run "${AGENT_TITLE}" \ --background \ --title "${AGENT_TITLE}" \ --provider mock \ @@ -206,238 +215,241 @@ clear_input ad keyboard dismiss || true ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/baseline.json" -capture_screen "${ARTIFACTS_DIR}/baseline.png" -read -r input_x input_y baseline_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" -) -read -r model_x model_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" -) -read -r attach_x attach_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" -) -read -r _ header_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" -) +# Chat-only invariants, then the long-draft checks on New workspace. +run_chat_baseline() { + snapshot_json "${ARTIFACTS_DIR}/baseline.json" + capture_screen "${ARTIFACTS_DIR}/baseline.png" + read -r input_x input_y baseline_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" + ) + read -r model_x model_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" + ) + read -r attach_x attach_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" + ) + read -r _ header_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" + ) -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -keyboard_shift="$(read_keyboard_shift)" -adb shell input keyevent 76 -sleep 1 -capture_screen "${ARTIFACTS_DIR}/command-popover.png" -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -model_open_y="$((model_y - keyboard_shift))" -adb shell input tap "${model_x}" "${model_open_y}" -sleep 1 -capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="compact-provider-list"' 10000 -ad press 'label="Close"' --settle -adb shell input tap "${input_x}" "${input_y}" -clear_focused_input_with_adb -ad wait 'editable=true' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + keyboard_shift="$(read_keyboard_shift)" + adb shell input keyevent 76 + sleep 1 + capture_screen "${ARTIFACTS_DIR}/command-popover.png" + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + model_open_y="$((model_y - keyboard_shift))" + adb shell input tap "${model_x}" "${model_open_y}" + sleep 1 + capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="compact-provider-list"' 10000 + ad press 'label="Close"' --settle + adb shell input tap "${input_x}" "${input_y}" + clear_focused_input_with_adb + ad wait 'editable=true' 10000 -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -adb shell input keyevent 76 -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -snapshot_json "${ARTIFACTS_DIR}/command-popover.json" -node "${ASSERT}" above-y \ - "${ARTIFACTS_DIR}/command-popover.json" \ - "/exit, Archive the current agent" \ - "$((input_y - baseline_input_height / 2))" -adb shell input keyevent 67 -ad wait 'label="Message, @files, /commands"' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + adb shell input keyevent 76 + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + snapshot_json "${ARTIFACTS_DIR}/command-popover.json" + node "${ASSERT}" above-y \ + "${ARTIFACTS_DIR}/command-popover.json" \ + "/exit, Archive the current agent" \ + "$((input_y - baseline_input_height / 2))" + adb shell input keyevent 67 + ad wait 'label="Message, @files, /commands"' 10000 -ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" -ad fill 'editable=true focused=true' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/multiline-first.json" \ - "${ARTIFACTS_DIR}/multiline-second.json" + ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" + ad fill 'editable=true focused=true' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/multiline-first.json" \ + "${ARTIFACTS_DIR}/multiline-second.json" -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" + keyboard_shift="$(read_keyboard_shift)" + capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" -node "${ASSERT}" same-header \ - "${ARTIFACTS_DIR}/baseline.png" \ - "${ARTIFACTS_DIR}/keyboard-open.png" \ - "$((header_y + 48))" + node "${ASSERT}" same-header \ + "${ARTIFACTS_DIR}/baseline.png" \ + "${ARTIFACTS_DIR}/keyboard-open.png" \ + "$((header_y + 48))" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" -node "${ASSERT}" xml-above-y \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "Add attachment" \ - "$(read_ime_top)" -display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" -# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to -# its baseline height. Fabric only re-measures the Android input when its text prop changes, -# so the editing input renders again after every edit -# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the -# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the -# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from -# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key -# row on the paseo-api35 layout. -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -open_gboard "${input_x}" "${input_y}" -adb shell input keycombination 113 122 -adb shell input keyevent 20 20 20 20 20 20 20 20 -screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" -backspace_x="$((screen_width - 85))" -backspace_y="$(($(read_ime_top) + 507))" -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 -adb shell input keycombination 113 123 -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-hold-delete.json" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" + node "${ASSERT}" xml-above-y \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "Add attachment" \ + "$(read_ime_top)" + display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + # Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to + # its baseline height. Fabric only re-measures the Android input when its text prop changes, + # so the editing input renders again after every edit + # (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the + # report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the + # draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from + # the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key + # row on the paseo-api35 layout. + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + open_gboard "${input_x}" "${input_y}" + adb shell input keycombination 113 122 + adb shell input keyevent 20 20 20 20 20 20 20 20 + screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" + backspace_x="$((screen_width - 85))" + backspace_y="$(($(read_ime_top) + 507))" + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 + adb shell input keycombination 113 123 + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'label="Message, @files, /commands"' 10000 + snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-hold-delete.json" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" -read -r changes_x changes_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" -) -adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" -ad wait 'id="changes-header"' 10000 -capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" -ad press 'label="Close Explorer sidebar"' --settle -ad wait "text=\"${AGENT_TITLE}\"" 10000 -ad wait 'editable=true' 10000 + read -r changes_x changes_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" + ) + adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" + ad wait 'id="changes-header"' 10000 + capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" + ad press 'label="Close Explorer sidebar"' --settle + ad wait "text=\"${AGENT_TITLE}\"" 10000 + ad wait 'editable=true' 10000 -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="message-input-attachment-menu-content"' 10000 -ad press 540 600 --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="message-input-attachment-menu-content"' 10000 + ad press 540 600 --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap 983 "$((attach_y - keyboard_shift))" -wait_for_ime false -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait text "keyboard invariant line one" 10000 -snapshot_json "${ARTIFACTS_DIR}/after-submit.json" -node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-submit.json" + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap 983 "$((attach_y - keyboard_shift))" + wait_for_ime false + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait text "keyboard invariant line one" 10000 + snapshot_json "${ARTIFACTS_DIR}/after-submit.json" + node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-submit.json" -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" -open_gboard "${input_x}" "${input_y}" -node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & -stall_pid="$!" -sleep 0.25 -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" -wait "${stall_pid}" -capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ - 1400 \ - 430 -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ - 1400 \ - 430 + open_gboard "${input_x}" "${input_y}" + node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & + stall_pid="$!" + sleep 0.25 + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" + wait "${stall_pid}" + capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ + 1400 \ + 430 + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ + 1400 \ + 430 -ad press 'id="menu-button"' --settle -ad press 'id="sidebar-global-new-workspace"' --settle -ad wait 'id="workspace-create-submit"' 10000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "x" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" -read -r new_input_x new_input_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" -) -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" -# Typing more lines moves the setup fields up with the composer instead of -# hiding their bottom rows behind it. -adb shell input keycombination 113 123 -adb shell input keyevent 66 66 66 -adb shell input text grown -sleep 1 -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" -node "${ASSERT}" xml-fields-follow-growth \ - "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + ad press 'id="menu-button"' --settle + ad press 'id="sidebar-global-new-workspace"' --settle + ad wait 'id="workspace-create-submit"' 10000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "x" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" + read -r new_input_x new_input_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" + ) + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" + # Typing more lines moves the setup fields up with the composer instead of + # hiding their bottom rows behind it. + adb shell input keycombination 113 123 + adb shell input keyevent 66 66 66 + adb shell input text grown + sleep 1 + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" + node "${ASSERT}" xml-fields-follow-growth \ + "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${LONG_MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" -read -r new_input_x new_input_y new_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" -) -# The editor shrinks when the keyboard opens. Its bottom stays anchored, while -# its old center can move into the setup fields after the height constraint. -new_input_y="$((new_input_y + new_input_height / 2 - 8))" -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${LONG_MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" + read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" + ) + # The editor shrinks when the keyboard opens. Its bottom stays anchored, while + # its old center can move into the setup fields after the height constraint. + new_input_y="$((new_input_y + new_input_height / 2 - 8))" + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" -# Dismissing the keyboard moves the composer down at its editing height and -# brings the setup fields back fully visible above it. -adb shell input keyevent BACK -wait_for_ime false -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -node "${ASSERT}" xml-fields-above-composer \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + # Dismissing the keyboard moves the composer down at its editing height and + # brings the setup fields back fully visible above it. + adb shell input keyevent BACK + wait_for_ime false + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + node "${ASSERT}" xml-fields-above-composer \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" +} # All hosts run the same scenario; only navigation differs. # For the shared scenario, tap the editor's blank right edge once. Tapping @@ -470,20 +482,80 @@ press_composer_control() { adb shell input tap "${x}" "${y}" } +# Opens suggestions with the keyboard closed, before it opens, or after it settled; +# checks their bounds and taps the best match where it is drawn. Accessibility keeps +# reporting rows behind the keyboard, so existence is not evidence. It also drops the +# composer while suggestions are open, so the composer bounds come from a reference +# snapshot with an empty input in the keyboard state the suggestions are checked in. +check_autocomplete() { + local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6" + local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}" + if [[ "${order}" == keyboard-closed ]]; then + prepare_composer closed + snapshot_json "${prefix}-reference.json" + adb shell input text "${query}" + else + prepare_composer open + # The composer follows the keyboard animation, which ends after the IME frame settles. + sleep 1 + snapshot_json "${prefix}-reference.json" + if [[ "${order}" == before-keyboard ]]; then + adb shell ime set "${HELPER_IME}" >/dev/null + wait_for_ime false + adb shell input text "${query}" + open_editor_keyboard "${overlay_x}" "${overlay_y}" + else + adb shell input text "${query}" + fi + fi + ad wait 'id="composer-autocomplete-popover"' 10000 + # The popover remeasures once keyboard motion settles. + sleep 1 + capture_screen "${prefix}.png" + snapshot_json "${prefix}.json" + local ime_top="" + if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi + local best_x best_y + read -r best_x best_y < <( + node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}" + ) + adb shell input tap "${best_x}" "${best_y}" + sleep 1 + snapshot_json "${prefix}-selected.json" + node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}" + clear_focused_input_with_adb + echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted" +} + +# Drafts list commands for their selected provider, and a fresh install selects none. +select_claude_model() { + local x y h + press_composer_control combined-model-selector + ad wait 'id="agent-controls-model"' 10000 + ad press 'id="agent-controls-model"' --settle + ad press 'id="model-provider-claude"' --settle + snapshot_json "${ARTIFACTS_DIR}/model-list.json" + read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*') + adb shell input tap "${x}" "${y}" + ad wait 'id="agent-controls-thinking"' 10000 + ad press 'label="Close"' --settle +} + run_host_overlays() { local host="$1" + if [[ "${host}" != chat ]]; then + prepare_composer closed + select_claude_model + fi + # Chat lists the mock provider's command; drafts list the release skills in + # this repository's .claude/skills for the selected Claude model. + local command_query=/release command_match=/release-beta command_inserted="/release-beta " + if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi + for order in keyboard-closed before-keyboard after-keyboard; do + check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}" + check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"' + done for mode in closed open; do - for query in / @README; do - prepare_composer closed - adb shell input text "${query}" - if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi - ad wait 'id="composer-autocomplete-popover"' 10000 - local kind=commands - if [[ "${query}" == @* ]]; then kind=files; fi - capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png" - clear_focused_input_with_adb - echo "PASS host=${host}: ${kind} popover keyboard=${mode}" - done prepare_composer "${mode}" press_composer_control combined-model-selector ad wait 'id="compact-provider-list"' 10000 @@ -656,19 +728,29 @@ run_host_scenario() { run_host_scroll "${host}" } +run_host() { + if [[ "${SCENARIO}" == overlays ]]; then + run_host_overlays "$1" + else + run_host_scenario "$1" + fi +} + +if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null ad wait "text=\"${AGENT_TITLE}\"" 10000 -run_host_scenario chat +run_host chat ad keyboard dismiss || true ad press 'id="workspace-tab-switcher-trigger"' --settle ad wait 'id="workspace-tab-switcher-new-agent"' 10000 ad press 'id="workspace-tab-switcher-new-agent"' --settle -ad wait 'text="New Agent"' 10000 -run_host_scenario workspace-draft +# Only a draft offers to import a session. +ad wait 'id="composer-import-agent-pill"' 10000 +run_host workspace-draft ad keyboard dismiss || true ad press 'id="menu-button"' --settle ad press 'id="sidebar-global-new-workspace"' --settle ad wait 'id="workspace-create-submit"' 10000 -run_host_scenario new-workspace +run_host new-workspace echo "Composer keyboard invariants passed" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 6bd13a5da5..637a162682 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import process from "node:process"; import sharp from "sharp"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; const [, , command, ...args] = process.argv; @@ -115,11 +116,14 @@ if (command === "stream-moved") { `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, ); } else if (command === "rect") { + // A trailing `*` matches the first identifier with that prefix. const [snapshotPath, identifier, edge] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null; const node = snapshot.data.nodes.find( (candidate) => candidate.identifier === identifier || + (prefix !== null && candidate.identifier?.startsWith(prefix)) || candidate.label === identifier || (identifier === "editable" && candidate.type.endsWith("EditText")), ); @@ -232,6 +236,27 @@ if (command === "stream-moved") { if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) { throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`); } +} else if (command === "autocomplete-best") { + // Prints the center of the expected best match after checking its placement + // against the composer in the reference snapshot. + const [snapshotPath, referencePath, expected, imeTopArgument] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const reference = JSON.parse(await fs.readFile(referencePath, "utf8")); + const best = resolveAutocompletePlacement(snapshot, { + expected, + keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined, + reference, + }); + process.stdout.write(`${best.x} ${best.y}\n`); +} else if (command === "composer-text") { + // Accessibility drops a trailing space, so compare without it. + const [snapshotPath, expected] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText")); + const text = input?.value ?? input?.label ?? ""; + if (text.trimEnd() !== expected.trimEnd()) { + throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`); + } } else if (command === "same-region") { const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args; const crop = { diff --git a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs new file mode 100644 index 0000000000..22c0b42e8f --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs @@ -0,0 +1,94 @@ +// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device +// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that +// sit behind the keyboard, so existence is not evidence: compare their screen bounds. + +const bottomOf = (rect) => rect.y + rect.height; + +function findById(nodes, identifier) { + const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect); + if (!node) throw new Error(`Missing node: ${identifier}`); + return node; +} + +function contains(outer, inner) { + return ( + inner.x >= outer.x - 1 && + inner.y >= outer.y - 1 && + inner.x + inner.width <= outer.x + outer.width + 1 && + bottomOf(inner) <= bottomOf(outer) + 1 + ); +} + +/** + * The best match is the list row nearest the composer. Check that the popover sits + * between the app's header controls and the composer, that the keyboard (when shown) + * is below both, and that the row nearest the composer is fully drawn and is + * `expected`. A clipped best match fails: the nearest whole row is then another one. + * Return where to tap it. Android leaves the composer out of the accessibility tree + * while suggestions are open, so its callers read the composer from `reference`: a + * snapshot in the same keyboard state with an empty input. + */ +export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) { + if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); + const nodes = snapshot.data.nodes; + const popover = findById(nodes, "composer-autocomplete-popover").rect; + const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect; + // The popover may cover the compact header, never the status bar above it. + const minimumTop = + nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0; + // iOS reports the keyboard in the app's tree; Android callers pass the IME frame. + const keyboardY = + keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; + const popoverBottom = bottomOf(popover); + if (popoverBottom > composer.y + 1) { + throw new Error( + `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, + ); + } + if (popover.y < minimumTop - 1) { + throw new Error( + `Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`, + ); + } + if (keyboardY !== undefined && popoverBottom > keyboardY + 1) { + throw new Error( + `Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`, + ); + } + // Rows are the innermost labelled boxes spanning the popover. The list scrolls to + // its end, so the lowest row is the best match nearest the composer. + const candidates = nodes.filter( + (node) => + node.rect && + node.label && + node.identifier !== "composer-autocomplete-popover" && + !/scroll bar/i.test(node.label) && + node.rect.height > 0 && + // The popover border insets rows by a few pixels; their labels are narrower. + node.rect.width >= popover.width * 0.97 && + contains(popover, node.rect), + ); + const area = (rect) => rect.width * rect.height; + const rows = candidates.filter( + (outer) => + !candidates.some( + (inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect), + ), + ); + if (rows.length === 0) throw new Error("Autocomplete has no visible rows"); + const best = rows.reduce((lowest, row) => + bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest, + ); + const label = best.label.split(", ")[0]; + if (label !== expected) { + throw new Error(`Best visible match is ${label}, expected ${expected}`); + } + return { + label, + x: Math.round(best.rect.x + best.rect.width / 2), + y: Math.round(best.rect.y + best.rect.height / 2), + popover, + composer, + keyboardTop: keyboardY ?? null, + }; +} diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs new file mode 100644 index 0000000000..e3fc4af072 --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -0,0 +1,229 @@ +// Composer autocomplete placement on iOS with the software keyboard: chat, workspace +// draft tab, and New workspace. Run where agent-device reaches the simulator, with the +// development app connected to the fixture host. See docs/mobile-testing.md. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../.."); +const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback; +const serverId = env("SERVER_ID"); +const agentId = env("AGENT_ID"); +const workspaceId = env("WORKSPACE_ID"); +if (!serverId || !agentId || !workspaceId) { + throw new Error( + "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", + ); +} +const draftId = env("DRAFT_ID", "composer-keyboard"); +const session = env("SESSION", "composer-keyboard-ios"); +const device = env("DEVICE", "iPhone 16 Pro"); +const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"); +const grownPrefix = "grown line one\ngrown line two\ngrown line three\n"; +// Partial queries with known best matches. `inserted` is the composer text after +// tapping the best match. +const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' }; +// The release skills ship in this repository's .claude/skills. +const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " }; +// The chat fixture's mock provider lists one command. +const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " }; + +fs.mkdirSync(artifactsDir, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function snapshot(name) { + const output = ad("snapshot", "--raw", "--json"); + fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output); + ad("screenshot", path.join(artifactsDir, `${name}.png`)); + return JSON.parse(output); +} + +const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect); +const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard"); +const composerText = (snap) => + snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? ""; + +async function waitFor(name, predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const snap = snapshot(name); + if (predicate(snap)) return snap; + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`); + await sleep(500); + } +} + +// Keyboard motion has settled once two snapshots agree on the composer position. +async function settledPlacement(name, keyboard) { + let previousTop; + return waitFor(name, (snap) => { + const composer = nodeById(snap, "message-input-root"); + if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; + if (keyboardShown(snap) !== keyboard) return false; + const stable = composer.rect.y === previousTop; + previousTop = composer.rect.y; + return stable; + }); +} + +function assertPlacement(name, snap, expected) { + const placement = resolveAutocompletePlacement(snap, { expected }); + process.stdout.write( + `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( + placement.popover.y + placement.popover.height, + )} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`, + ); + return placement; +} + +// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a +// draft has no stream, so open and close the model sheet, which takes input focus. +async function dismissKeyboard(snap) { + const composer = nodeById(snap, "message-input-root").rect; + ad("press", "4", String(Math.round(composer.y - 8))); + await sleep(1_000); + if (!keyboardShown(snapshot("dismiss-keyboard"))) return; + const sheetOpen = (sheet) => + sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close"); + ad("press", 'id="combined-model-selector"'); + await waitFor("dismiss-keyboard-sheet", sheetOpen); + ad("press", 'label="Close"'); + // The input is not hittable until the sheet has gone. + await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done)); +} + +// Tap past the end of the (short) last line so the caret lands after the mention. +// The input's right padding holds the fullscreen toggle; stay clear of it. +function focusAtEnd(snap) { + const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect; + if (!input) throw new Error("Missing composer input"); + ad( + "press", + String(Math.round(input.x + input.width * 0.7)), + String(Math.round(input.y + input.height - 6)), + ); +} + +// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end. +async function clearInput(name) { + focusAtEnd(snapshot(`${name}-before-clear`)); + const keyboard = await waitFor(`${name}-clear`, keyboardShown); + const deleteKey = keyboard.data.nodes.find( + (node) => node.type === "Key" && node.label === "delete" && node.rect, + )?.rect; + if (!deleteKey) throw new Error("Missing keyboard delete key"); + const length = composerText(keyboard).length; + ad( + "press", + String(Math.round(deleteKey.x + deleteKey.width / 2)), + String(Math.round(deleteKey.y + deleteKey.height / 2)), + "--count", + String(length + 2), + "--interval-ms", + "40", + ); +} + +// The accessibility value drops a trailing space. +const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); + +async function runCase(host, kind, { text, expected, inserted }) { + const name = `${host}-${kind}`; + await clearInput(name); + await dismissKeyboard(snapshot(`${name}-empty`)); + // fill taps the input and types at once, so suggestions open while the keyboard opens. + ad("fill", "editable=true", text); + assertPlacement( + `${name} opened while keyboard opens`, + await settledPlacement(`${name}-opening`, true), + expected, + ); + await clearInput(`${name}-settled`); + await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); + ad("type", text); + assertPlacement( + `${name} opened after keyboard settled`, + await settledPlacement(`${name}-open`, true), + expected, + ); + await dismissKeyboard(snapshot(`${name}-before-close`)); + assertPlacement( + `${name} keyboard closed`, + await settledPlacement(`${name}-closed`, false), + expected, + ); + focusAtEnd(snapshot(`${name}-before-reopen`)); + const reopened = assertPlacement( + `${name} keyboard opened under open suggestions`, + await settledPlacement(`${name}-reopened`, true), + expected, + ); + ad("press", String(reopened.x), String(reopened.y)); + const selected = await waitFor(`${name}-selected`, (snap) => + sameText(composerText(snap), inserted), + ); + if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`); + process.stdout.write( + `PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`, + ); +} + +// Drafts list commands for their selected provider, and a fresh install selects none. +async function selectClaudeModel(host) { + ad("press", 'id="combined-model-selector"'); + await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model"))); + ad("press", 'id="agent-controls-model"'); + await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude"))); + ad("press", 'id="model-provider-claude"'); + const models = await waitFor(`${host}-models`, (snap) => + snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect), + ); + const row = models.data.nodes.find( + (node) => node.identifier?.startsWith("model-row-claude-") && node.rect, + ).rect; + ad( + "press", + String(Math.round(row.x + row.width / 2)), + String(Math.round(row.y + row.height / 2)), + ); + await waitFor(`${host}-model-selected`, (snap) => + Boolean(nodeById(snap, "agent-controls-thinking")), + ); + ad("press", 'label="Close"'); + await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model")); +} + +async function runHost(host) { + if (host !== "chat") await selectClaudeModel(host); + await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase); + await runCase(host, "files", fileCase); + await runCase(host, "files-grown", { + text: `${grownPrefix}${fileCase.text}`, + expected: fileCase.expected, + inserted: `${grownPrefix}${fileCase.inserted}`, + }); + await clearInput(`${host}-done`); + await dismissKeyboard(snapshot(`${host}-done`)); +} + +const hosts = [ + ["chat", `paseo://h/${serverId}/agent/${agentId}`], + ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], + ["new-workspace", `paseo://new?serverId=${serverId}`], +]; +for (const [host, url] of hosts) { + ad("open", url, "--platform", "ios", "--device", device); + await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); + await runHost(host); +} +process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`); diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index 9608a95a0c..c6d3579206 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -35,7 +35,8 @@ export const test = base.extend<{ import { readFile, rm } from "node:fs/promises"; export default function contribute(server) { return server.before("agent.session_open", async ({ request }) => { - if (request.reason !== "create") return request; + // Metadata helpers have no workspace and must not consume the one-shot failure. + if (request.reason !== "create" || !request.workspaceId) return request; const gate = ${JSON.stringify(gate)}; let command = await readFile(gate, "utf8").catch(() => "release"); while (command === "hold") { diff --git a/packages/app/e2e/support/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts index 6b51b84609..f819cc98e9 100644 --- a/packages/app/e2e/support/helpers/command-center-agent-controls.ts +++ b/packages/app/e2e/support/helpers/command-center-agent-controls.ts @@ -18,6 +18,9 @@ export async function openCommandCenterForAgent( workspace: Pick, ): Promise { await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await expect( + page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }), + ).toBeVisible({ timeout: 30_000 }); const panel = await openCommandCenter(page); return panel.getByTestId("command-center-input"); } diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index ec6ade4102..eb0b888d24 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult( expect(overflow.overflow).toBe("hidden"); expect(overflow.whiteSpace).toBe("nowrap"); } + +/** Hold real daemon responses until the user has finished the interaction under test. */ +export async function holdDirectorySuggestionResponses(page: Page) { + let holding = false; + const pending: Array<() => void> = []; + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") { + pending.push(() => ws.send(message)); + } else { + ws.send(message); + } + }); + }); + return { + hold() { + holding = true; + }, + async release() { + await expect.poll(() => pending.length).toBeGreaterThan(0); + holding = false; + for (const send of pending.splice(0)) send(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-performance.ts b/packages/app/e2e/support/helpers/command-center-performance.ts new file mode 100644 index 0000000000..b61771c05e --- /dev/null +++ b/packages/app/e2e/support/helpers/command-center-performance.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from "node:fs"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { createMockIdleAgent } from "./archive-tab"; +import { seedWorkspace } from "./seed-client"; + +export async function seedSearchableCommandCenter() { + const seed = await seedWorkspace({ + repoPrefix: "command-center-performance-", + title: "Search workspace", + git: false, + }); + try { + const agents = []; + for (let index = 0; index < 60; index += 1) { + agents.push( + await createMockIdleAgent(seed.client, { + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: `Search agent ${String(index).padStart(3, "0")}`, + }), + ); + } + const workspaces = []; + for (let index = 0; index < 40; index += 1) { + const path = `${seed.repoPath}/workspace-${index}`; + mkdirSync(path); + const result = await seed.client.createWorkspace({ + source: { kind: "directory", path, projectId: seed.projectId }, + title: `Search workspace ${String(index).padStart(3, "0")}`, + }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + workspaces.push(result.workspace); + } + return { ...seed, agents, workspaces }; + } catch (error) { + await seed.cleanup(); + throw error; + } +} + +export async function observeCommandCenterRowRenders(page: Page) { + await page.addInitScript(() => { + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true; + }); +} + +export async function searchCommandCenter(panel: Locator, query: string) { + await panel.getByTestId("command-center-input").fill(query); +} + +export async function warmCommandCenterRows(page: Page) { + // FlatList fills its overscan window in timed batches after the first visible frame. + await page.waitForTimeout(600); + await page.evaluate(() => { + globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; + }); +} + +export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const renders = await page.evaluate(() => + Object.fromEntries( + Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) => + id.startsWith("CommandCenterResultRow:"), + ), + ), + ); + expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } }); +} + +export async function expectCommandCenterAgentWindow(panel: Locator, total: number) { + const rows = panel.locator( + '[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])', + ); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(total); +} + +export async function scrollToOldestCommandCenterAgent(panel: Locator) { + await panel.getByTestId("command-center-results").evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); +} diff --git a/packages/app/e2e/support/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts index dd4712fb36..42287ca693 100644 --- a/packages/app/e2e/support/helpers/command-center-scroll.ts +++ b/packages/app/e2e/support/helpers/command-center-scroll.ts @@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing( .evaluate((element) => element.getBoundingClientRect().top); expect(subtitleTop - titleTop).toBeLessThanOrEqual(18); } + +/** Wait for the browser to deliver wheel events before publishing a daemon update. */ +export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) { + await page.setViewportSize({ width: 1280, height: 500 }); + const viewport = panel.getByTestId("command-center-results"); + await viewport.hover(); + await page.mouse.wheel(0, 300); + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100); + const samples = await sampleCommandCenterScroll(panel); + const offset = samples.at(-1)!; + expect(offset).toBeGreaterThan(100); + return offset; +} + +async function sampleCommandCenterScroll(panel: Locator): Promise { + return panel.getByTestId("command-center-results").evaluate(async (viewport) => { + const samples: number[] = []; + for (let frame = 0; frame < 30; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(viewport.scrollTop); + } + return samples; + }); +} + +export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) { + const samples = await sampleCommandCenterScroll(panel); + expect(Math.min(...samples)).toBe(offset); + expect(Math.max(...samples)).toBe(offset); +} + +export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) { + await page.keyboard.press("ArrowDown"); + await expect + .poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop)) + .toBeLessThan(offset); + const second = panel.getByTestId("command-center-results").getByRole("button").nth(1); + await expect(second).toBeInViewport({ ratio: 1 }); +} diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 9098068698..1c3d5dda89 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -17,6 +17,7 @@ import { workspaceDeckEntryLocator, } from "./workspace-ui"; import { getServerId } from "./server-id"; +import { revealSidebarProjectWorkspaces } from "./sidebar"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; @@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) { }); } await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await revealSidebarProjectWorkspaces(page, project.projectKey); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); await page .getByTestId(draftTestId!) diff --git a/packages/app/e2e/support/helpers/file-upload-gate.ts b/packages/app/e2e/support/helpers/file-upload-gate.ts new file mode 100644 index 0000000000..c33b49f637 --- /dev/null +++ b/packages/app/e2e/support/helpers/file-upload-gate.ts @@ -0,0 +1,44 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +function isFileUploadResponse(message: WebSocketMessage): boolean { + if (typeof message !== "string") return false; + try { + const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } }; + return (envelope.message ?? envelope).type === "file.upload.response"; + } catch { + return false; + } +} + +/** + * Holds the daemon's first `file.upload.response`, so the upload is stored on + * the host but the app still sees it in flight. + */ +export async function holdFileUploadResponse(page: Page) { + let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null; + let resolveHeld: (() => void) | null = null; + const heldResponse = new Promise((resolve) => { + resolveHeld = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (held || !isFileUploadResponse(message)) { + browser.send(message); + return; + } + held = { browser, message }; + resolveHeld?.(); + }); + }); + return { + waitUntilHeld: () => heldResponse, + release: () => held?.browser.send(held.message), + /** Drops the app's connection while the upload is in flight. */ + disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }), + }; +} diff --git a/packages/app/e2e/support/helpers/local-desktop-host.ts b/packages/app/e2e/support/helpers/local-desktop-host.ts new file mode 100644 index 0000000000..863857e947 --- /dev/null +++ b/packages/app/e2e/support/helpers/local-desktop-host.ts @@ -0,0 +1,38 @@ +import type { Page } from "@playwright/test"; + +// Poses as the desktop app running the e2e daemon as its own managed daemon, so +// `useIsLocalDaemon` reports the host as this machine. `directory` is where the +// desktop bridge claims dropped files live on disk. +export async function installLocalDesktopHost(page: Page, directory: string): Promise { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set"); + } + await page.addInitScript( + (localHost: { serverId: string; directory: string }) => { + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "linux", + invoke: async (command: string) => { + if (command === "desktop_daemon_status") { + return { + serverId: localHost.serverId, + status: "running", + listen: null, + hostname: null, + pid: null, + home: "", + version: null, + desktopManaged: true, + error: null, + }; + } + return null; + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` }, + }; + }, + { serverId, directory }, + ); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 4aec3fff01..4419e5193f 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -44,6 +44,7 @@ export interface SeedDaemonClient { fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ entries: SeedWorkspaceDescriptor[]; }>; + setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>; setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; clearWorkspaceAttention(workspaceId: string): Promise; setWorkspaceLabel(input: { @@ -147,6 +148,7 @@ export interface SeedDaemonClient { predicate: (snapshot: { status: string }) => boolean, timeout?: number, ): Promise<{ status: string }>; + cancelAgent(agentId: string): Promise; sendAgentMessage(agentId: string, text: string): Promise; waitForFinish( agentId: string, diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 5b7b819bf0..66a913e7b3 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "@playwright/test"; import { getServerId } from "./server-id"; +import { projectEquivalenceViewKey } from "./project-view-key"; interface ContextMenuAnchor { x: number; @@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise { timeout: 5_000, }); } + +export async function revealSidebarProjectWorkspaces( + page: Page, + projectKey: string, +): Promise { + await page + .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`) + .click(); +} diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 95c6a4d689..2b4aa46e9b 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } + +export async function expectCompactSidebarOpen(page: Page): Promise { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); +} diff --git a/packages/app/eas.json b/packages/app/eas.json index 6ec7f248d3..719a9379e5 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -15,6 +15,9 @@ } }, "production": { + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + }, "env": { "APP_VARIANT": "production" }, diff --git a/packages/app/package.json b/packages/app/package.json index dc3deb047d..b59ef19b73 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,13 +1,13 @@ { "name": "@getpaseo/app", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "main": "index.ts", "scripts": { "start": "npm run start:expo", "start:expo": "cross-env APP_VARIANT=development expo start", "reset-project": "node ./scripts/reset-project.js", - "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", + "eas-build-post-install": "npm --prefix ../.. run build:app-deps", "android": "npm run dev:android", "android:development": "npm run dev:android", "android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", @@ -35,7 +35,6 @@ "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", - "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs", "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs deleted file mode 100644 index d3b02336b2..0000000000 --- a/packages/app/scripts/build-terminal-webview-html.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import esbuild from "esbuild"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const appRoot = path.resolve(__dirname, ".."); -const repoRoot = path.resolve(appRoot, "../.."); -const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts"); -const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts"); - -async function resolveTsPath(basePath) { - const candidates = [ - basePath, - `${basePath}.ts`, - `${basePath}.tsx`, - `${basePath}.js`, - `${basePath}.jsx`, - path.join(basePath, "index.ts"), - path.join(basePath, "index.tsx"), - path.join(basePath, "index.js"), - path.join(basePath, "index.jsx"), - ]; - for (const candidate of candidates) { - try { - const stat = await fs.stat(candidate); - if (stat.isFile()) return candidate; - } catch { - // try next candidate - } - } - return basePath; -} - -const aliasPlugin = { - name: "paseo-alias", - setup(build) { - build.onResolve({ filter: /^@\// }, async (args) => ({ - path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))), - })); - build.onResolve({ filter: /^@server\// }, async (args) => ({ - path: await resolveTsPath( - path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)), - ), - })); - }, -}; - -const result = await esbuild.build({ - entryPoints: [entry], - bundle: true, - write: false, - format: "iife", - platform: "browser", - target: ["ios15", "chrome100"], - loader: { - ".css": "text", - }, - plugins: [aliasPlugin], - logLevel: "info", -}); - -const js = result.outputFiles[0]?.text; -if (!js) { - throw new Error("terminal webview bundle produced no JavaScript"); -} - -const html = ` - - - - - - - - -`; - -const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs. -// Do not edit by hand. - -export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)}; -`; - -await fs.writeFile(output, contents); -console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts new file mode 100644 index 0000000000..f3c3f139f7 --- /dev/null +++ b/packages/app/src/agent-presentation/index.ts @@ -0,0 +1,84 @@ +import { useShallow } from "zustand/shallow"; +import { useProviderIcon } from "@/components/provider-icons"; +import type { PanelDescriptor } from "@/panels/panel-registry"; +import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; + +function formatProviderLabel(provider: Agent["provider"]): string { + if (!provider) { + return "Agent"; + } + return provider + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resolveAgentLabel(title: string | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function useAgentDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptorState = useSessionStore( + useShallow((state) => { + const session = state.sessions[context.serverId]; + const agent = + session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; + return { + provider: agent?.provider ?? "codex", + title: agent?.title ?? null, + status: agent?.status ?? null, + pendingPermissionCount: agent?.pendingPermissions.length ?? 0, + requiresAttention: agent?.requiresAttention ?? false, + attentionReason: agent?.attentionReason ?? null, + isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, + }; + }), + ); + const icon = useProviderIcon(descriptorState.provider, context.serverId); + return buildAgentDescriptor({ ...descriptorState, icon }); +} + +export function buildAgentDescriptor(input: { + provider: Agent["provider"]; + title: string | null; + status: Agent["status"] | null; + pendingPermissionCount: number; + requiresAttention: boolean; + attentionReason: Agent["attentionReason"]; + isTurnActive: boolean; + icon: PanelDescriptor["icon"]; +}): PanelDescriptor { + const provider = input.provider; + const label = resolveAgentLabel(input.title); + + return { + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + tooltip: label ?? `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + icon: input.icon, + statusBucket: input.status + ? deriveSidebarStateBucket({ + status: input.isTurnActive ? "running" : input.status, + pendingPermissionCount: input.pendingPermissionCount, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + }) + : null, + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f24df99f26..99022863f5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; @@ -507,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon return ( - {surface} + {surface} ); } diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 9b66e99c18..6d80abb1a6 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -6,6 +6,7 @@ export { export { AssistantFileLinkResolverProvider, useAssistantFileLinkEnvironment, + useAssistantFileLinkEnvironmentBridge, type AssistantFileLinkResolverProviderProps, } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index a8773a2398..935683bb0e 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue { const AssistantFileLinkResolverContext = createContext(null); +export function useAssistantFileLinkEnvironmentBridge() { + const value = useContext(AssistantFileLinkResolverContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [value], + ); +} + export function AssistantFileLinkResolverProvider({ client, serverId, diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx index 386cb429f1..18d2727e82 100644 --- a/packages/app/src/assistant-file-links/use-file-link.test.tsx +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -2,9 +2,9 @@ * @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import React, { useCallback, useMemo, useState, type ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToastApi } from "@/components/toast-host"; import type { InlinePathTarget } from "./parse"; import { AssistantFileLinkResolverProvider } from "./provider"; @@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t } describe("useFileLink", () => { + // Lookups resolve after a test's last assertion; an unmounted hook cannot re-render + // once jsdom is torn down. + afterEach(() => { + cleanup(); + }); + it("returns the same object across no-op parent rerenders", () => { const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([])); const queryClient = createQueryClient(); diff --git a/packages/app/src/attachments/upload-limit.ts b/packages/app/src/attachments/upload-limit.ts new file mode 100644 index 0000000000..8a81043f4b --- /dev/null +++ b/packages/app/src/attachments/upload-limit.ts @@ -0,0 +1,2 @@ +/** Largest file the app sends through `client.uploadFile`. */ +export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 58f1a4ce54..7a44f8ccd9 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,3 +1,5 @@ +import equal from "fast-deep-equal"; +import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, Modal, @@ -10,12 +12,25 @@ import { type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; -import { AgentStatusDot } from "@/components/agent-status-dot"; +import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; +import { StatusIcon } from "@/components/status-icon"; +import { useWorkspaceStatus } from "@/workspace-status"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import type { Theme } from "@/styles/theme"; import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text"; import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { MaterialFileIcon } from "@/components/material-file-icon"; @@ -69,9 +84,7 @@ import { buildContributionSections, filterAndRankBuiltInResults, joinSubtitleParts, - moveActiveResultId, PINNED_SECTION_BAND, - preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, type CommandCenterFileResult, @@ -81,6 +94,7 @@ import { type CommandCenterSearchFields, type CommandCenterWorkspaceResult, } from "./results"; +import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction"; import { useWorkspaceFileSearch } from "./workspace-file-search"; const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({ @@ -106,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] }; } +function createWorkspaceResultAction(serverId: string, workspaceId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToWorkspace({ serverId, workspaceId }); + }; +} + +function createAgentResultAction(serverId: string, agentId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId, agentId }); + }; +} + /** * Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what * was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke. @@ -115,6 +143,9 @@ function useBuiltInRows(open: boolean): { agents: CommandCenterAgentResult[]; } { const { t } = useTranslation(); + const previousRows = useRef( + new Map(), + ); const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; @@ -136,7 +167,22 @@ function useBuiltInRows(open: boolean): { const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets }); return useMemo(() => { - if (!open) return { workspaces: [], agents: [] }; + if (!open) { + previousRows.current.clear(); + return { workspaces: [], agents: [] }; + } + // Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged + // row data so a directory update does not invalidate every mounted ResultRow. + const nextRows = new Map(); + const preserve = ( + row: T, + ): T => { + const previous = previousRows.current.get(row.id); + if (previous) row.run = previous.run; + const stable = previous && equal(previous, row) ? (previous as T) : row; + nextRows.set(row.id, stable); + return stable; + }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { const workspaceProject = { @@ -147,23 +193,24 @@ function useBuiltInRows(open: boolean): { for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; - allWorkspaces.push({ - kind: "workspace", - id: `workspace:${host.serverId}:${workspace.id}`, - title: workspace.title ?? workspace.name, - subtitle: joinSubtitleParts([ - showHost ? host.serverName : null, - project.projectName, - workspace.currentBranch, - ]), - project: workspaceProject, - changeRequestNumber: workspace.changeRequestNumber, - activityAt: workspace.statusEnteredAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); - }, - }); + allWorkspaces.push( + preserve({ + kind: "workspace", + id: `workspace:${host.serverId}:${workspace.id}`, + title: workspace.title ?? workspace.name, + subtitle: joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]), + serverId: host.serverId, + workspaceId: workspace.id, + project: workspaceProject, + changeRequestNumber: workspace.changeRequestNumber, + activityAt: workspace.statusEnteredAt, + run: createWorkspaceResultAction(host.serverId, workspace.id), + }), + ); } } } @@ -174,22 +221,20 @@ function useBuiltInRows(open: boolean): { const workspaceTitle = agent.workspaceId ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) : undefined; - return { + return preserve({ kind: "agent", id: `agent:${agent.serverId}:${agent.id}`, agent, - title: agent.title || t("shell.commandCenter.newAgent"), + title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"), subtitle: joinSubtitleParts([ showHost ? agent.serverLabel : null, workspaceTitle ?? shortenPath(agent.cwd), ]), activityAt: agent.lastActivityAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; + run: createAgentResultAction(agent.serverId, agent.id), + }); }); + previousRows.current = nextRows; return { workspaces: allWorkspaces, agents: agentRows }; }, [agents, iconDataByProjectViewKey, open, projects, showHost, t]); } @@ -227,8 +272,6 @@ interface CommandCenterState { setQuery(query: string): void; activeId: string | null; rows: readonly CommandCenterListRow[]; - results: readonly CommandCenterResult[]; - rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; inputRef: React.RefObject; fileSearchLoading: boolean; @@ -238,7 +281,10 @@ interface CommandCenterState { key(key: string): boolean; } -function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState { +function useCommandCenterState( + listMetrics: CommandCenterListMetrics, + interaction: CommandCenterInteraction, +): CommandCenterState { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); @@ -248,8 +294,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); - const [query, setQueryState] = useState(""); - const [activeId, setActiveId] = useState(null); + const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot); const builtInSections = useBuiltInSections(open, query); const { entries: fileSearchEntries, @@ -296,15 +341,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe ), [builtInSections, contributionSections, fileSections, listMetrics, scope], ); - const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); - - // Editing the query re-ranks everything, so an arrow-key selection made under the previous - // query is no longer meaningful. Reset it in the same update as the query rather than in an - // effect, so there is no intermediate render holding the stale selection. - const setQuery = useCallback((next: string) => { - setQueryState(next); - setActiveId(null); - }, []); + useLayoutEffect(() => { + interaction.update({ open, scope, query, projection, loading: fileSearchLoading }); + }, [interaction, open, scope, query, projection, fileSearchLoading]); const close = useCallback(() => setOpen(false), [setOpen]); const select = useCallback( @@ -327,18 +366,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe return true; } if (pressed === "Enter") { - const selected = results.find((result) => result.id === resolvedActiveId); + const selected = results.find((result) => result.id === activeId); if (!selected) return false; select(selected); return true; } if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false; - if (results.length === 0) return false; - const direction = pressed === "ArrowDown" ? "next" : "previous"; - setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); - return true; + return interaction.move(pressed === "ArrowDown" ? "next" : "previous"); }, - [close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope], + [ + close, + open, + projection.selectableResults, + query, + activeId, + scope, + select, + setScope, + interaction, + ], ); useEffect(() => { @@ -348,8 +394,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe const timer = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(timer); } - setQueryState(""); - setActiveId(null); if (!wasOpen) return; const element = takeCommandCenterFocusRestoreElement(); if (!element) return; @@ -367,11 +411,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe scope, clearScope: () => setScope(null), query, - setQuery, - activeId: resolvedActiveId, + setQuery: interaction.setQuery, + activeId, rows: projection.rows, - results: projection.selectableResults, - rowIndexByResultId: projection.rowIndexByResultId, offsets: projection.offsets, inputRef, fileSearchLoading, @@ -397,6 +439,7 @@ const ResultRow = memo(function ResultRow({ active, onSelect, }: ResultRowProps) { + recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]); const press = useCallback(() => onSelect(result), [onSelect, result]); const choice = result.kind === "contribution" && result.contribution.presentation.kind === "choice" @@ -430,7 +473,19 @@ const ResultRow = memo(function ResultRow({ result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId } > - + {(state) => ( + + )} ); }); @@ -484,6 +539,72 @@ function SubtitleWithTimestamp({ ); } +const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function AgentResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterAgentResult; + query: string; + backdrop: SurfaceBackdrop; +}) { + const agent = result.agent; + const descriptor = useAgentDescriptor( + { kind: "agent", agentId: agent.id }, + { serverId: agent.serverId }, + ); + const Icon = useMemo( + () => withUnistyles(descriptor.icon, providerIconMapping), + [descriptor.icon], + ); + return ( + + + + + + + + + + + + + + + + ); +} + +function WorkspaceResultIcon({ + result, + backdrop, +}: { + result: CommandCenterWorkspaceResult; + backdrop: SurfaceBackdrop; +}) { + const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId); + return ( + + + + + + ); +} + /** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) { const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase(); @@ -498,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu ); } -function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) { +function ResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterResult; + query: string; + backdrop: SurfaceBackdrop; +}) { if (result.kind === "file") { return ( @@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: ); } if (result.kind === "agent") { - const agent = result.agent; - return ( - - - - - - - - - - - - ); + return ; } if (result.kind === "workspace") { const key = result.id.slice("workspace:".length); @@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: {/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */} - - + + >(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef(null); - const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); + const [interaction] = useState(() => + createCommandCenterInteraction((offset) => { + const list = listRef.current ?? bottomSheetListRef.current; + list?.scrollToOffset({ offset, animated: false }); + }), + ); + const state = useCommandCenterState( + isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop, + interaction, + ); + const showBottomSheet = isCompact && isNative; + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: state.open, isEnabled: showBottomSheet, onClose: state.close, }); - const revealActiveResult = useCallback(() => { - if (!state.open || !state.activeId) return; - const index = state.rowIndexByResultId.get(state.activeId); - if (index === undefined) return; - const { offset, visibleLength } = scrollMetricsRef.current; - if (visibleLength <= 0) return; - const rowTop = state.offsets[index]; - const rowBottom = rowTop + state.rows[index].height; - let nextOffset: number | null = null; - if (rowTop < offset) nextOffset = rowTop; - if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength; - if (nextOffset === null) return; - const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; - const boundedOffset = Math.max(0, nextOffset); - scrollMetricsRef.current.offset = boundedOffset; - ref?.scrollToOffset({ offset: boundedOffset, animated: false }); - }, [ - showBottomSheet, - state.activeId, - state.offsets, - state.open, - state.rowIndexByResultId, - state.rows, - ]); - useEffect(() => { - if (!state.open) { - scrollMetricsRef.current = { offset: 0, visibleLength: 0 }; - return; - } - revealActiveResult(); - }, [revealActiveResult, state.open]); const handleListLayout = useCallback( (event: LayoutChangeEvent) => { - scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height; - revealActiveResult(); + interaction.layout(event.nativeEvent.layout.height); }, - [revealActiveResult], + [interaction], + ); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + interaction.scrolled(event.nativeEvent.contentOffset.y); + }, + [interaction], ); - const handleListScroll = useCallback((event: NativeSyntheticEvent) => { - scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y; - }, []); useEffect(() => { if (!showBottomSheet || !state.open) return; const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); @@ -790,6 +864,14 @@ export function CommandCenter() { windowSize: 5, onLayout: handleListLayout, onScroll: handleListScroll, + onScrollBeginDrag: interaction.beginManualScroll, + onTouchStart: interaction.beginManualScroll, + ...(isWeb + ? { + onWheel: interaction.beginManualScroll, + onMouseDown: interaction.beginManualScroll, + } + : {}), scrollEventThrottle: 16, }; const keyPress = useCallback( diff --git a/packages/app/src/command-center/interaction.test.ts b/packages/app/src/command-center/interaction.test.ts new file mode 100644 index 0000000000..5b5d8bdef9 --- /dev/null +++ b/packages/app/src/command-center/interaction.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { createCommandCenterInteraction } from "./interaction"; +import { projectCommandCenterRows, type CommandCenterFileResult } from "./results"; +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; + +function results(ids = ["a", "b", "c", "d"]) { + return projectCommandCenterRows( + [ + { + id: "files", + band: 0, + rank: 0, + title: "Files", + results: ids.map((id) => ({ + id, + kind: "file", + filePath: id, + title: id, + subtitle: "", + run() {}, + })), + }, + ], + { singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 }, + ); +} + +function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) { + const offsets: number[] = []; + const model = createCommandCenterInteraction((offset) => offsets.push(offset)); + const publish = (ids = ["a", "b", "c", "d"], loading = false) => + model.update({ + open: true, + scope: options.scope ?? null, + query: model.getSnapshot().query, + projection: results(ids), + loading, + }); + publish(); + if (options.measured !== false) model.layout(80); + return { model, offsets, publish }; +} + +describe("Command Center interaction", () => { + it("keeps manual scrolling through result refresh, removal, and resizing", () => { + const { model, offsets, publish } = setup(); + model.beginManualScroll(); + model.scrolled(100); + publish(); + publish(["b", "c", "d"]); + model.layout(60); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([]); + }); + + it("reveals only clipped keyboard selections and wraps around", () => { + const { model, offsets } = setup(); + expect(model.move("next")).toBe(true); + expect(offsets).toEqual([20]); + model.scrolled(20); // The programmatic scroll callback is only a measurement. + model.move("next"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60, 20]); + model.move("previous"); + expect(model.getSnapshot().activeId).toBe("d"); + expect(offsets).toEqual([20, 60, 20, 100]); + }); + + it("keyboard navigation takes control again after manual scrolling", () => { + const { model, offsets } = setup(); + model.beginManualScroll(); + model.scrolled(100); + model.move("next"); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([60]); + }); + + it("a new query reveals even when the active ID stays the same", () => { + const { model, offsets, publish } = setup(); + model.scrolled(100); + model.setQuery("a"); + expect(offsets).toEqual([]); + publish(["a", "b"]); + expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" }); + expect(offsets).toEqual([20]); + model.scrolled(100); + publish(["a", "b"]); + expect(offsets).toEqual([20]); + }); + + it("an arrow command reveals a single result even when its ID cannot change", () => { + const { model, offsets, publish } = setup(); + publish(["a"]); + model.scrolled(30); + model.move("next"); + expect(offsets).toEqual([20]); + }); + + it("defers reveals until layout exists and consumes them only once", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.move("previous"); + expect(offsets).toEqual([]); + model.layout(80); + expect(offsets).toEqual([100]); + model.scrolled(0); + publish(); + model.layout(60); + expect(offsets).toEqual([100]); + }); + + it("manual interaction cancels an unmeasured request", () => { + const { model, offsets } = setup({ measured: false }); + model.move("previous"); + model.beginManualScroll(); + model.layout(80); + expect(offsets).toEqual([]); + }); + + it("waits for scoped search results instead of revealing the previous query's rows", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.scrolled(100); + model.setQuery("new"); + publish(["a", "b"], true); + expect(offsets).toEqual([]); + expect(model.getSnapshot().activeId).toBeNull(); + publish(["b", "a"]); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([20]); + }); + + it("manual interaction cancels a reveal awaiting search results", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.setQuery("new"); + publish([], true); + model.beginManualScroll(); + model.scrolled(100); + publish(["new"]); + expect(model.getSnapshot().activeId).toBe("new"); + expect(offsets).toEqual([]); + }); + + it("empty searches complete without granting future passive results permission to scroll", () => { + const { model, offsets, publish } = setup(); + model.setQuery("missing"); + publish([]); + expect(model.move("next")).toBe(false); + expect(model.getSnapshot().activeId).toBeNull(); + model.scrolled(100); + publish(["late"]); + expect(offsets).toEqual([]); + }); + + it("scope changes reset selection and request a reveal", () => { + const { model, offsets } = setup(); + model.move("next"); + model.scrolled(100); + model.update({ open: true, scope: "files", query: "", projection: results(), loading: false }); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([20, 20]); + }); + + it("close discards the old query, metrics, and deferred reveal", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.setQuery("old"); + publish(); + model.move("previous"); + model.update({ open: false, scope: null, query: "old", projection: results(), loading: false }); + model.layout(80); + expect(model.getSnapshot()).toEqual({ query: "", activeId: null }); + expect(offsets).toEqual([]); + publish(); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([]); + }); +}); diff --git a/packages/app/src/command-center/interaction.ts b/packages/app/src/command-center/interaction.ts new file mode 100644 index 0000000000..8ac0acce15 --- /dev/null +++ b/packages/app/src/command-center/interaction.ts @@ -0,0 +1,131 @@ +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; +import { + moveActiveResultId, + preserveActiveResultId, + type CommandCenterListProjection, +} from "./results"; + +interface Snapshot { + query: string; + activeId: string | null; +} + +interface ResultsInput { + open: boolean; + scope: CommandCenterScope; + query: string; + projection: CommandCenterListProjection; + loading: boolean; +} + +/** Selection persists; permission to move the viewport lasts for one user command. */ +export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) { + let snapshot: Snapshot = { query: "", activeId: null }; + const listeners = new Set<() => void>(); + let open = false; + let scope: CommandCenterScope = null; + let projection: CommandCenterListProjection | null = null; + let loading = false; + let revealPending = false; + let offset = 0; + let visibleLength = 0; + + function publish(next: Snapshot) { + if (next.query === snapshot.query && next.activeId === snapshot.activeId) return; + snapshot = next; + for (const listener of listeners) listener(); + } + + function reveal() { + if (!open || !revealPending || !projection || visibleLength <= 0) return; + // Scoped file search keeps its previous entries while the next query is loading. + if (scope === "files" && loading) return; + if (!snapshot.activeId) { + if (!loading) revealPending = false; + return; + } + const index = projection.rowIndexByResultId.get(snapshot.activeId); + if (index === undefined) return; + const top = projection.offsets[index]; + const bottom = top + projection.rows[index].height; + revealPending = false; + let nextOffset = offset; + if (top < offset) nextOffset = top; + if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength; + nextOffset = Math.max(0, nextOffset); + if (nextOffset === offset) return; + offset = nextOffset; + scrollToOffset(nextOffset); + } + + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + update(input: ResultsInput) { + const resetSelection = input.open && (!open || scope !== input.scope); + open = input.open; + scope = input.scope; + if (!open) { + projection = null; + revealPending = false; + offset = 0; + visibleLength = 0; + publish({ query: "", activeId: null }); + return; + } + if (resetSelection) { + revealPending = true; + publish({ ...snapshot, activeId: null }); + } + // A query command invalidates the old projection until React supplies its new results. + if (input.query !== snapshot.query) return; + projection = input.projection; + loading = input.loading; + if (scope !== "files" || !loading) { + publish({ + ...snapshot, + activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults), + }); + } + reveal(); + }, + setQuery(query: string) { + projection = null; + revealPending = true; + publish({ query, activeId: null }); + }, + move(direction: "next" | "previous"): boolean { + if ( + !open || + !projection || + (scope === "files" && loading) || + projection.selectableResults.length === 0 + ) + return false; + publish({ + ...snapshot, + activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction), + }); + revealPending = true; + reveal(); + return true; + }, + layout(height: number) { + visibleLength = height; + reveal(); + }, + scrolled(nextOffset: number) { + offset = nextOffset; + }, + beginManualScroll() { + revealPending = false; + }, + }; +} + +export type CommandCenterInteraction = ReturnType; diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 688352f00f..fe7bec5897 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -110,6 +110,8 @@ function workspace( ): CommandCenterWorkspaceResult { return { kind: "workspace", + serverId: "server", + workspaceId: "workspace", id, title, subtitle, diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index 84fc91d3f0..52034ca893 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -55,6 +55,8 @@ export interface CommandCenterWorkspaceResult { id: string; title: string; subtitle: string; + serverId: string; + workspaceId: string; project: CommandCenterWorkspaceProject; /** PR/MR number backing this workspace, so a number query can match it exactly. */ changeRequestNumber: number | null; diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index 1008604bec..8864da5ea8 100644 --- a/packages/app/src/command-center/workspace-file-search.ts +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; @@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string openWorkspaceFileFromExplorer({ filePath: path, persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), - closeExplorerAfterOpen: true, - showMobileAgent: usePanelStore.getState().showMobileAgent, openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) => useWorkspaceLayoutStore.getState().openTab({ workspaceKey, @@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string intent: "reveal", placement, }), - focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + revealWorkspaceTab, }); }, [serverId, workspaceId], diff --git a/packages/app/src/command-center/workspace-search.test.ts b/packages/app/src/command-center/workspace-search.test.ts index 93bb5fc6bf..b43e9edfbd 100644 --- a/packages/app/src/command-center/workspace-search.test.ts +++ b/packages/app/src/command-center/workspace-search.test.ts @@ -11,6 +11,8 @@ function workspace(input: { }): CommandCenterWorkspaceResult { return { kind: "workspace", + serverId: "server", + workspaceId: "workspace", id: input.id, title: input.title ?? input.id, subtitle: input.subtitle ?? "", diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 06f7872995..a1cb5cf779 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push( buildNewWorkspaceRoute({ serverId, @@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { testID: "add-project-flow-add-host", select: () => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push(buildSettingsAddHostRoute(Date.now())); }, }); diff --git a/packages/app/src/components/connection-failure-copy.test.ts b/packages/app/src/components/connection-failure-copy.test.ts index 21cfcd40e5..de78ba43eb 100644 --- a/packages/app/src/components/connection-failure-copy.test.ts +++ b/packages/app/src/components/connection-failure-copy.test.ts @@ -28,6 +28,23 @@ function failure(message: string) { } describe("direct connection failure copy", () => { + it.each([ + "paseo-qa-nonexistent.invalid", + "ssl-paseo-qa.invalid", + "tls-paseo-qa.invalid", + "certificate-paseo-qa.invalid", + ])( + "identifies Android DNS failures for %s without treating the hostname as TLS evidence", + (host) => { + const message = `Unable to resolve host "${host}": No address associated with hostname`; + expect(failure(message)).toEqual({ + title: "Failed: tcp://example.test:443?ssl=true", + detail: labels.hostNotFound, + raw: `transport error — ${message}`, + }); + }, + ); + it("identifies Android certificate validation errors without calling them DNS failures", () => { const message = "java.security.cert.CertPathValidatorException: Trust anchor for certification path not found."; @@ -43,6 +60,7 @@ describe("direct connection failure copy", () => { ["SSL handshake failed", labels.tlsError], ["TLS handshake failed", labels.tlsError], ["getaddrinfo ENOTFOUND example.test", labels.hostNotFound], + ["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound], ["Host not found", labels.hostNotFound], ["connect ECONNREFUSED", labels.refused], ["connection timed out", labels.timedOut], diff --git a/packages/app/src/components/connection-failure-copy.ts b/packages/app/src/components/connection-failure-copy.ts index 1491ed01d8..e1ad2f203c 100644 --- a/packages/app/src/components/connection-failure-copy.ts +++ b/packages/app/src/components/connection-failure-copy.ts @@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: { rawLower.includes("err_connection_refused") ) { detail = labels.refused; + } else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) { + detail = labels.hostNotFound; } else if ( rawLower.includes("certificate") || rawLower.includes("certpathvalidator") || @@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: { rawLower.includes("ssl") ) { detail = labels.tlsError; - } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { + } else if (rawLower.includes("not found")) { detail = labels.hostNotFound; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = labels.hostUnreachable; diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx index bafc9c0227..fa381bf545 100644 --- a/packages/app/src/components/file-drop/file-drop-backdrop.tsx +++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx @@ -34,7 +34,7 @@ export function FileDropBackdrop() { // non-animated children (applying themed Unistyles styles to an Animated.View crashes // on theme change — see docs/unistyles.md). return ( - + diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 89bbbca7c4..e9b80a00be 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem; /** * What a consumer (e.g. a composer) registers to receive files dropped onto the - * surrounding FileDropZone. Raster images arrive already persisted via `onFiles`; - * everything else arrives raw via `onGenericFiles`. + * surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that + * also provides `onFiles` gets its raster images persisted as attachments. */ export interface FileDropSink { - onFiles: (images: ImageAttachment[]) => void; + onFiles?: (images: ImageAttachment[]) => void; onGenericFiles?: (items: DroppedItem[]) => void; onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index 947c95d91e..2e3dc320b4 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -148,8 +148,9 @@ export function useDropListeners({ sink.onGenericFiles(items); } - const imagePaths = payload.paths.filter(isRasterImagePath); - if (imagePaths.length === 0) { + const onFiles = sink.onFiles; + const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : []; + if (!onFiles || imagePaths.length === 0) { return; } @@ -162,7 +163,7 @@ export function useDropListeners({ // composer the user dropped on (matches the web path below). No post-persist busy // re-check: a mixed drop's own generic upload flips the busy flag, and re-checking // would discard the image from the same drop. - sink.onFiles(attachments); + onFiles(attachments); return; }) .catch((error) => { @@ -262,16 +263,17 @@ export function useDropListeners({ sink.onGenericFiles(genericItems); } - const imageFiles = files.filter(isRasterImageFile); + const onFiles = sink.onFiles; + const imageFiles = onFiles ? files.filter(isRasterImageFile) : []; - if (imageFiles.length === 0) return; + if (!onFiles || imageFiles.length === 0) return; try { const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); // No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag, // and re-checking would discard the image from the same drop. The guard at drop start // already rejects drops that begin while busy. - sink.onFiles(attachments); + onFiles(attachments); } catch (error) { console.error("[useDropListeners] Failed to process dropped files:", error); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 79f836d1d4..3f5d218150 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -166,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { - showMobileAgent(); void openProjectPicker(); - }, [showMobileAgent, openProjectPicker]); + }, [openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); @@ -205,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, []); const handleImportSessionMobile = useCallback(() => { - showMobileAgent(); openImportSession(); - }, [openImportSession, showMobileAgent]); + }, [openImportSession]); const labels = useMemo( (): SidebarLabels => ({ diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 73c008dd22..0e1d7d28ab 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -1,7 +1,8 @@ /** * @vitest-environment jsdom */ -import { act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,6 +84,8 @@ function workspace(input: { scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], - workspaceOrderByProject: { - ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`], - ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`], - }, }); }); } @@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => { useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); }); }); @@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => { }); }); }); + +describe("sidebar group visibility", () => { + const entries = (statuses: WorkspaceDescriptor["status"][]) => + statuses.map((status, index) => + createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: workspace({ + id: `row-${index + 1}`, + projectId: "project-a", + projectDisplayName: "Project A", + name: `Row ${index + 1}`, + status, + }), + }), + ); + const policy = { + kind: "workspaces" as const, + getStatusBucket: (entry: ReturnType) => entry.statusBucket, + }; + const names = (items: readonly ReturnType[]) => + items.map((item) => item.workspaceId); + + it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { + const items = entries(Array(count).fill("done")); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items.slice(0, 7)); + expect(result.current.canToggle).toBe(count > 7); + }); + + it.each(["needs_input", "failed", "attention", "running"] as const)( + "keeps row twelve visible for %s without filling gaps", + (status) => { + const items = entries([...Array(11).fill("done"), status]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-12", + ]); + expect(result.current.visibleItems[7]).toBe(items[11]); + expect(result.current.canToggle).toBe(true); + }, + ); + + it("shows eight active rows once with no redundant toggle", () => { + const items = entries([ + "running", + "needs_input", + "attention", + "failed", + "running", + "attention", + "needs_input", + "failed", + ]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items); + expect(result.current.canToggle).toBe(false); + }); + + it("recomputes current ordered membership and retains reveal through status, order and size changes", () => { + const items = entries([ + ...Array(7).fill("done"), + "running", + "done", + "failed", + "done", + "attention", + ]); + const { result, rerender, unmount } = renderHook( + ({ rows }) => useLimitedSidebarGroup(rows, policy), + { initialProps: { rows: items } }, + ); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-8", + "row-10", + "row-12", + ]); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) }); + expect(result.current.canToggle).toBe(false); + expect(result.current.expanded).toBe(true); + rerender({ rows: items.slice(0, 3) }); + expect(result.current.canToggle).toBe(false); + rerender({ rows: items.toReversed() }); + expect(result.current.visibleItems).toEqual(items.toReversed()); + act(() => result.current.toggleExpanded()); + expect(names(result.current.visibleItems)).toEqual([ + "row-12", + "row-11", + "row-10", + "row-9", + "row-8", + "row-7", + "row-6", + ]); + const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" }; + rerender({ rows: [newest, ...items] }); + expect(result.current.visibleItems[0]).toBe(newest); + expect(result.current.visibleItems).toHaveLength(10); + expect(result.current.expanded).toBe(false); + act(() => result.current.toggleExpanded()); + unmount(); + const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(remounted.result.current.expanded).toBe(false); + expect(remounted.result.current.visibleItems).toHaveLength(10); + }); + + it("follows effective pending creation, recovery and agent activity without changing Created order", () => { + const items = entries(Array(12).fill("done")); + const descriptor = workspace({ + id: "row-12", + projectId: "project-a", + projectDisplayName: "Project A", + name: "Row 12", + }); + const pending = { + draftId: "draft", + serverId: SERVER_ID, + workspaceId: descriptor.id, + agentId: null, + clientMessageId: "message", + text: "Create", + timestamp: 1234, + lifecycle: "active" as const, + }; + const { result, rerender } = renderHook( + ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), + { initialProps: { last: items[11] } }, + ); + expect(result.current.visibleItems).toHaveLength(7); + const creating = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: pending }, + }); + expect(creating.statusBucket).toBe("running"); + rerender({ last: creating }); + expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]); + const abandoned = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } }, + }); + rerender({ last: abandoned }); + expect(result.current.visibleItems).toHaveLength(7); + rerender({ last: creating }); + for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) { + const last = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + workspaceAgentActivity: new Map([ + [descriptor.id, { agentId: "agent", status, enteredAt: null }], + ]), + }); + expect(last.statusBucket).toBe(status); + rerender({ last }); + expect(names(result.current.visibleItems)).toEqual( + status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"], + ); + } + }); + + it("preserves the twenty-row pinned policy regardless of status", () => { + const items = entries(Array(21).fill("running")); + const { result } = renderHook(() => useLimitedSidebarGroup(items)); + expect(result.current.visibleItems).toEqual(items.slice(0, 20)); + expect(result.current.canToggle).toBe(true); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toHaveLength(20); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 74d57ca667..cc248adeff 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps { shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; @@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({ ]; } -function noop() {} - const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", @@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1403,7 +1401,6 @@ function WorkspaceRowItem({ isCreating = false, selectionEnabled, activeWorkspaceSelection, - onWorkspacePress, drag, isDragging = false, dragHandleProps, @@ -1412,9 +1409,8 @@ function WorkspaceRowItem({ if (!workspace.serverId) { return; } - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( @@ -1465,7 +1461,7 @@ function areWorkspaceRowItemPropsEqual( previous.hostBadge === next.hostBadge && previous.leadingProjectName === next.leadingProjectName && previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri && - previous.shortcutNumber === next.shortcutNumber && + (!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.canPin === next.canPin && @@ -1507,7 +1503,7 @@ function WorkspaceRow({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1552,16 +1548,12 @@ function ProjectBlock({ selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, - parentGestureRef, onToggleCollapsed, onWorkspacePress, - onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, - useNestable, - dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1577,16 +1569,12 @@ function ProjectBlock({ selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; - parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; - useNestable: boolean; - dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1594,12 +1582,20 @@ function ProjectBlock({ supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const visibilityPolicy = useMemo( + () => ({ + kind: "workspaces" as const, + getStatusBucket: (workspace: SidebarWorkspacePlacement) => + workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, + }), + [workspaceEntriesByKey], + ); const { visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces); + } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const preferredServerId = activeHostStatus === "online" ? activeServerId : null; @@ -1628,16 +1624,10 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - ( - item: SidebarWorkspacePlacement, - input?: { - drag?: () => void; - isDragging?: boolean; - dragHandleProps?: DraggableListDragHandleProps; - }, - ) => { + (item: SidebarWorkspacePlacement) => { return ( ); }, @@ -1671,29 +1658,6 @@ function ProjectBlock({ ], ); - const renderWorkspace = useCallback( - ({ - item, - drag: workspaceDrag, - isActive, - dragHandleProps: workspaceDragHandleProps, - }: DraggableRenderItemInfo) => { - return renderWorkspaceRow(item, { - drag: workspaceDrag, - isDragging: isActive, - dragHandleProps: workspaceDragHandleProps, - }); - }, - [renderWorkspaceRow], - ); - - const handleWorkspaceDragEnd = useCallback( - (workspaces: SidebarWorkspacePlacement[]) => { - onWorkspaceReorder(project.viewKey, workspaces); - }, - [onWorkspaceReorder, project.viewKey], - ); - const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1759,20 +1723,12 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - + style={styles.workspaceListContainer} + > + {visibleWorkspaces.map(renderWorkspaceRow)} + {canToggleWorkspaces ? ( [0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && - previous.workspaceEntriesByKey === next.workspaceEntriesByKey && + projectWorkspaceEntriesEqual(previous, next) && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && - previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + projectShortcutBadgesEqual(previous, next) && previous.hostBadgeByServerId === next.hostBadgeByServerId && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && - previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && - previous.onWorkspaceReorder === next.onWorkspaceReorder && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && - previous.useNestable === next.useNestable && - previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId && areProjectBlockSelectionsEqual(previous, next) ); } +function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { + if ( + !next.showShortcutBadges || + previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey + ) + return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.shortcutIndexByWorkspaceKey.get(workspaceKey) === + next.shortcutIndexByWorkspaceKey.get(workspaceKey), + ); +} + +function projectWorkspaceEntriesEqual( + previous: ProjectBlockProps, + next: ProjectBlockProps, +): boolean { + if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.workspaceEntriesByKey.get(workspaceKey) === + next.workspaceEntriesByKey.get(workspaceKey), + ); +} + function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, @@ -2137,8 +2114,6 @@ function ProjectModeList({ const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); - const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); - const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -2236,30 +2211,6 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); - const handleWorkspaceReorder = useCallback( - (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { - const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); - if ( - !hasVisibleOrderChanged({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) { - return; - } - - setWorkspaceOrder( - projectViewKey, - mergeWithRemainder({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }), - ); - }, - [getWorkspaceOrder, setWorkspaceOrder], - ); - const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2306,16 +2257,12 @@ function ProjectModeList({ selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} - parentGestureRef={parentGestureRef} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} - onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} - useNestable={platformIsNative} - dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2329,15 +2276,12 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, - handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, - parentGestureRef, - dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index be5a66ff6f..4fb04ebb67 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const hosts = useHosts(); // `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed // from it would lose the row that undoes the filter as soon as the filter narrowed to one. - const { allProjects, resolvedProjectFilters } = useSidebarModel(); + const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel(); const { labels } = useWorkspaceLabelProjection(); const [managerOpen, setManagerOpen] = useState(false); const openManager = useCallback(() => setManagerOpen(true), []); @@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, ]; + if (preferences.grouping === "project") { + definitions.push({ + id: "sort", + title: t("sidebar.display.sort.label"), + content: workspaceSortSupported ? ( + + ) : ( + {t("sidebar.display.sort.updateHost")} + ), + }); + } + if (showHostFilter) { definitions.push({ id: "hostFilter", @@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, [ t, preferences, + workspaceSortSupported, hosts, showHostFilter, showProjectFilter, @@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { > {t("sidebar.display.grouping.label")} + {preferences.grouping === "project" ? ( + + {t("sidebar.display.sort.label")} + + ) : null} setWorkspaceSort("created"), [setWorkspaceSort]); + const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); + return ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ); +} + /** * Every label you could filter by, wherever it lives, one row each. * diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index 68d347a258..a2ef3212e0 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useCallback, useMemo } from "react"; import { useAppSettings, @@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences { showBackground: boolean; toggleBackground: () => void; grouping: SidebarGroupMode; + workspaceSort: WorkspaceSort; + setWorkspaceSort: (sort: WorkspaceSort) => void; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; setTitleSource: (source: WorkspaceTitleSource) => void; @@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences { export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const showBackground = useSidebarViewStore((state) => state.showBackground); const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, titleSource: workspaceTitleSource, setTitleSource, @@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, workspaceTitleSource, setTitleSource, diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts new file mode 100644 index 0000000000..ce9f47671c --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -0,0 +1,21 @@ +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; + +export type SidebarGroupVisibilityPolicy = + | { kind: "pinned" } + | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; + +export function selectSidebarGroupVisibility( + items: readonly T[], + expanded: boolean, + policy: SidebarGroupVisibilityPolicy, +) { + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline = items.filter( + (item, index) => + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"), + ); + return { + visibleItems: expanded ? items.slice() : baseline, + canToggle: baseline.length < items.length, + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index ec834aee19..cfa69428f8 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -134,7 +134,7 @@ export function SidebarModelProvider({ visibleWorkspaceKeys.has(workspace.workspaceKey), ); return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) - ? [{ ...project, workspaces }] + ? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }] : []; }); } diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index a1481dcff9..0f7e8c24ca 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -145,14 +145,13 @@ function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) { ); } -function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) { +function SidebarSearchRow() { const { t } = useTranslation(); const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search")); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const handlePress = useCallback(() => { - onBeforeNavigate?.(); setCommandCenterOpen(true); - }, [onBeforeNavigate, setCommandCenterOpen]); + }, [setCommandCenterOpen]); return ( workspace.statusBucket, +}; + function StatusGroupRows({ group, collapsed, @@ -345,7 +350,7 @@ function StatusGroupRows({ expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows); + } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); return ( @@ -507,7 +512,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, inStatusGroup = true, - onWorkspacePress, drag, isDragging = false, dragHandleProps, @@ -538,9 +542,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ const handlePress = useCallback(() => { if (!workspace.serverId) return; - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( (items: readonly T[]) { +export function useLimitedSidebarGroup( + items: readonly T[], + policy: SidebarGroupVisibilityPolicy = PINNED_POLICY, +) { const [expanded, setExpanded] = useState(false); - const visibleItems = useMemo( - () => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)), - [expanded, items], + const visibility = useMemo( + () => selectSidebarGroupVisibility(items, expanded, policy), + [expanded, items, policy], ); - const canToggle = items.length > INITIAL_VISIBLE_ITEMS; const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); - return { visibleItems, expanded, canToggle, toggleExpanded }; + return { ...visibility, expanded, toggleExpanded }; } diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index ae1d67dce3..f3de17b230 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import type { useContextMenu } from "@/components/ui/context-menu"; export function useLongPressDragInteraction(input: { - drag: () => void; + drag?: () => void; menuController: ReturnType | null; }) { const { drag, menuController } = input; @@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers(); - if (platformIsWeb) { + if (platformIsWeb || !drag) { return; } diff --git a/packages/app/src/components/status-icon.tsx b/packages/app/src/components/status-icon.tsx new file mode 100644 index 0000000000..afa7f4b078 --- /dev/null +++ b/packages/app/src/components/status-icon.tsx @@ -0,0 +1,119 @@ +import { useMemo, type ReactElement, type ReactNode } from "react"; +import { View } from "react-native"; +import { CircleAlert } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { StatusRing } from "@/components/status-ring"; +import { getStatusRingOffset } from "@/components/status-ring/geometry"; +import { + STATUS_INDICATOR_ALERT_SIZE, + STATUS_INDICATOR_DOT_SIZE, +} from "@/utils/status-indicator-geometry"; +import type { Theme } from "@/styles/theme"; + +interface StatusIconProps { + children: ReactNode; + statusBucket: SidebarStateBucket | null; + size?: number; + statusDotBorderColor?: string; + backdrop: SurfaceBackdrop; +} +const DEFAULT_STATUS_DOT_OFFSET = -2; +const STATUS_ALERT_OFFSET = -3; +const ThemedCircleAlert = withUnistyles(CircleAlert); +const needsInputAlertMapping = (theme: Theme) => ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); + +export function StatusIcon({ + children, + statusBucket, + size = 14, + statusDotBorderColor, + backdrop, +}: StatusIconProps): ReactElement { + const bucket = statusBucket; + const isRunning = bucket === "running"; + let statusDotColor: string | undefined; + if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; + else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; + const showNeedsInputAlert = bucket === "needs_input"; + const agentIconWrapperStyle = useMemo( + () => [styles.agentIconWrapper, { width: size, height: size }], + [size], + ); + const statusDotStyle = useMemo( + () => [ + styles.statusDot, + { + backgroundColor: statusDotColor, + borderColor: statusDotBorderColor ?? styles[backdrop].borderColor, + }, + ], + [statusDotColor, statusDotBorderColor, backdrop], + ); + + return ( + + {children} + {isRunning ? ( + + + + ) : null} + {statusDotColor ? : null} + {showNeedsInputAlert ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + agentIconWrapper: { + position: "relative", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + statusDot: { + position: "absolute", + right: DEFAULT_STATUS_DOT_OFFSET, + bottom: DEFAULT_STATUS_DOT_OFFSET, + width: STATUS_INDICATOR_DOT_SIZE, + height: STATUS_INDICATOR_DOT_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + statusRing: { + position: "absolute", + right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + }, + surface0: { borderColor: theme.colors.surface0 }, + surface1: { borderColor: theme.colors.surface1 }, + surface2: { borderColor: theme.colors.surface2 }, + surfaceSidebar: { borderColor: theme.colors.surfaceSidebar }, + surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover }, + surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected }, + statusAlertOverlay: { + position: "absolute", + right: STATUS_ALERT_OFFSET, + bottom: STATUS_ALERT_OFFSET, + }, + statusDotFailed: { + color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, + }, + statusDotAttention: { + color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, + }, +})); diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index 26bba48aad..bf5f86eedb 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; export interface TerminalPasteActionProps { - hasClipboardText: boolean; + /** Clipboard holds text or an image, so a paste attempt can do something. */ + hasClipboardContent: boolean; onPaste: () => void; } -export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) { +export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) { return ( diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 3575e21500..c4c7080b83 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -12,6 +12,7 @@ import type { TerminalLocalFileLinkTarget, } from "../terminal/local-links/terminal-local-link-provider"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; +import type { TerminalFile } from "../terminal/file-paste"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; @@ -75,4 +76,5 @@ export interface TerminalEmulatorProps { pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; + onFilePaste?: (file: Promise) => void; } diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx deleted file mode 100644 index dbaa0175d4..0000000000 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ /dev/null @@ -1,715 +0,0 @@ -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; -import { - Keyboard, - StyleSheet, - View, - type GestureResponderEvent, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { - TerminalOutputData, - TerminalFindResult, -} from "../terminal/runtime/terminal-emulator-runtime"; -import type { - TerminalLocalFileLinkSource, - TerminalLocalFileLinkTarget, -} from "../terminal/local-links/terminal-local-link-provider"; -import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; -import type { PendingTerminalModifiers } from "../utils/terminal-keys"; -import { openExternalUrl } from "../utils/open-external-url"; -import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract"; -import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; -import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; - -type BridgeInboundMessage = - | { - type: "mount"; - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; - } - | { type: "unmount"; streamKey: string } - | { type: "writeOutput"; streamKey: string; text: string } - | { type: "restoreOutput"; streamKey: string; text: string } - | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } - | { type: "paste"; streamKey: string; text: string } - | { type: "clear"; streamKey: string } - | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } - | { type: "clearFind"; streamKey: string } - | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } - | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } - | { type: "setTheme"; streamKey: string; theme: ITheme } - | { type: "setScrollback"; streamKey: string; lines: number } - | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } - | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } - | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } - | { - type: "resolveLocalFileLinkResponse"; - streamKey: string; - requestId: number; - target: TerminalLocalFileLinkTarget | null; - }; - -type BridgeOutboundMessage = - | { type: "bridgeReady" } - | { type: "rendererReady"; streamKey: string; isReady: boolean } - | { type: "input"; streamKey: string; data: string } - | { type: "findRequest"; streamKey: string } - | { type: "findResult"; streamKey: string; result: TerminalFindResult } - | { - type: "resize"; - streamKey: string; - rows: number; - cols: number; - shouldClaim?: boolean; - forceClaim?: boolean; - } - | { - type: "terminalKey"; - streamKey: string; - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - } - | { type: "pendingModifiersConsumed"; streamKey: string } - | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } - | { type: "openExternalUrl"; streamKey: string; url: string } - | { - type: "resolveLocalFileLink"; - streamKey: string; - requestId: number; - source: TerminalLocalFileLinkSource; - } - | { - type: "openLocalFileLink"; - streamKey: string; - target: TerminalLocalFileLinkTarget; - disposition: "main" | "side"; - } - | { type: "swipeLeft"; streamKey: string } - | { type: "swipeRight"; streamKey: string } - | { type: "debug"; message: string; details?: unknown }; - -const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml }; -const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"]; -const BRIDGE_READY_TIMEOUT_MS = 2_500; -const RENDERER_READY_TIMEOUT_MS = 2_500; -const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; -type WebViewProps = ComponentProps; -interface PendingTerminalTap { - startX: number; - startY: number; - moved: boolean; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - -function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { - return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); -} - -function createMountMessage(input: { - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -}): BridgeInboundMessage { - return { - type: "mount", - streamKey: input.streamKey, - initialSnapshot: input.initialSnapshot, - scrollbackLines: input.scrollbackLines, - theme: input.theme, - fontFamily: input.fontFamily, - fontSize: input.fontSize, - pendingModifiers: input.pendingModifiers, - swipeGesturesEnabled: input.swipeGesturesEnabled, - }; -} - -export default function WebViewTerminalEmulator({ - ref, - streamKey, - testId = "terminal-surface", - xtermTheme = { - background: "#0b0b0b", - foreground: "#e6e6e6", - cursor: "#e6e6e6", - }, - scrollbackLines, - fontFamily, - fontSize, - swipeGesturesEnabled = false, - onSwipeLeft, - onSwipeRight, - initialSnapshot = null, - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onRendererReadyChange, - pendingModifiers = { ctrl: false, shift: false, alt: false }, - focusRequestToken = 0, - resizeRequestToken = 0, -}: TerminalEmulatorProps) { - const webViewRef = useRef(null); - const [webViewEpoch, setWebViewEpoch] = useState(0); - const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); - const bridgeReadyRef = useRef(false); - const bridgeReadyVersionRef = useRef(0); - const rendererReadyVersionRef = useRef(0); - const pendingMessagesRef = useRef([]); - const outputDecoderRef = useRef(new TextDecoder()); - const mountRequestedStreamKeyRef = useRef(null); - const rendererReadyStreamKeyRef = useRef(null); - const bridgeReadyTimeoutRef = useRef | null>(null); - const rendererReadyTimeoutRef = useRef | null>(null); - const pendingTapRef = useRef(null); - const mountConfigRef = useRef({ - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }); - mountConfigRef.current = { - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }; - const callbacksRef = useRef({ - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }); - callbacksRef.current = { - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }; - - const clearBridgeReadyTimeout = useCallback(() => { - if (bridgeReadyTimeoutRef.current === null) return; - clearTimeout(bridgeReadyTimeoutRef.current); - bridgeReadyTimeoutRef.current = null; - }, []); - - const clearRendererReadyTimeout = useCallback(() => { - if (rendererReadyTimeoutRef.current === null) return; - clearTimeout(rendererReadyTimeoutRef.current); - rendererReadyTimeoutRef.current = null; - }, []); - - const resetWebViewDocument = useCallback(() => { - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - setWebViewEpoch((value) => value + 1); - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); - - const scheduleBridgeReadyWatchdog = useCallback(() => { - clearBridgeReadyTimeout(); - const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; - bridgeReadyTimeoutRef.current = setTimeout(() => { - bridgeReadyTimeoutRef.current = null; - if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { - return; - } - resetWebViewDocument(); - }, BRIDGE_READY_TIMEOUT_MS); - }, [clearBridgeReadyTimeout, resetWebViewDocument]); - - const scheduleRendererReadyWatchdog = useCallback(() => { - clearRendererReadyTimeout(); - const expectedRendererReadyVersion = rendererReadyVersionRef.current; - rendererReadyTimeoutRef.current = setTimeout(() => { - rendererReadyTimeoutRef.current = null; - if ( - !shouldResetForMissingRendererReady({ - expectedReadyVersion: expectedRendererReadyVersion, - currentReadyVersion: rendererReadyVersionRef.current, - expectedStreamKey: streamKey, - mountRequestedStreamKey: mountRequestedStreamKeyRef.current, - rendererReadyStreamKey: rendererReadyStreamKeyRef.current, - }) - ) { - return; - } - resetWebViewDocument(); - }, RENDERER_READY_TIMEOUT_MS); - }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); - - const flushPendingMessages = useCallback(() => { - if (!bridgeReadyRef.current || !webViewRef.current) return; - const pending = pendingMessagesRef.current.splice(0); - for (const message of pending) { - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - } - }, []); - - const sendToWebView = useCallback((message: BridgeInboundMessage) => { - if (!bridgeReadyRef.current || !webViewRef.current) { - pendingMessagesRef.current.push(message); - return; - } - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - }, []); - - useImperativeHandle( - ref, - (): TerminalEmulatorHandle => ({ - find: { - setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }), - search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }), - clear: () => sendToWebView({ type: "clearFind", streamKey }), - }, - writeOutput: (data: TerminalOutputData) => { - const output = outputDecoderRef.current.decode(data, { stream: true }); - if (output.length === 0) { - return; - } - sendToWebView({ type: "writeOutput", streamKey, text: output }); - }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - if (text.length === 0) { - return; - } - sendToWebView({ type: "restoreOutput", streamKey, text }); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "renderSnapshot", streamKey, state }); - }, - paste: (text: string) => { - sendToWebView({ type: "paste", streamKey, text }); - }, - copySelection: async () => "", - clear: () => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "clear", streamKey }); - }, - claimSize: () => { - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - }, - showKeyboard: () => { - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - webViewRef.current?.requestFocus(); - }, - blur: () => { - webViewRef.current?.injectJavaScript( - "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;", - ); - Keyboard.dismiss(); - }, - }), - [sendToWebView, streamKey], - ); - - useEffect(() => { - outputDecoderRef.current.decode(); - }, [streamKey]); - - useEffect(() => { - if (bridgeReadyVersion <= 0) return; - const mountMessage = createMountMessage(mountConfigRef.current); - mountRequestedStreamKeyRef.current = streamKey; - rendererReadyStreamKeyRef.current = null; - sendToWebView(mountMessage); - flushPendingMessages(); - scheduleRendererReadyWatchdog(); - }, [ - bridgeReadyVersion, - flushPendingMessages, - scheduleRendererReadyWatchdog, - sendToWebView, - streamKey, - ]); - - const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); - }, [sendToWebView, streamKey, themeKey, xtermTheme]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); - }, [scrollbackLines, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); - }, [fontFamily, fontSize, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); - }, [pendingModifiers, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); - }, [sendToWebView, streamKey, swipeGesturesEnabled]); - - useEffect(() => { - if (focusRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - sendToWebView({ type: "focus", streamKey }); - webViewRef.current?.requestFocus(); - }, [focusRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - if (resizeRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false }); - }, [resizeRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - return () => { - if (mountRequestedStreamKeyRef.current) { - const previousStreamKey = mountRequestedStreamKeyRef.current; - callbacksRef.current.onRendererReadyChange?.({ - streamKey: previousStreamKey, - isReady: false, - }); - sendToWebView({ type: "unmount", streamKey: previousStreamKey }); - } - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - }; - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); - - const handleLifecycleMessage = useCallback( - (message: BridgeOutboundMessage): boolean => { - if (message.type === "bridgeReady") { - bridgeReadyRef.current = true; - bridgeReadyVersionRef.current += 1; - clearBridgeReadyTimeout(); - setBridgeReadyVersion((value) => value + 1); - return true; - } - if (message.type === "rendererReady") { - if (message.streamKey === mountRequestedStreamKeyRef.current) { - rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null; - if (message.isReady) { - rendererReadyVersionRef.current += 1; - clearRendererReadyTimeout(); - } - } - callbacksRef.current.onRendererReadyChange?.({ - streamKey: message.streamKey, - isReady: message.isReady, - }); - return true; - } - return false; - }, - [clearBridgeReadyTimeout, clearRendererReadyTimeout], - ); - - const resolveLocalFileLink = useCallback( - async (message: Extract) => { - try { - const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ?? - null); - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target, - }); - } catch { - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target: null, - }); - } - }, - [sendToWebView], - ); - - const handleTerminalMessage = useCallback( - ( - message: Exclude, - ) => { - if (message.type === "resolveLocalFileLink") { - void resolveLocalFileLink(message); - return; - } - if (message.type === "openLocalFileLink") { - callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition); - return; - } - switch (message.type) { - case "input": - callbacksRef.current.onInput?.(message.data); - break; - case "resize": - callbacksRef.current.onResize?.({ - rows: message.rows, - cols: message.cols, - shouldClaim: message.shouldClaim !== false, - forceClaim: message.forceClaim, - }); - break; - case "terminalKey": - callbacksRef.current.onTerminalKey?.({ - key: message.key, - ctrl: message.ctrl, - shift: message.shift, - alt: message.alt, - meta: message.meta, - }); - break; - case "pendingModifiersConsumed": - callbacksRef.current.onPendingModifiersConsumed?.(); - break; - case "inputModeChange": - callbacksRef.current.onInputModeChange?.(message.state); - break; - case "openExternalUrl": - void openExternalUrl(message.url); - break; - case "swipeLeft": - callbacksRef.current.onSwipeLeft?.(); - break; - case "swipeRight": - callbacksRef.current.onSwipeRight?.(); - break; - case "debug": - break; - } - }, - [resolveLocalFileLink], - ); - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - let message: BridgeOutboundMessage; - try { - message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; - } catch { - return; - } - - if (message.type === "bridgeReady" || message.type === "rendererReady") { - handleLifecycleMessage(message); - return; - } - if ( - !shouldHandleTerminalBridgeMessage(message, { - desiredStreamKey: streamKey, - mountedStreamKey: mountRequestedStreamKeyRef.current, - }) - ) { - return; - } - if (message.type === "findRequest") { - callbacksRef.current.onFindRequest?.(); - return; - } - if (message.type === "findResult") { - callbacksRef.current.onFindResult?.(message.result); - return; - } - handleTerminalMessage(message); - }, - [handleLifecycleMessage, handleTerminalMessage, streamKey], - ); - - const handleLoadStart = useCallback>(() => { - bridgeReadyRef.current = false; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - scheduleBridgeReadyWatchdog(); - }, [scheduleBridgeReadyWatchdog]); - - const handleContentProcessDidTerminate = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleRenderProcessGone = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => { - pendingTapRef.current = { - startX: event.nativeEvent.pageX, - startY: event.nativeEvent.pageY, - moved: false, - }; - }, []); - - const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => { - const pendingTap = pendingTapRef.current; - if (!pendingTap) return; - const dx = event.nativeEvent.pageX - pendingTap.startX; - const dy = event.nativeEvent.pageY - pendingTap.startY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - pendingTap.moved = true; - } - }, []); - - const handleWebViewTouchEnd = useCallback(() => { - const pendingTap = pendingTapRef.current; - pendingTapRef.current = null; - if (!pendingTap || pendingTap.moved) { - return; - } - webViewRef.current?.requestFocus(); - callbacksRef.current.onFocus?.(); - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - }, [sendToWebView, streamKey]); - - const handleWebViewTouchCancel = useCallback(() => { - pendingTapRef.current = null; - }, []); - - const backgroundColor = xtermTheme.background ?? "#0b0b0b"; - const rootStyle = useMemo>( - () => [styles.root, { backgroundColor }], - [backgroundColor], - ); - const webViewStyle = useMemo>( - () => [styles.webView, { backgroundColor }], - [backgroundColor], - ); - const webViewContainerStyle = useMemo>( - () => [styles.webViewContainer, { backgroundColor }], - [backgroundColor], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - minWidth: 0, - overflow: "hidden", - }, - webView: { - flex: 1, - }, - webViewContainer: { - flex: 1, - }, -}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 052d7a02f0..0635291c4c 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -2,10 +2,8 @@ import { useEffect } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { useAppSettings } from "../hooks/use-settings"; import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability"; import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native"; -import WebViewTerminalEmulator from "./terminal-emulator-webview.native"; import type { TerminalEmulatorProps } from "./terminal-emulator-contract"; export type { TerminalEmulatorHandle } from "./terminal-emulator-contract"; @@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina } export default function TerminalEmulator(props: TerminalEmulatorProps) { - const { settings } = useAppSettings(); - const { useLegacyTerminalRenderer } = settings; const rendererSelection = resolveTerminalRendererSelection({ - useLegacyTerminalRenderer, supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay, }); @@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) { return ; } - const Renderer = - rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator; - - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index be12355bea..3d272fb75e 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -11,9 +11,7 @@ import { useImperativeHandle, useMemo, useRef, - useState, type CSSProperties, - type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type Ref, } from "react"; @@ -29,6 +27,7 @@ import { type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; +import type { TerminalFile } from "../terminal/file-paste"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, @@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; -import { - extractTerminalDropPaths, - isTerminalDragLeaveOutside, - isTerminalFileDrag, - prepareDroppedPathsForTerminal, -} from "../terminal/drop/terminal-file-drop"; -import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; @@ -145,6 +137,7 @@ interface TerminalEmulatorProps { disposition: "main" | "side", ) => Promise | void; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + onFilePaste?: (file: Promise) => void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; @@ -191,6 +184,7 @@ export default function TerminalEmulator({ onResolveLocalFileLink, onOpenLocalFileLink, onRendererReadyChange, + onFilePaste, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, @@ -220,6 +214,7 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }); mountCallbacksRef.current = { onFindRequest, @@ -231,13 +226,12 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }; const initialSnapshotRef = useRef(initialSnapshot); initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; - const [isDropActive, setIsDropActive] = useState(false); - const dropActiveTimeoutRef = useRef | null>(null); const domBridgeRef = useRef(null); const pasteText = useCallback((text: string) => { @@ -506,11 +500,13 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, onOpenExternalUrl: openExternalUrl, }, }); }, [ onFindRequest, + onFilePaste, onFindResult, onInput, onInputModeChange, @@ -582,111 +578,6 @@ export default function TerminalEmulator({ [showTerminalContextMenu], ); - const clearDropActiveTimeout = useCallback(() => { - if (dropActiveTimeoutRef.current === null) { - return; - } - clearTimeout(dropActiveTimeoutRef.current); - dropActiveTimeoutRef.current = null; - }, []); - - const clearTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(false); - }, [clearDropActiveTimeout]); - - const keepTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(true); - dropActiveTimeoutRef.current = setTimeout(() => { - dropActiveTimeoutRef.current = null; - setIsDropActive(false); - }, 180); - }, [clearDropActiveTimeout]); - - useEffect(() => { - const root = rootRef.current; - if (!root) { - return () => {}; - } - - const handleDragEnter = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - keepTerminalDropActive(); - }; - - const handleDragOver = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "copy"; - } - keepTerminalDropActive(); - }; - - const handleDrop = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - clearTerminalDropActive(); - - const bridge = getDesktopHost(); - const paths = extractTerminalDropPaths(event.dataTransfer, bridge); - if (paths.length === 0) { - return; - } - - runtimeRef.current?.focus(); - mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge)); - }; - - root.addEventListener("dragenter", handleDragEnter, { capture: true }); - root.addEventListener("dragover", handleDragOver, { capture: true }); - root.addEventListener("drop", handleDrop, { capture: true }); - window.addEventListener("dragend", clearTerminalDropActive); - window.addEventListener("drop", clearTerminalDropActive); - - return () => { - root.removeEventListener("dragenter", handleDragEnter, { capture: true }); - root.removeEventListener("dragover", handleDragOver, { capture: true }); - root.removeEventListener("drop", handleDrop, { capture: true }); - window.removeEventListener("dragend", clearTerminalDropActive); - window.removeEventListener("drop", clearTerminalDropActive); - clearDropActiveTimeout(); - }; - }, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]); - - const handleRootDragLeave = useCallback( - (event: ReactDragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - if ( - !isTerminalDragLeaveOutside({ - currentTarget: event.currentTarget, - relatedTarget: event.relatedTarget, - }) - ) { - return; - } - clearTerminalDropActive(); - }, - [clearTerminalDropActive], - ); - const rootDivStyle = useMemo( () => ({ position: "relative", @@ -702,19 +593,6 @@ export default function TerminalEmulator({ }), [xtermTheme.background], ); - const dropOverlayStyle = useMemo( - () => ({ - position: "absolute", - inset: 0, - zIndex: 9, - border: "1px solid rgba(78, 161, 255, 0.72)", - backgroundColor: "rgba(78, 161, 255, 0.16)", - opacity: isDropActive ? 1 : 0, - pointerEvents: "none", - transition: "opacity 120ms ease-out", - }), - [isDropActive], - ); return (
-
); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 4b37470b30..6c1561bc5b 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -43,7 +43,23 @@ import { shouldShowTerminalPasteAction, type TerminalVirtualKeyboardControl, } from "@/terminal/runtime/terminal-virtual-keyboard"; -import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; +import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; +import { + TerminalFilePasteQueue, + TerminalFileTooLargeError, + terminalFileFromBase64, + type TerminalFile, +} from "@/terminal/file-paste"; +import { useToast } from "@/contexts/toast-context"; +import type { DismissToast } from "@/components/toast-host"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { useFileDrop } from "@/components/file-drop/use-file-drop"; +import type { DroppedItem } from "@/components/file-drop/types"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { getDesktopHost } from "@/desktop/host"; +import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; +import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; +import { toErrorMessage } from "@/utils/error-messages"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { EMPTY_FOCUS_CLAIM_STATE, @@ -122,6 +138,24 @@ type PendingTerminalInput = input: TerminalKeyInput; }; +async function readNativeClipboardImage(): Promise { + const image = await Clipboard.getImageAsync({ format: "png" }); + if (!image) { + return null; + } + return terminalFileFromBase64({ + fileName: "clipboard.png", + mimeType: "image/png", + data: image.data.slice(image.data.indexOf(",") + 1), + }); +} + +/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */ +function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) { + useFileDrop({ onGenericFiles: onFiles }); + return null; +} + function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } @@ -292,9 +326,47 @@ export function TerminalPane({ const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [hasSelection, setHasSelection] = useState(false); const [hasClipboardText, setHasClipboardText] = useState(false); + const [hasClipboardImage, setHasClipboardImage] = useState(false); const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); + const toast = useToast(); + const dismissUploadingToastRef = useRef(null); + const showFileUploading = useStableEvent((uploading: boolean) => { + dismissUploadingToastRef.current?.(); + dismissUploadingToastRef.current = uploading + ? toast.show(t("workspace.terminal.uploadingFile"), { + durationMs: null, + testID: "terminal-file-uploading", + }) + : null; + }); + const reportFileUploaded = useStableEvent(() => { + toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" }); + }); + const reportFilePasteFailure = useStableEvent((error: unknown) => { + const reason = + error instanceof TerminalFileTooLargeError + ? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName }) + : toErrorMessage(error); + toast.error(t("workspace.terminal.pasteFileFailed", { reason })); + }); + const [filePastes] = useState( + () => + new TerminalFilePasteQueue({ + onUploadingChange: showFileUploading, + onUploaded: reportFileUploaded, + onFailure: reportFilePasteFailure, + }), + ); + useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]); + useEffect(() => { + if (!isConnected && filePastes.cancel()) { + reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected"))); + } + }, [filePastes, isConnected, reportFilePasteFailure, t]); + // Only the desktop app's own daemon can read files on this machine by path. + const isLocalHost = useIsLocalDaemon(serverId); useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection); const emulatorRef = useRef(null); const findRef = useRef(null); @@ -325,10 +397,15 @@ export function TerminalPane({ return; } try { - const hasText = await Clipboard.hasStringAsync(); + const [hasText, hasImage] = await Promise.all([ + Clipboard.hasStringAsync(), + Clipboard.hasImageAsync(), + ]); setHasClipboardText(hasText); + setHasClipboardImage(hasImage); } catch { setHasClipboardText(false); + setHasClipboardImage(false); } }, [showVirtualKeyBar]); @@ -705,6 +782,9 @@ export function TerminalPane({ alt: boolean; meta?: boolean; }): boolean => { + if (input.ctrl && input.key.toLowerCase() === "c") { + filePastes.cancel(); + } if (!client || !terminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", @@ -740,7 +820,7 @@ export function TerminalPane({ } return true; }, - [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput], + [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes], ); const handleTerminalData = useCallback( @@ -748,6 +828,10 @@ export function TerminalPane({ if (data.length === 0) { return; } + // Ctrl+C abandons file pastes that have not landed yet. + if (data.includes("\x03")) { + filePastes.cancel(); + } if (hasPendingTerminalModifiers(modifiers)) { const pendingResolution = resolvePendingModifierDataInput({ @@ -793,12 +877,59 @@ export function TerminalPane({ clearPendingModifiers, client, dispatchTerminalInputEntry, + filePastes, modifiers, sendTerminalKey, enqueuePendingTerminalInput, ], ); + const pasteTerminalText = useStableEvent((text: string) => { + const entry: PendingTerminalInput = { + type: "data", + data: encodeTerminalPaste({ + text, + bracketedPaste: inputModeRef.current.bracketedPaste ?? false, + }), + }; + if (!dispatchTerminalInputEntry(entry)) { + enqueuePendingTerminalInput(entry); + } + }); + + const handleFilePaste = useStableEvent((file: Promise) => { + filePastes.add(file, { + upload: async (upload) => { + if (!client || !isConnected) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const response = await client.uploadFile(upload); + if (!response.file) { + throw new Error(response.error ?? "Upload failed"); + } + return response.file.path; + }, + paste: pasteTerminalText, + }); + }); + + const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { + const bridge = getDesktopHost(); + const paths: string[] = []; + for (const item of items) { + const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge }); + if (delivery.kind === "path") { + paths.push(delivery.path); + } else { + handleFilePaste(delivery.read()); + } + } + if (paths.length > 0) { + pasteTerminalText(prepareDroppedPathsForTerminal(paths)); + } + requestTerminalFocus(); + }); + const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => { const nextSize = { rows: input.rows, cols: input.cols }; const claim = resolveTerminalResizeClaim({ @@ -876,11 +1007,15 @@ export function TerminalPane({ const handleTerminalPaste = useCallback(() => { requestTerminalReflow(); - void pasteTerminalClipboard({ + const image = pasteTerminalClipboard({ clipboard: { readText: () => Clipboard.getStringAsync() }, terminal: { paste: (text) => emulatorRef.current?.paste(text) }, - }).then(() => refreshClipboardAvailability()); - }, [requestTerminalReflow, refreshClipboardAvailability]); + }) + .catch(() => false) + .then((pastedText) => (pastedText ? null : readNativeClipboardImage())); + handleFilePaste(image); + void image.catch(() => null).then(() => refreshClipboardAvailability()); + }, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]); const handleTerminalCopy = useCallback(() => { void emulatorRef.current @@ -1032,7 +1167,7 @@ export function TerminalPane({ return showPasteAction ? ( ) : null; @@ -1065,7 +1200,8 @@ export function TerminalPane({ return ( - + + ) : null} - + {streamError ? ( diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts deleted file mode 100644 index 7a08aa19d9..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; - -describe("terminal WebView message routing", () => { - it("rejects terminal messages from a stale stream", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - expect( - shouldHandleTerminalBridgeMessage( - { type: "resize", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - }); - - it("rejects the old WebView stream while React is switching callbacks", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" }, - ), - ).toBe(false); - }); - - it("accepts messages from the mounted stream and unscoped diagnostics", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-b" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - expect( - shouldHandleTerminalBridgeMessage( - { type: "debug" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - }); -}); diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts deleted file mode 100644 index c23c6b7244..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface TerminalBridgeMessageIdentity { - type: string; - streamKey?: string; -} - -export function shouldHandleTerminalBridgeMessage( - message: TerminalBridgeMessageIdentity, - stream: { - desiredStreamKey: string; - mountedStreamKey: string | null; - }, -): boolean { - return ( - message.streamKey === undefined || - (message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey) - ); -} diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts deleted file mode 100644 index 00db851676..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; - -describe("legacy terminal WebView readiness", () => { - it("resets when a requested mount never reports renderer readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(true); - }); - - it("keeps the document after the requested renderer confirms readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(false); - }); - - it("ignores a watchdog from an earlier ready generation", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 1, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); - - it("does not let readiness from a stale stream satisfy the requested mount", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-b", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(true); - }); - - it("does not reset a newer mount from an expired watchdog", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts deleted file mode 100644 index a0cf804156..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function shouldResetForMissingRendererReady(input: { - expectedReadyVersion: number; - currentReadyVersion: number; - expectedStreamKey: string; - mountRequestedStreamKey: string | null; - rendererReadyStreamKey: string | null; -}): boolean { - return ( - input.currentReadyVersion === input.expectedReadyVersion && - input.mountRequestedStreamKey === input.expectedStreamKey && - input.rendererReadyStreamKey !== input.expectedStreamKey - ); -} diff --git a/packages/app/src/components/toast-host.test.tsx b/packages/app/src/components/toast-host.test.tsx new file mode 100644 index 0000000000..32f1bfb8a7 --- /dev/null +++ b/packages/app/src/components/toast-host.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useToastHost } from "./toast-host"; + +describe("useToastHost", () => { + it("dismisses the toast that show returned the dismiss for", () => { + const { result } = renderHook(() => useToastHost()); + + let dismiss = () => {}; + act(() => { + dismiss = result.current.api.show("Uploading image…", { durationMs: null }); + }); + expect(result.current.toast?.content).toBe("Uploading image…"); + + act(() => dismiss()); + expect(result.current.toast).toBeNull(); + }); + + it("leaves a newer toast in place when an older one is dismissed", () => { + const { result } = renderHook(() => useToastHost()); + + let dismissUploading = () => {}; + act(() => { + dismissUploading = result.current.api.show("Uploading image…", { durationMs: null }); + }); + act(() => { + result.current.api.show("Image uploaded", { variant: "success" }); + }); + + act(() => dismissUploading()); + expect(result.current.toast?.content).toBe("Image uploaded"); + }); +}); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 6b9009dd6a..d9c8001f50 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -43,8 +43,11 @@ export interface ToastState { testID?: string; } +/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */ +export type DismissToast = () => void; + export interface ToastApi { - show: (content: ReactNode, options?: ToastShowOptions) => void; + show: (content: ReactNode, options?: ToastShowOptions) => DismissToast; copied: (label?: string) => void; error: (message: string) => void; } @@ -52,6 +55,7 @@ export interface ToastApi { type ToastViewportPlacement = "app-shell" | "panel"; const DEFAULT_DURATION_MS = 2200; +const noopDismiss: DismissToast = () => {}; const TOAST_MAX_WIDTH = 480; const toastEntering = FadeIn.duration(140); const toastExiting = FadeOut.duration(140); @@ -73,10 +77,10 @@ export function useToastHost(): { const [toast, setToast] = useState(null); const idRef = useRef(0); - const show = useCallback((content: ReactNode, options?: ToastShowOptions) => { + const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => { const nativeMessage = typeof content === "string" ? content.trim() : null; if (!content || nativeMessage === "") { - return; + return noopDismiss; } const variant = options?.variant ?? "default"; @@ -87,12 +91,13 @@ export function useToastHost(): { const duration = durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); - return; + return noopDismiss; } idRef.current += 1; + const id = idRef.current; setToast({ - id: idRef.current, + id, content, nativeMessage, icon: options?.icon, @@ -100,6 +105,7 @@ export function useToastHost(): { durationMs, testID: options?.testID, }); + return () => setToast((current) => (current?.id === id ? null : current)); }, []); const api = useMemo( diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 9b1dcaf23d..fa5e724944 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -9,32 +9,20 @@ import { } from "react"; import { View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, -} from "react-native-reanimated"; +import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/keyboard/shift"; +import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; const OFFSET_FROM_ANCHOR = SPACING[3]; -interface Rect { - x: number; - y: number; - width: number; - height: number; -} - interface RelativeAnchorRect { x: number; y: number; @@ -42,14 +30,6 @@ interface RelativeAnchorRect { hostHeight: number; } -function measureElement(element: View): Promise { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - interface AutocompletePopoverProps { visible: boolean; anchorRef: RefObject; @@ -79,8 +59,9 @@ export function AutocompletePopover({ const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift, isMoving } = useKeyboardShift(); - const measuredShift = useSharedValue(0); + const { isMoving } = useKeyboardShift(); + const anchor = useKeyboardAnchor(); + const { measureAtRest } = anchor; const measurementGeneration = useRef(0); const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); @@ -90,20 +71,21 @@ export function AutocompletePopover({ if (!anchorElement) return; const generation = measurementGeneration.current; void Promise.all([ - measureElement(anchorElement), + measureAtRest(anchorElement), measureFloatingPanelPortalHost(portalHostName), ]).then(([anchorRect, hostRect]) => { - if (generation !== measurementGeneration.current || !hostRect) return undefined; + if (generation !== measurementGeneration.current || !anchorRect || !hostRect) { + return undefined; + } setRelativeAnchorRect({ x: anchorRect.x - hostRect.x, y: anchorRect.y - hostRect.y, width: anchorRect.width, hostHeight: hostRect.height, }); - measuredShift.value = shift.value; return undefined; }); - }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); + }, [anchorRef, canMeasure, measureAtRest, portalHostName]); useEffect(() => { measurementGeneration.current += 1; @@ -131,37 +113,38 @@ export function AutocompletePopover({ [isMoving, remeasure], ); + // Lay out at the anchor's resting position; the KeyboardTranslateView below lifts + // it with the anchor. Height changes only at keyboard boundaries. const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; return inlineUnistylesStyle({ position: "absolute" as const, left: relativeAnchorRect.x, width: relativeAnchorRect.width, + bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR, }); }, [relativeAnchorRect]); const anchorY = relativeAnchorRect?.y ?? 0; - const baseBottom = relativeAnchorRect - ? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR - : 0; - const keyboardLayoutStyle = useAnimatedStyle(() => { - const shiftDelta = shift.value - measuredShift.value; - return { - bottom: baseBottom + shiftDelta, - maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2), - }; - }, [anchorY, baseBottom, safeAreaInsets.top]); + const reservedLift = anchor.reservedLift; + const heightStyle = useAnimatedStyle( + () => ({ + maxHeight: Math.max( + 0, + anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2, + ), + }), + [anchorY, reservedLift, safeAreaInsets.top], + ); if (!visible || !relativeAnchorRect || !baseStyle) return null; if (options.length > 0 && selectedIndex < 0) return null; + // The translate view is the popover's own frame, so it lifts only the popover. return ( - - + + - + ); } - -const styles = StyleSheet.create(() => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - }, -})); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx index 3ec4d6393c..6990f84902 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx @@ -32,9 +32,12 @@ import { useSharedValue } from "react-native-reanimated"; export function SheetHostScope({ children, style, + enabled = true, }: { children: ReactNode; style?: StyleProp; + /** Disabled scopes keep their mounted children and use the enclosing host. */ + enabled?: boolean; }) { const parent = useBottomSheetModalInternal(); const hostName = `sheet-host-${useId()}`; @@ -69,12 +72,14 @@ export function SheetHostScope({ ); return ( - + {children} - - - + {enabled ? ( + + + + ) : null} ); @@ -87,5 +92,6 @@ const styles = StyleSheet.create({ }, host: { ...StyleSheet.absoluteFill, + zIndex: 1, }, }); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 7124fd2f0a..84e9141b6a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -31,6 +31,8 @@ function workspaceDescriptor(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); useSidebarViewStore.setState({ groupMode: "project", diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 7e197b4f13..f810ef19c6 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,4 +1,5 @@ import type { SelectedFile } from "@/attachments/selected-file"; +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { @@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: { for (const file of input.files) { const bytes = await file.readBytes(); - if (bytes.byteLength > 50 * 1024 * 1024) { + if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) { throw new Error( i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }), ); diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx index 96e1e1139a..5115666994 100644 --- a/packages/app/src/composer/diff-stat-pill.test.tsx +++ b/packages/app/src/composer/diff-stat-pill.test.tsx @@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill"; const WORKSPACE_ID = "workspace"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: WORKSPACE_ID, projectId: "project", projectDisplayName: "Project", diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 6ad84d632d..4776b5af3d 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -119,6 +119,20 @@ const SessionsContext = createContext(null); const WorkspaceContext = createContext(null); const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); +/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */ +export function useComposerEditingEnvironmentBridge() { + const sessions = useContext(SessionsContext); + const workspaceKey = useContext(WorkspaceContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [sessions, workspaceKey], + ); +} + /** App-container ownership survives replacement of both the gesture shell and tab layout. */ export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { const [sessions] = useState(() => new ComposerEditingSessions()); diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c7ef8e281b..c0c3bea91f 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -8,6 +8,8 @@ import { } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "/repo/worktree", projectId: "/repo", projectDisplayName: "Repo", diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 606b27368e..5f70030819 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,5 +1,6 @@ +import { usePaneContext } from "@/panels/pane-context"; import { useEffect, useMemo, useState } from "react"; -import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client"; +import type { PluginFile, PluginTab } from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -69,13 +70,9 @@ export function FilePane({ () => ({ lineStart: location.lineStart, lineEnd: location.lineEnd, - revision: navigationRevision, + path, }), - [location.lineEnd, location.lineStart, navigationRevision], - ); - const panelTarget = useMemo( - () => ({ serverId, workspaceRoot: root, path }), - [path, root, serverId], + [location.lineEnd, location.lineStart, path], ); let body; @@ -113,8 +110,8 @@ export function FilePane({ key={`${panel.id}:${targetKey}`} panel={panel} file={handle.file} - location={panelLocation} - target={panelTarget} + params={panelLocation} + revision={navigationRevision} onModifiedChange={setModified} /> @@ -131,21 +128,27 @@ export function FilePane({ function MountedFilePanel({ panel, file, - location, - target, + params, + revision, onModifiedChange, }: { panel: FilePanel; file: PluginFile; - location: { lineStart?: number; lineEnd?: number; revision: number }; - target: { serverId: string; workspaceRoot: string; path: string }; + params: { path: string; lineStart?: number; lineEnd?: number }; + revision: number; onModifiedChange: (modified: boolean) => void; }) { - const [tab] = useState(() => ({ - setModified: (modified) => onModifiedChange(modified), - })); + const { state, setCurrentTabState } = usePaneContext(); + const tab = useMemo( + () => ({ + setModified: onModifiedChange, + state, + setState: setCurrentTabState, + }), + [onModifiedChange, state, setCurrentTabState], + ); useEffect(() => () => onModifiedChange(false), [onModifiedChange]); - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index f47f11d029..5407719ab4 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -1,4 +1,4 @@ -import { useFileUrl } from "@getpaseo/plugin/client"; +import { useWorkspace, useFileUrl, type PluginFilePanelProps } from "@getpaseo/plugin/client"; import { useCallback, useState } from "react"; import { ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -7,28 +7,28 @@ import { useSessionStore } from "@/stores/session-store"; import { FileHtmlPreview } from "./html-preview"; import { FilePanelLoading } from "./loading"; import { FileMarkdownPreview } from "./markdown-preview"; -import type { BuiltinFilePanelProps } from "./panels"; import { TextFilePanel, type TextFilePreviewProps } from "./source/panel"; import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; /** Source: the text file panel with no Preview, for text files no other built-in panel claims. */ -function SourcePanel(props: BuiltinFilePanelProps) { +function SourcePanel(props: PluginFilePanelProps) { return ; } /** Markdown: the text file panel with the rendered document as its Preview. */ -function MarkdownPanel(props: BuiltinFilePanelProps) { +function MarkdownPanel(props: PluginFilePanelProps) { return ; } /** HTML: the text file panel with the sandboxed page as its Preview. */ -function HtmlPanel(props: BuiltinFilePanelProps) { +function HtmlPanel(props: PluginFilePanelProps) { return ; } /** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ -function MarkdownPreview({ text, target }: TextFilePreviewProps) { - const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); +function MarkdownPreview({ text, params, workspaceId, host }: TextFilePreviewProps) { + const client = useSessionStore((state) => state.sessions[host.id]?.client ?? null); + const workspaceRoot = useWorkspace(workspaceId, (workspace) => workspace.directory); const [rendered, setRendered] = useState(false); const handleLayout = useCallback(() => setRendered(true), []); return ( @@ -37,10 +37,10 @@ function MarkdownPreview({ text, target }: TextFilePreviewProps) { @@ -58,7 +58,7 @@ function HtmlPreview({ text }: TextFilePreviewProps) { ); } -function ImagePanel({ file }: BuiltinFilePanelProps) { +function ImagePanel({ file }: PluginFilePanelProps) { const url = useFileUrl(file); if (url.status === "loading") return ; if (url.status === "error") return ; @@ -83,7 +83,7 @@ const components = { export function BuiltinFilePanelView({ panel, ...props -}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { +}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { const Component = components[panel.id]; return ; } diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts index 7646755074..b3f33e040a 100644 --- a/packages/app/src/file-panels/panels.ts +++ b/packages/app/src/file-panels/panels.ts @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client"; import { useInstalledPlugins } from "@/plugins/registry"; import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types"; import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog"; @@ -13,14 +12,6 @@ export interface PluginFilePanel { } export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel; -/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */ -export interface BuiltinFilePanelProps { - file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; - target: { serverId: string; workspaceRoot: string; path: string }; -} - export function useFilePanels(serverId: string): readonly FilePanel[] { const plugins = useInstalledPlugins(); return useMemo( diff --git a/packages/app/src/file-panels/plugin-host.tsx b/packages/app/src/file-panels/plugin-host.tsx index 06b2260a8b..4cc96cdc3d 100644 --- a/packages/app/src/file-panels/plugin-host.tsx +++ b/packages/app/src/file-panels/plugin-host.tsx @@ -1,54 +1,28 @@ -import { useMemo } from "react"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; -import type { PluginTheme } from "@getpaseo/plugin"; -import { withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePaneContext } from "@/panels/pane-context"; -import { useHosts } from "@/runtime/host-runtime"; +import type { PluginFilePanelProps } from "@getpaseo/plugin/client"; +import { useMemo } from "react"; import { createPluginClientStateSource } from "@/plugins/client-state/source"; -import { usePluginHostNavigation } from "@/plugins/host-navigation"; import { PluginInstallationProvider } from "@/plugins/installation-provider"; import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary"; -import { resolvePluginPlatform } from "@/plugins/platform"; -import { toPluginTheme } from "@/plugins/theme"; -import type { Theme } from "@/styles/theme"; -import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels"; +import type { PluginFilePanel } from "./panels"; -type Props = Pick & { panel: PluginFilePanel }; - -function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) { - const { workspaceId } = usePaneContext(); +export function PluginFilePanelHost({ + panel, + ...props +}: PluginFilePanelProps & { panel: PluginFilePanel }) { const { plugin, contribution } = panel; - const serverId = plugin.serverId; - const compact = useIsCompactFormFactor(); - const hosts = useHosts(); - const navigation = usePluginHostNavigation(serverId); - const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]); - const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo( + () => createPluginClientStateSource(plugin.serverId), + [plugin.serverId], + ); const Component = contribution.Component; return ( - + ); } -const ThemedHost = withUnistyles(PluginHost); -const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); -export function PluginFilePanelHost(props: Props) { - return ; -} diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index 7339cc50e7..23ea42f875 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -7,7 +7,11 @@ import { type ComponentProps, type ComponentType, } from "react"; -import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client"; +import { + useFileText, + type PluginFilePanelProps, + type PluginFilePanelParams, +} from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -18,7 +22,6 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { formatFileSize } from "@/utils/format-file-size"; import type { Theme } from "@/styles/theme"; import { FilePanelLoading } from "../loading"; -import type { BuiltinFilePanelProps } from "../panels"; import type { FileConflictAlertState } from "./conflict-alert"; import { FileEditorModel, getFileConflictCallout } from "./editor/model"; import { FileEditorView } from "./editor/view"; @@ -51,10 +54,12 @@ const MAX_EDITABLE_BYTES = 1024 * 1024; /** A rendered view of the file's text: the panel's Preview, shown instead of its source. */ export interface TextFilePreviewProps { text: string; - target: BuiltinFilePanelProps["target"]; + params: PluginFilePanelProps["params"]; + workspaceId: string; + host: PluginFilePanelProps["host"]; } -type TextFilePanelProps = BuiltinFilePanelProps & { +type TextFilePanelProps = PluginFilePanelProps & { /** The panel's Preview; with one, the toolbar switches between Preview and Source. */ preview?: ComponentType; }; @@ -78,9 +83,10 @@ export function TextFilePanel(props: TextFilePanelProps) { * each later line target switches to Source again. */ function useTextFileView( - location: PluginFileLocation, + params: PluginFilePanelParams, + revision: number, ): [TextFileView, (view: TextFileView) => void] { - const lineTarget = location.lineStart ? location.revision : null; + const lineTarget = params.lineStart ? revision : null; const [view, setView] = useState(lineTarget === null ? "preview" : "source"); const [shownLineTarget, setShownLineTarget] = useState(lineTarget); if (lineTarget !== null && lineTarget !== shownLineTarget) { @@ -102,17 +108,19 @@ function TooLargeTextFile({ size }: { size: number }) { function ReadonlyTextFile({ file, - location, - target, + params, + revision, + workspaceId, + host, preview: Preview, presentation, }: TextFilePanelProps & { presentation: "highlighted" | "plain" }) { const text = useFileText(file); const { t } = useTranslation(); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const sourceLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (text.status === "loading") return ; if (text.status === "error") { @@ -132,13 +140,13 @@ function ReadonlyTextFile({ onViewChange={Preview ? setView : undefined} /> {showPreview ? ( - + ) : ( )} @@ -146,11 +154,19 @@ function ReadonlyTextFile({ ); } -function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) { +function EditableTextFile({ + file, + tab, + params, + revision, + workspaceId, + host, + preview: Preview, +}: TextFilePanelProps) { const { settings } = useAppSettings(); const { t } = useTranslation(); const [model] = useState(() => new FileEditorModel({ file })); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -181,8 +197,8 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex const handleOverwrite = useCallback(() => void model.overwrite(), [model]); const handleRetry = useCallback(() => void model.reload(), [model]); const editorLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (snapshot.status === "loading") return ; @@ -222,13 +238,13 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex conflict={conflict} /> {showPreview ? ( - + ) : ( ; +type Props = Pick & { + panel: FilePanel; +}; +function FilePanelHost({ panel, theme, ...input }: Props & { theme: PluginTheme }) { + const { workspaceId, serverId } = usePaneContext(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const label = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const host = useMemo(() => ({ id: serverId, label }), [serverId, label]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); + const navigation = usePluginHostNavigation(serverId); + const props: PluginFilePanelProps = { ...input, workspaceId, theme, host, layout, navigation }; + if (panel.builtin) + return ( + + + + ); return ; } +const ThemedHost = withUnistyles(FilePanelHost); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function FilePanelView(props: Props) { + return ; +} diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index bd52000af3..5b9921e31d 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import invariant from "tiny-invariant"; import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model"; import type { @@ -162,6 +162,16 @@ function paletteKey(palette: DiffPalette): string { const WorkspaceCacheContext = createContext(null); +export function useDiffDocumentWorkspaceCacheBridge() { + const cache = useDiffDocumentWorkspaceCache(); + return useCallback( + (children: ReactNode) => ( + {children} + ), + [cache], + ); +} + export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) { const [cache] = useState(createDiffDocumentWorkspaceCache); return {children}; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 1bc4cf2135..59648e237b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -9,6 +9,7 @@ import { buildSidebarWorkspacePlacementModel, buildSidebarProjectsFromStructure, computeSidebarOrderUpdates, + createSidebarWorkspacePlacementSelector, createSidebarWorkspaceEntry, deriveProjectStatusBucket, deriveSidebarLoadingState, @@ -20,6 +21,8 @@ import { function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "ws-1", projectId: "proj", projectDisplayName: "repo", @@ -150,6 +153,8 @@ function workspace(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -610,13 +615,12 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects: [], persistedProjectOrder: ["stale-project"], - getWorkspaceOrder: () => [], }); - expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); + expect(updates).toEqual({ projectOrder: null }); }); - it("appends unseen projects while putting unseen workspaces before the saved order", () => { + it("appends unseen projects without changing saved project order", () => { const projects = [ sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }), sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }), @@ -625,36 +629,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []), }); expect(updates.projectOrder).toEqual(["project-a", "project-b"]); - expect(updates.workspaceOrders).toEqual([ - { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, - { projectViewKey: "project-b", order: ["srv:ws-3"] }, - ]); - }); - - it("preserves the saved workspace order behind multiple newly discovered workspaces", () => { - const projects = [ - sidebarProject({ - projectKey: "project-a", - workspaceKeys: ["newest", "newer", "old-a", "old-b"], - }), - ]; - - const updates = computeSidebarOrderUpdates({ - projects, - persistedProjectOrder: ["project-a"], - getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"], - }); - - expect(updates.workspaceOrders).toEqual([ - { - projectViewKey: "project-a", - order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], - }, - ]); }); it("returns no project-order update when persisted order already covers visible keys", () => { @@ -666,11 +643,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-b", "project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]), }); expect(updates.projectOrder).toBeNull(); - expect(updates.workspaceOrders).toEqual([]); }); }); @@ -993,3 +968,14 @@ describe("deriveProjectStatusBucket", () => { ).toBe("done"); }); }); + +it("retains unrelated projects and keyed row placements when one project reorders", () => { + const select = createSidebarWorkspacePlacementSelector(); + const a = project({ projectKey: "a", workspaceKeys: ["srv:a1", "srv:a2"] }); + const b = project({ projectKey: "b", workspaceKeys: ["srv:b1"] }); + const before = select([a, b]); + const after = select([{ ...a, workspaceKeys: ["srv:a2", "srv:a1"] }, b]); + expect(after.projects[1]).toBe(before.projects[1]); + expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); + expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index e004c3fb89..3c4ce4e2da 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,5 @@ +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; +import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; @@ -123,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual( return true; } -interface EffectiveWorkspaceStatus { - status: WorkspaceDescriptor["status"]; - enteredAt: Date | null; -} - function projectNameForWorkspace(workspace: WorkspaceDescriptor): string { return ( workspace.projectCustomName ?? @@ -188,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: { const EMPTY_WORKSPACE_LABELS: string[] = []; -function deriveEffectiveWorkspaceStatus(input: { - serverId: string; - workspace: WorkspaceDescriptor; - pendingCreateAttempts?: Record; - workspaceAgentActivity?: ReadonlyMap; -}): EffectiveWorkspaceStatus { - if (input.workspace.status !== "done") { - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; - } - - const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ - serverId: input.serverId, - workspaceId: input.workspace.id, - pendingCreateAttempts: input.pendingCreateAttempts, - }); - if (pendingStartedAt) { - return { status: "running", enteredAt: pendingStartedAt }; - } - - const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); - if (rootAgentActivity && rootAgentActivity.status !== "done") { - return rootAgentActivity; - } - - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; -} - -function getPendingInitialAgentCreateStartedAt(input: { - serverId: string; - workspaceId: string; - pendingCreateAttempts: Record | undefined; -}): Date | null { - let latestStartedAt: Date | null = null; - for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { - if (pending.serverId !== input.serverId) continue; - if (pending.workspaceId !== input.workspaceId) continue; - if (pending.lifecycle === "abandoned") continue; - const startedAt = new Date(pending.timestamp); - if (!latestStartedAt || startedAt > latestStartedAt) { - latestStartedAt = startedAt; - } - } - return latestStartedAt; -} - export interface ProjectStatusSession { workspaces: Map; workspaceAgentActivity: Map; @@ -287,6 +239,34 @@ export function deriveProjectStatusBucket(input: { return aggregateSidebarStateBuckets(buckets); } +/** One bounded projection per sidebar; unchanged placements survive project reordering. */ +export function createSidebarWorkspacePlacementSelector(): ( + projects: readonly HostProjectListItem[], +) => SidebarWorkspacePlacementModel { + let previous: SidebarWorkspacePlacementModel | null = null; + return (projects) => { + const next = buildSidebarWorkspacePlacementModel({ projects }); + const previousProjects = new Map( + previous?.projects.map((project) => [project.viewKey, project]), + ); + const previousWorkspaces = new Map( + previous?.workspaces.map((workspace) => [workspace.workspaceKey, workspace]), + ); + next.projects = next.projects.map((project) => { + const prior = previousProjects.get(project.viewKey); + if (prior && equal(prior, project)) return prior; + project.workspaces = project.workspaces.map((workspace) => { + const priorWorkspace = previousWorkspaces.get(workspace.workspaceKey); + return priorWorkspace && equal(priorWorkspace, workspace) ? priorWorkspace : workspace; + }); + return project; + }); + next.workspaces = next.projects.flatMap((project) => project.workspaces); + previous = next; + return next; + }; +} + export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { @@ -555,56 +535,21 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } -export function prependMissingOrderKeys(input: { - currentOrder: string[]; - visibleKeys: string[]; -}): string[] { - if (input.visibleKeys.length === 0) { - return input.currentOrder; - } - - const existingKeys = new Set(input.currentOrder); - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); - if (missingKeys.length === 0) { - return input.currentOrder; - } - - return [...missingKeys, ...input.currentOrder]; -} - export interface SidebarOrderUpdates { projectOrder: string[] | null; - workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; - getWorkspaceOrder: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { - if (input.projects.length === 0) { - return { projectOrder: null, workspaceOrders: [] }; - } - const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; - - const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; - for (const project of input.projects) { - const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); - const nextWorkspaceOrder = prependMissingOrderKeys({ - currentOrder: persistedWorkspaceOrder, - visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }); - if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); - } - } - - return { projectOrder, workspaceOrders }; + return { + projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, + }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-import-session.tsx b/packages/app/src/hooks/use-import-session.tsx index bdcf5c4563..f08d152605 100644 --- a/packages/app/src/hooks/use-import-session.tsx +++ b/packages/app/src/hooks/use-import-session.tsx @@ -1,10 +1,9 @@ import { useCallback, useState, type ReactNode } from "react"; -import { type Href, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useHostChooser } from "@/hosts/host-chooser"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useOpenProject } from "@/hooks/use-open-project"; interface UseImportSessionOptions { @@ -31,7 +30,6 @@ interface ImportedAgentTarget { export function useNavigateToImportedAgent( serverId: string | null | undefined, ): (agent: ImportedAgentTarget) => Promise { - const router = useRouter(); const normalizedServerId = serverId?.trim() || null; const openProject = useOpenProject(normalizedServerId); @@ -40,10 +38,10 @@ export function useNavigateToImportedAgent( if (!normalizedServerId) return; const project = await openProject(agent.cwd); if (project.ok) { - router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href); + navigateToAgent({ serverId: normalizedServerId, agentId: agent.id }); } }, - [normalizedServerId, openProject, router], + [normalizedServerId, openProject], ); } diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 292b54ab87..1da68b4edf 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -56,6 +56,7 @@ function createToast(): FakeToast { shown, show: (message, options) => { shown.push({ message, options }); + return () => {}; }, copied: () => {}, error: () => {}, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 323868280f..dadb912a24 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -389,26 +389,6 @@ describe("loadAppSettingsFromStorage", () => { expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests"); }); - it("uses the native terminal renderer by default", async () => { - const deps = makeDeps(); - - const result = await loadAppSettingsFromStorage(deps); - - expect(result.useLegacyTerminalRenderer).toBe(false); - }); - - it("loads the per-device legacy terminal renderer preference", async () => { - const deps = makeDeps({ - storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }), - }), - }); - - const result = await loadAppSettingsFromStorage(deps); - - expect(result.useLegacyTerminalRenderer).toBe(true); - }); - it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index d3b918755f..4fe08bb6d5 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -81,7 +81,6 @@ export interface AppSettings { sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; terminalScrollbackLines: number; - useLegacyTerminalRenderer: boolean; uiFontFamily: string; // "" = platform default UI stack monoFontFamily: string; // "" = platform default mono stack uiBaseFontSize: number; // clamped px, platform default 14 or 15 @@ -142,7 +141,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sendBehavior: "steer", serviceUrlBehavior: "ask", terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, - useLegacyTerminalRenderer: false, uiFontFamily: "", monoFontFamily: "", uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE, @@ -224,7 +222,6 @@ const StoredAppSettingsSchema = z MIN_TERMINAL_SCROLLBACK_LINES, MAX_TERMINAL_SCROLLBACK_LINES, ).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES), - useLegacyTerminalRenderer: z.boolean().catch(false), uiFontFamily: sanitizedFontFamily().catch(""), monoFontFamily: sanitizedFontFamily().catch(""), uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE) diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d8cc3383e5..5665b07e11 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,15 +1,16 @@ +import { useHostFeatureMap } from "@/runtime/host-features"; import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; -import { useHostProjects } from "@/projects/host-projects"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { - buildSidebarWorkspacePlacementModel, + createSidebarWorkspacePlacementSelector, computeSidebarOrderUpdates, createSidebarWorkspaceEntry, deriveProjectStatusBucket, @@ -85,6 +86,7 @@ const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = []; const EMPTY_PROJECT_NAMES = new Map(); export interface SidebarWorkspacesListResult { + workspaceSortSupported: boolean; workspacePlacements: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; projectNamesByViewKey: Map; @@ -138,14 +140,17 @@ export function useSidebarWorkspacesList(options?: { const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds); - const hostProjects = useHostProjects(directoryServerIds); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const groupMode = useSidebarViewStore((state) => state.groupMode); + const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); + const workspaceSortSupported = [...sortSupport.values()].every(Boolean); + const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); + const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); const sidebarModel = useMemo( - () => - buildSidebarWorkspacePlacementModel({ - projects: hostProjects, - }), - [hostProjects], + () => selectPlacements(hostProjects), + [hostProjects, selectPlacements], ); const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; @@ -161,16 +166,11 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, - getWorkspaceOrder: (projectViewKey) => - orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - for (const { projectViewKey, order } of updates.workspaceOrders) { - orderStore.setWorkspaceOrder(projectViewKey, order); - } }, [persistedProjectOrder, projects]); const refreshAll = useCallback(() => { @@ -193,6 +193,7 @@ export function useSidebarWorkspacesList(options?: { }); return { + workspaceSortSupported, workspacePlacements, projects, projectNamesByViewKey, diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index c0b62697e0..5e4d7f053c 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, @@ -81,7 +82,13 @@ export function useHostChooser() { ); if (availableHosts.length === 0) { - (input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))(); + ( + input.onNoHosts ?? + (() => { + usePanelStore.getState().showMobileAgent(); + router.push(buildSettingsAddHostRoute(Date.now())); + }) + )(); return false; } diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index c837815a7c..18ba3f041e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -369,9 +369,6 @@ describe("translation resources", () => { it("includes Settings expansion keys for the Batch 3A migration", () => { expect(en.settings.diagnostics.title).toBe("Diagnostics"); - expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe( - "Use legacy terminal renderer", - ); expect(en.settings.about.title).toBe("About"); expect(en.settings.about.releaseChannel.label).toBe("Release channel"); expect(en.settings.appearance.theme.title).toBe("Theme"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c44c7f8ffc..16779d95e1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -615,6 +615,9 @@ export const ar: TranslationResources = { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", + fileUploaded: "تم رفع الملف", + pasteFileFailed: "تعذر لصق الملف: {{reason}}", + uploadingFile: "جارٍ رفع الملف…", }, sheet: { openAsTab: "فتح كعلامة تبويب", @@ -1163,6 +1166,12 @@ export const ar: TranslationResources = { showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", + sort: { + label: "الترتيب", + created: "تاريخ الإنشاء", + lastPrompt: "آخر طلب", + updateHost: "حدّث المضيفين لترتيب مساحات العمل", + }, grouping: { label: "التجميع", project: "المشروع", @@ -2329,11 +2338,6 @@ export const ar: TranslationResources = { }, diagnostics: { title: "التشخيص", - legacyTerminalRenderer: { - label: "استخدام عارض الطرفية السابق", - description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية", - accessibilityLabel: "استخدام عارض الطرفية السابق", - }, testAudio: "اختبار الصوت", playTest: "لعب الاختبار", playing: "جارٍ اللعب...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d752c89372..6471b03f60 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -612,6 +612,9 @@ export const en = { hostDisconnected: "Host is not connected", updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", + fileUploaded: "File uploaded", + pasteFileFailed: "Failed to paste file: {{reason}}", + uploadingFile: "Uploading file…", }, sheet: { openAsTab: "Open as tab", @@ -1171,6 +1174,12 @@ export const en = { showBackground: "Show background", trigger: "Display preferences", heading: "Display", + sort: { + label: "Sort", + created: "Created", + lastPrompt: "Last prompt", + updateHost: "Update hosts to sort workspaces", + }, grouping: { label: "Grouping", project: "Project", @@ -2417,11 +2426,6 @@ export const en = { }, diagnostics: { title: "Diagnostics", - legacyTerminalRenderer: { - label: "Use legacy terminal renderer", - description: "Use the previous WebView terminal after reopening a terminal", - accessibilityLabel: "Use legacy terminal renderer", - }, testAudio: "Test audio", playTest: "Play test", playing: "Playing...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f5eda9f6b9..82ab9e6977 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -621,6 +621,9 @@ export const es: TranslationResources = { hostDisconnected: "Hostno está conectado", updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", + fileUploaded: "Archivo subido", + pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", + uploadingFile: "Subiendo archivo…", }, sheet: { openAsTab: "Abrir como pestaña", @@ -1199,6 +1202,12 @@ export const es: TranslationResources = { showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", + sort: { + label: "Orden", + created: "Creación", + lastPrompt: "Último prompt", + updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", + }, grouping: { label: "Agrupación", project: "Proyecto", @@ -2345,11 +2354,6 @@ export const es: TranslationResources = { }, diagnostics: { title: "Diagnóstico", - legacyTerminalRenderer: { - label: "Usar el renderizador de terminal anterior", - description: "Usa el terminal WebView anterior al volver a abrir un terminal", - accessibilityLabel: "Usar el renderizador de terminal anterior", - }, testAudio: "audio de prueba", playTest: "Prueba de juego", playing: "Jugando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7ffeaf8d59..44aae87628 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -621,6 +621,9 @@ export const fr: TranslationResources = { hostDisconnected: "L’hôte n’est pas connecté", updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", unableToSubscribe: "Impossible de s’abonner au terminal", + fileUploaded: "Fichier envoyé", + pasteFileFailed: "Impossible de coller le fichier : {{reason}}", + uploadingFile: "Envoi du fichier…", }, sheet: { openAsTab: "Ouvrir dans un onglet", @@ -1192,6 +1195,12 @@ export const fr: TranslationResources = { showBackground: "Afficher les espaces en arrière-plan", trigger: "Préférences d’affichage", heading: "Affichage", + sort: { + label: "Tri", + created: "Création", + lastPrompt: "Dernier prompt", + updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", + }, grouping: { label: "Regroupement", project: "Projet", @@ -2343,11 +2352,6 @@ export const fr: TranslationResources = { }, diagnostics: { title: "Diagnostic", - legacyTerminalRenderer: { - label: "Utiliser l’ancien rendu du terminal", - description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal", - accessibilityLabel: "Utiliser l’ancien rendu du terminal", - }, testAudio: "Tester le son", playTest: "Lancer le test", playing: "Lecture…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1ba8dac613..baa248aa31 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -621,6 +621,9 @@ export const ja: TranslationResources = { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", + fileUploaded: "ファイルをアップロードしました", + pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", + uploadingFile: "ファイルをアップロード中…", }, sheet: { openAsTab: "タブで開く", @@ -1176,6 +1179,12 @@ export const ja: TranslationResources = { showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", + sort: { + label: "並び順", + created: "作成日時", + lastPrompt: "最後のプロンプト", + updateHost: "ワークスペースを並べ替えるにはホストを更新してください", + }, grouping: { label: "グループ化", project: "プロジェクト", @@ -2313,11 +2322,6 @@ export const ja: TranslationResources = { }, diagnostics: { title: "診断", - legacyTerminalRenderer: { - label: "以前のターミナルレンダラーを使用", - description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します", - accessibilityLabel: "以前のターミナルレンダラーを使用", - }, testAudio: "音声をテスト", playTest: "テスト再生", playing: "再生中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f59d03a8e9..a573ef1ffa 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -616,6 +616,9 @@ export const ko: TranslationResources = { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", + fileUploaded: "파일을 업로드했습니다", + pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", + uploadingFile: "파일 업로드 중…", }, sheet: { openAsTab: "탭으로 열기", @@ -1170,6 +1173,12 @@ export const ko: TranslationResources = { showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", + sort: { + label: "정렬", + created: "생성일", + lastPrompt: "마지막 프롬프트", + updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", + }, grouping: { label: "그룹화", project: "프로젝트", @@ -2304,11 +2313,6 @@ export const ko: TranslationResources = { }, diagnostics: { title: "진단", - legacyTerminalRenderer: { - label: "레거시 터미널 렌더러 사용", - description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.", - accessibilityLabel: "레거시 터미널 렌더러 사용", - }, testAudio: "오디오 테스트", playTest: "테스트 재생", playing: "재생 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8a98a32487..43c32d2914 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -620,6 +620,9 @@ export const ptBR: TranslationResources = { hostDisconnected: "Host não está conectado", updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", + fileUploaded: "Arquivo enviado", + pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", + uploadingFile: "Enviando arquivo…", }, sheet: { openAsTab: "Abrir como aba", @@ -1189,6 +1192,12 @@ export const ptBR: TranslationResources = { showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", + sort: { + label: "Ordenação", + created: "Criação", + lastPrompt: "Último prompt", + updateHost: "Atualize os hosts para ordenar os espaços de trabalho", + }, grouping: { label: "Agrupamento", project: "Projeto", @@ -2328,11 +2337,6 @@ export const ptBR: TranslationResources = { }, diagnostics: { title: "Diagnósticos", - legacyTerminalRenderer: { - label: "Usar renderizador de terminal anterior", - description: "Usa o terminal WebView anterior após reabrir um terminal", - accessibilityLabel: "Usar renderizador de terminal anterior", - }, testAudio: "Testar áudio", playTest: "Reproduzir teste", playing: "Reproduzindo...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index b5e519baad..586ad8595d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -622,6 +622,9 @@ export const ru: TranslationResources = { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", + fileUploaded: "Файл загружен", + pasteFileFailed: "Не удалось вставить файл: {{reason}}", + uploadingFile: "Загрузка файла…", }, sheet: { openAsTab: "Открыть во вкладке", @@ -1180,6 +1183,12 @@ export const ru: TranslationResources = { showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", + sort: { + label: "Сортировка", + created: "Дата создания", + lastPrompt: "Последний запрос", + updateHost: "Обновите хосты для сортировки рабочих пространств", + }, grouping: { label: "Группировка", project: "Проект", @@ -2330,11 +2339,6 @@ export const ru: TranslationResources = { }, diagnostics: { title: "Диагностика", - legacyTerminalRenderer: { - label: "Использовать прежний рендерер терминала", - description: "Использовать прежний WebView-терминал после повторного открытия", - accessibilityLabel: "Использовать прежний рендерер терминала", - }, testAudio: "Проверка звука", playTest: "Воспроизвести тестовый звук", playing: "Воспроизведение...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2d5fbca085..08bb8491cd 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -615,6 +615,9 @@ export const zhCN: TranslationResources = { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", + fileUploaded: "文件已上传", + pasteFileFailed: "无法粘贴文件:{{reason}}", + uploadingFile: "正在上传文件…", }, sheet: { openAsTab: "作为标签页打开", @@ -1155,6 +1158,12 @@ export const zhCN: TranslationResources = { showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", + sort: { + label: "排序", + created: "创建时间", + lastPrompt: "最近提示词", + updateHost: "请更新主机以排序工作区", + }, grouping: { label: "分组", project: "项目", @@ -2268,11 +2277,6 @@ export const zhCN: TranslationResources = { }, diagnostics: { title: "诊断", - legacyTerminalRenderer: { - label: "使用旧版终端渲染器", - description: "重新打开终端后使用之前的 WebView 终端", - accessibilityLabel: "使用旧版终端渲染器", - }, testAudio: "测试音频", playTest: "播放测试", playing: "正在播放...", diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts index 5f057d4ecd..e54fc7ace9 100644 --- a/packages/app/src/keyboard/shift/index.ts +++ b/packages/app/src/keyboard/shift/index.ts @@ -1,4 +1,5 @@ export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; export { KeyboardTranslateView } from "./internal/translate"; +export { useKeyboardAnchor } from "./internal/anchor"; export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/keyboard/shift/internal/anchor.ts b/packages/app/src/keyboard/shift/internal/anchor.ts new file mode 100644 index 0000000000..ff34216682 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/anchor.ts @@ -0,0 +1,31 @@ +import { useContext, useMemo } from "react"; +import { useDerivedValue, type DerivedValue } from "react-native-reanimated"; +import { + KeyboardTranslationContext, + useKeyboardShift, + type KeyboardTranslationScope, +} from "./context"; +import { resolveKeyboardLift } from "./policy"; + +export interface KeyboardAnchor extends KeyboardTranslationScope { + /** The anchor's lift at keyboard boundaries. Bound overlay height with this, never per frame. */ + reservedLift: DerivedValue; +} + +/** + * Position portal content against an element inside a `KeyboardTranslateView`. + * Lay the content out from `measureAtRest` and pass `translation` to a portal'd + * `KeyboardTranslateView`: the content then rides the anchor's own animation + * driver, frame for frame. + */ +export function useKeyboardAnchor(): KeyboardAnchor { + const { translation, measureAtRest } = useContext(KeyboardTranslationContext); + const { layoutShift } = useKeyboardShift(); + const reservedLift = useDerivedValue(() => + resolveKeyboardLift({ shift: layoutShift.value, ...translation }), + ); + return useMemo( + () => ({ measureAtRest, translation, reservedLift }), + [measureAtRest, reservedLift, translation], + ); +} diff --git a/packages/app/src/keyboard/shift/internal/context.ts b/packages/app/src/keyboard/shift/internal/context.ts index dcb0b4cf64..f010c01255 100644 --- a/packages/app/src/keyboard/shift/internal/context.ts +++ b/packages/app/src/keyboard/shift/internal/context.ts @@ -1,5 +1,7 @@ import { createContext, useContext } from "react"; +import type { View } from "react-native"; import type { SharedValue } from "react-native-reanimated"; +import { measureInWindow } from "./measure"; export interface KeyboardShiftContextValue { shift: SharedValue; @@ -10,6 +12,35 @@ export interface KeyboardShiftContextValue { } export const KeyboardShiftContext = createContext(null); + +/** How the nearest KeyboardTranslateView moves its descendants with the keyboard. */ +export interface KeyboardTranslation { + enabled: boolean; + bottomClearance: number; +} + +export interface WindowRect { + x: number; + y: number; + width: number; + height: number; +} + +/** What the nearest KeyboardTranslateView tells the content it moves. */ +export interface KeyboardTranslationScope { + translation: KeyboardTranslation; + /** + * The element's window rect with the keyboard closed, whatever the keyboard is doing + * now; null once the element or the translate view has unmounted. + */ + measureAtRest: (element: View) => Promise; +} + +/** Views outside a KeyboardTranslateView stay put. */ +export const KeyboardTranslationContext = createContext({ + translation: { enabled: false, bottomClearance: 0 }, + measureAtRest: measureInWindow, +}); export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ diff --git a/packages/app/src/keyboard/shift/internal/measure.ts b/packages/app/src/keyboard/shift/internal/measure.ts new file mode 100644 index 0000000000..95b07e4ae8 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/measure.ts @@ -0,0 +1,60 @@ +import { useMemo, type RefObject } from "react"; +import type { View } from "react-native"; +import type { KeyboardTranslation, KeyboardTranslationScope, WindowRect } from "./context"; + +export function measureInWindow(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => resolve({ x, y, width, height })); + }); +} + +/** + * The element's window rect with `frame`'s own transform left out. Whether + * `measureInWindow` includes a transform depends on the driver: Reanimated commits + * it to the shadow tree, RN's native driver commits it only when the view happens + * to re-render. Fabric's `measureLayout` and a view's `measure` origin ignore + * transforms. The frame's parent must not move with the keyboard; the composer dock + * is the only anchor host, and its parent is the stationary dock viewport. + */ +function measureInUntranslatedWindow(frame: View, element: View): Promise { + const parent = frame.parentElement; + if (!parent) return Promise.resolve(null); + const parentRect = parent.getBoundingClientRect(); + const frameOrigin = new Promise<{ x: number; y: number }>((resolve) => { + frame.measure((x, y) => resolve({ x, y })); + }); + const offset = new Promise((resolve) => { + element.measureLayout( + frame, + (x, y, width, height) => resolve({ x, y, width, height }), + () => resolve(null), + ); + }); + return Promise.all([frameOrigin, offset]).then(([origin, rect]) => + rect + ? { + x: parentRect.x + origin.x + rect.x, + y: parentRect.y + origin.y + rect.y, + width: rect.width, + height: rect.height, + } + : null, + ); +} + +/** What a KeyboardTranslateView publishes to the content it moves. */ +export function useKeyboardTranslationScope( + frameRef: RefObject, + translation: KeyboardTranslation, +): KeyboardTranslationScope { + return useMemo( + () => ({ + translation, + measureAtRest: (element: View) => + frameRef.current + ? measureInUntranslatedWindow(frameRef.current, element) + : Promise.resolve(null), + }), + [frameRef, translation], + ); +} diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 7c4902afe1..6867f3ffa1 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, + resolveKeyboardLift, resolveKeyboardShift, reserveKeyboardLayoutShift, } from "./policy"; @@ -89,6 +90,21 @@ describe("resolveKeyboardShift", () => { }); }); +describe("resolveKeyboardLift", () => { + it("lifts a docked view by the whole shift", () => { + expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302); + }); + + it("lets the keyboard fill a centered form's clearance before lifting it", () => { + expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136); + expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0); + }); + + it("keeps a disabled view in place", () => { + expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0); + }); +}); + describe("shouldReconcileHiddenKeyboardEnd", () => { it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => { expect( diff --git a/packages/app/src/keyboard/shift/internal/policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts index fe496fd110..4e618c8a49 100644 --- a/packages/app/src/keyboard/shift/internal/policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -52,6 +52,16 @@ export function resolveKeyboardShift(input: { return Math.max(0, input.rawKeyboardHeight - input.bottomInset); } +/** Distance a KeyboardTranslateView lifts its content for a given keyboard shift. */ +export function resolveKeyboardLift(input: { + shift: number; + enabled: boolean; + bottomClearance: number; +}): number { + "worklet"; + return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0; +} + export function shouldReconcileHiddenKeyboardEnd(input: { height: number; progress: number; diff --git a/packages/app/src/keyboard/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx index 2a9e9d581d..cec2f0fcfd 100644 --- a/packages/app/src/keyboard/shift/internal/translate.android.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -1,7 +1,9 @@ -import type { ReactNode } from "react"; -import type { ViewProps } from "react-native"; +import { useMemo, useRef, type ReactNode } from "react"; +import type { View, ViewProps } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useKeyboardShift } from "./context"; +import { KeyboardTranslationContext, useKeyboardShift } from "./context"; +import { useKeyboardTranslationScope } from "./measure"; +import { resolveKeyboardLift } from "./policy"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; @@ -18,13 +20,18 @@ export function KeyboardTranslateView({ ...props }: KeyboardTranslateViewProps) { const { shift } = useKeyboardShift(); + const frameRef = useRef(null); + const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const scope = useKeyboardTranslationScope(frameRef, translation); const keyboardStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }], + transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }], })); return ( - - {children} + + + {children} + ); } diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx index 1fea4fab0c..8628ba8b8c 100644 --- a/packages/app/src/keyboard/shift/internal/translate.ios.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -1,7 +1,9 @@ -import { useMemo, useEffect, type ReactNode } from "react"; -import { Animated, useAnimatedValue, type ViewProps } from "react-native"; +import { useMemo, useEffect, useRef, type ReactNode } from "react"; +import { Animated, useAnimatedValue, type View, type ViewProps } from "react-native"; import { useKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { KeyboardTranslationContext } from "./context"; +import { useKeyboardTranslationScope } from "./measure"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; @@ -35,7 +37,10 @@ export function KeyboardTranslateView({ // Fullscreen resizes this view while disabling translation. iOS Fabric can // reapply its previous transform during that layout commit. Keep the graph // attached and apply the mode on the native animation frame; a static zero or - // setValue during the React commit gets overwritten by the resize. + // setValue during the React commit gets overwritten by the resize. A rebuilt + // graph (a new bottomClearance after rotation) starts from JS-side keyboard + // values, so run it on the native frame too or it stays stale until the + // keyboard next moves. const enabledValue = useAnimatedValue(enabled ? 1 : 0); useEffect(() => { const animation = Animated.timing(enabledValue, { @@ -45,15 +50,21 @@ export function KeyboardTranslateView({ }); animation.start(); return () => animation.stop(); - }, [enabled, enabledValue]); + }, [enabled, enabledValue, translateY]); const keyboardStyle = useMemo( () => ({ transform: [{ translateY: Animated.multiply(translateY, enabledValue) }] }), [enabledValue, translateY], ); + const frameRef = useRef(null); + const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const scope = useKeyboardTranslationScope(frameRef, translation); + return ( - - {children} + + + {children} + ); } diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index 30daf5ffa9..ac57778111 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; import { i18n } from "@/i18n/i18next"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -66,3 +67,57 @@ describe("buildDraftPanelDescriptor", () => { await i18n.changeLanguage("en"); }); }); + +describe("shared agent descriptor", () => { + function describeAgent(overrides: Partial[0]> = {}) { + return buildAgentDescriptor({ + provider: "codex", + title: " Review checkout retries ", + status: "idle", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + isTurnActive: false, + icon: TestIcon, + ...overrides, + }); + } + + it("keeps the normalized title and provider identity together", () => { + expect(describeAgent()).toMatchObject({ + label: "Review checkout retries", + subtitle: "Codex agent", + icon: TestIcon, + titleState: "ready", + statusBucket: "done", + }); + }); + + it.each([null, "", " ", "New Agent", " new agent "])( + "leaves an untitled agent loading: %s", + (title) => { + expect(resolveAgentLabel(title)).toBeNull(); + expect(describeAgent({ title })).toMatchObject({ label: "", titleState: "loading" }); + }, + ); + + it("shows an active turn as running before the lifecycle update arrives", () => { + expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running"); + }); + + it("surfaces pending permissions even while the turn is running", () => { + expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe( + "needs_input", + ); + }); + + it.each([ + ["finished", "attention"], + ["error", "failed"], + ["permission", "needs_input"], + ] as const)("preserves %s attention", (attentionReason, statusBucket) => { + expect(describeAgent({ requiresAttention: true, attentionReason }).statusBucket).toBe( + statusBucket, + ); + }); +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2fa2624fb4..af14b94229 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -39,7 +39,7 @@ import { } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; -import { useProviderIcon } from "@/components/provider-icons"; +import { useAgentDescriptor } from "@/agent-presentation"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -62,7 +62,7 @@ import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { PaneSubtitle } from "@/panels/pane-subtitle"; -import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; +import { definePanel } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -101,7 +101,6 @@ import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; interface ChatAgentStateShape { @@ -256,31 +255,6 @@ function renderChatAgentNonReadyView(args: { return null; } -function formatProviderLabel(provider: Agent["provider"]): string { - if (!provider) { - return "Agent"; - } - return provider - .split(/[-_\s]+/) - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { return !agent.archivedAt && Boolean(agent.projectPlacement); } @@ -328,47 +302,6 @@ function storeFetchedAgentDetail(input: { return hydrated; } -function useAgentPanelDescriptor( - target: { kind: "agent"; agentId: string }, - context: { serverId: string }, -): PanelDescriptor { - const descriptorState = useSessionStore( - useShallow((state) => { - const session = state.sessions[context.serverId]; - const agent = - session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; - return { - provider: agent?.provider ?? "codex", - title: agent?.title ?? null, - status: agent?.status ?? null, - pendingPermissionCount: agent?.pendingPermissions.length ?? 0, - requiresAttention: agent?.requiresAttention ?? false, - attentionReason: agent?.attentionReason ?? null, - isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, - }; - }), - ); - const provider = descriptorState.provider; - const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = useProviderIcon(provider, context.serverId); - - return { - label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, - tooltip: label ?? `${formatProviderLabel(provider)} agent`, - titleState: label ? "ready" : "loading", - icon, - statusBucket: descriptorState.status - ? deriveSidebarStateBucket({ - status: descriptorState.isTurnActive ? "running" : descriptorState.status, - pendingPermissionCount: descriptorState.pendingPermissionCount, - requiresAttention: descriptorState.requiresAttention, - attentionReason: descriptorState.attentionReason, - }) - : null, - }; -} - function AgentPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); @@ -438,7 +371,7 @@ export function AgentConversationPanel() { export const agentPanelRegistration = definePanel("agent", { component: AgentConversationPanel, - useDescriptor: useAgentPanelDescriptor, + useDescriptor: useAgentDescriptor, }); export function useDraftPanelDescriptor( diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 1d5112359e..d26bd3271d 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -30,7 +30,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext(); + const { serverId, workspaceId, target, panelRevision } = usePaneContext(); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { @@ -45,7 +45,7 @@ function FilePanel() { serverId={serverId} workspaceRoot={workspaceDirectory} location={target} - navigationRevision={fileNavigationRevision ?? 0} + navigationRevision={panelRevision ?? 0} /> ); } diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 5e716b006a..52b33e5960 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -13,7 +13,7 @@ export interface PaneContextValue { tabId: string; target: WorkspaceTabTarget; state?: JsonValue; - fileNavigationRevision?: number; + panelRevision?: number; openTab: (target: WorkspaceTabTarget) => void; openPreferredTarget: (target: WorkspaceTabTarget, source: OpenInSidePaneSource) => void; openTargetToSide?: (target: WorkspaceTabTarget) => void; diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx index 2f2b97aab2..194a61efa3 100644 --- a/packages/app/src/panels/pull-request/activity.integration.test.tsx +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { waitFor } from "@testing-library/dom"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const noop = () => {}; -const toastApi = { show: noop, error: noop, copied: noop }; +const toastApi = { show: () => noop, error: noop, copied: noop }; const roots: ReturnType[] = []; function render(children: ReactNode) { const container = document.createElement("div"); @@ -124,6 +124,8 @@ beforeEach(async () => { workspaceKind: "local_checkout", name: "main", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index cba12e3cbd..07b1ceca75 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,39 @@ -import { act, useLayoutEffect, useState, useSyncExternalStore } from "react"; +import React, { + act, + useCallback, + useEffect, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it } from "vitest"; -import { RetainedPanelActivity } from "@/components/retained-panel"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent as NativeChat } from "./retained-chat-content.native"; import { RetainedChatContent as WebChat } from "./retained-chat-content.web"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { + ComposerEditingScope, + ComposerEditingSessionsProvider, + WorkspaceComposerEditingSessionsProvider, + useComposerEditingSession, +} from "@/composer/editing-session"; +import { + DiffDocumentWorkspaceCacheProvider, + useDiffDocumentWorkspaceCache, +} from "@/git/diff-document/workspace-cache"; +import { + AssistantFileLinkResolverProvider, + useAssistantFileLinkEnvironment, +} from "@/assistant-file-links"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; + +import { render as renderEnvironment } from "@testing-library/react"; +import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links"; + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root; let container: HTMLDivElement; @@ -99,3 +127,96 @@ it("retains modified panels beyond the LRU cap and evicts them normally after sa render("e"); expect(mounted).toEqual(["e", "a", "b"]); }); + +describe("workspace sheet environment", () => { + it("shares editing sessions and diff cache across the host, keeping resolver updates live", async () => { + let bridge: ContextBridge = (children) => children; + const observations = new Map>(); + let mounts = 0; + const queryClient = new QueryClient(); + const opened: Array<{ root: string; path: string }> = []; + function useEnvironment() { + return { + session: useComposerEditingSession("chat"), + cache: useDiffDocumentWorkspaceCache(), + files: useAssistantFileLinkEnvironment(), + active: useRetainedPanelActive(), + actions: useAssistantFileLinkActions(), + }; + } + function Probe({ name }: { name: string }) { + observations.set(name, useEnvironment()); + useEffect(() => { + if (name === "sheet") mounts++; + }, [name]); + return null; + } + function Capture() { + bridge = useWorkspaceSheetEnvironment(); + return ( + + + + ); + } + function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) { + const onOpenFile = useCallback( + (target: InlinePathTarget) => { + opened.push({ root: workspaceRoot, path: target.path }); + }, + [workspaceRoot], + ); + return ( + + + + + + + + + + + + + + ); + } + const source = renderEnvironment(); + const sheetContent = ( + + + + ); + const sheet = renderEnvironment( + {bridge(sheetContent)}, + ); + const pane = observations.get("pane")!; + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/before"); + const actions = observations.get("sheet")!.actions; + act(() => pane.session.setFullscreen(true)); + source.rerender(); + // An action captured before the provider update must already use the live resolver value. + await act(async () => actions.open({ href: "/after/linked.ts" }, "preferred")); + expect(opened).toEqual([{ root: "/after", path: "/after/linked.ts" }]); + sheet.rerender( + {bridge(sheetContent)}, + ); + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.session.getFullscreen()).toBe(true); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/after"); + expect(observations.get("sheet")?.active).toBe(false); + expect(mounts).toBe(1); + sheet.unmount(); + source.unmount(); + }); +}); diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index cbbbaaed4b..0c90185e53 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -3,6 +3,8 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index f0c768139a..c1f95b87fa 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -381,6 +381,23 @@ describe("evaluatePluginClientBundle", () => { ).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]); }); + it("registers workspace and agent panels with stable aliases", () => { + const plugin = evaluatePluginClientBundle( + "panels", + bundle(` + const Component = () => null; + plugin.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component }); + plugin.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component }); + plugin.addWorkspacePanel({ id: "legacy", title: "Legacy", icon: "File", context: "agent", Component }); + `), + ); + expect(plugin.workspacePanels.map(({ id, context }) => ({ id, context }))).toEqual([ + { id: "workspace", context: "workspace" }, + { id: "agent", context: "agent" }, + { id: "legacy", context: "agent" }, + ]); + }); + it("registers inline SVG and component icons", () => { const svg = ''; const plugin = evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index db5fece8c7..44bcbec57d 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -15,6 +15,8 @@ import * as Zod from "zod"; import { type PluginCleanup } from "@getpaseo/plugin"; import { type PluginClientContext, + type PluginAgentPanelContribution, + type PluginWorkspacePanelContribution as WorkspacePanelInput, type PluginScreenProps, type PluginScreenTitle, type PluginText, @@ -206,6 +208,31 @@ export function runPluginClientBundle( () => ids.delete(normalizedId), ); } + function registerPanel(contribution: PluginWorkspacePanelContribution): PluginCleanup { + const normalizedId = requireId(contribution.id, "workspace panel id"); + if (workspacePanelIds.has(normalizedId)) { + throw new Error(`Duplicate workspace panel: ${normalizedId}`); + } + const title = normalizePluginText(contribution.title); + if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); + const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); + if (typeof contribution.Component !== "function") { + throw new Error(`Workspace panel ${normalizedId} is not a component`); + } + const locations = normalizePanelLocations(normalizedId, contribution.locations); + workspacePanelIds.add(normalizedId); + return register( + collector.workspacePanels, + { + ...contribution, + id: normalizedId, + title, + icon, + locations, + }, + () => workspacePanelIds.delete(normalizedId), + ); + } const pluginContext: PluginClientContext = { ...runtime, addSettingsScreen(contribution) { @@ -313,33 +340,17 @@ export function runPluginClientBundle( () => filePanelIds.delete(panelId), ); }, - addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { - const normalizedId = requireId(contribution.id, "workspace panel id"); - if (workspacePanelIds.has(normalizedId)) { - throw new Error(`Duplicate workspace panel: ${normalizedId}`); + addAgentPanel(contribution: PluginAgentPanelContribution) { + return registerPanel({ ...contribution, context: "agent" }); + }, + addWorkspacePanel(input: WorkspacePanelInput) { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + if (input.context === "agent") return pluginContext.addAgentPanel(input); + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + if (input.context !== undefined && input.context !== "workspace") { + throw new Error(`Workspace panel ${input.id} has invalid context`); } - const title = normalizePluginText(contribution.title); - if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); - const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); - if (contribution.context !== "workspace" && contribution.context !== "agent") { - throw new Error(`Workspace panel ${normalizedId} has invalid context`); - } - if (typeof contribution.Component !== "function") { - throw new Error(`Workspace panel ${normalizedId} is not a component`); - } - const locations = normalizePanelLocations(normalizedId, contribution.locations); - workspacePanelIds.add(normalizedId); - return register( - collector.workspacePanels, - { - ...contribution, - id: normalizedId, - title, - icon, - locations, - }, - () => workspacePanelIds.delete(normalizedId), - ); + return registerPanel({ ...input, context: "workspace" }); }, addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { const normalizedId = requireId(contribution.id, "Command Center item id"); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 63d5f1c9f9..9c2a88a210 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -32,7 +32,10 @@ type PluginSidebarContribution = SDKPluginSidebarContribution; type PluginSidebarItemContribution = SDKPluginSidebarItemContribution; type PluginScreenContribution = SDKPluginScreenContribution; type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution; -type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution; +type WorkspacePanelInput = SDKPluginWorkspacePanelContribution; +type PluginWorkspacePanelContribution = + | (Extract & { context: "workspace" }) + | Extract; export type PluginSidebarSection = "header" | "footer"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 5534f78113..096400fb66 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,8 +1,13 @@ -import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; +import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import type { + PluginAgentPanelProps, + PluginWorkspacePanelProps, + PluginTab, +} from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; @@ -29,7 +34,20 @@ const pluginThemeMapping = (theme: Theme) => ({ }); function PluginPanelBody({ theme }: { theme: PluginTheme }) { - const { serverId, workspaceId, target } = usePaneContext(); + const { + serverId, + workspaceId, + target, + state: tabState, + setCurrentTabState, + panelRevision, + } = usePaneContext(); + const [modified, setModified] = useState(false); + usePublishPanelInstanceAttributes({ modified }); + const tab = useMemo( + () => ({ state: tabState, setState: setCurrentTabState, setModified }), + [tabState, setCurrentTabState], + ); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); const plugin = useInstalledPlugin(serverId, target.pluginId); const contribution = resolvePluginWorkspacePanel(plugin, target); @@ -63,7 +81,11 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { let Surface: unknown; if (contribution.context === "workspace") { const props: PluginWorkspacePanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "workspace", + params: {}, + revision: panelRevision ?? 0, + tab, theme, host, layout, @@ -75,12 +97,17 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { panel = ; } else if (agentExists && target.context === "agent") { const props: PluginAgentPanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "agent", + params: { agentId: target.agentId }, + revision: panelRevision ?? 0, + tab, theme, host, layout, navigation, workspaceId, + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 agentId: target.agentId, }; const Component = contribution.Component; diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index 4c3af57c35..4f0703564f 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,11 +1,11 @@ -import type { PluginWorkspacePanelContribution } from "../types"; +import type { EvaluatedPluginWorkspacePanelContribution } from "../types"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; export function resolvePluginWorkspacePanel( plugin: InstalledPlugin | null, target: PluginWorkspaceTabTarget, -): PluginWorkspacePanelContribution | null { +): EvaluatedPluginWorkspacePanelContribution | null { return ( plugin?.workspacePanels.find( (panel) => panel.id === target.panelId && panel.context === target.context, diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 350f8855a7..ba8bc1431f 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -20,6 +20,8 @@ function project(input: { function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", @@ -174,3 +176,51 @@ describe("buildWorkspaceStructureProjects", () => { ); }); }); + +test("sorts by creation or human prompt without changing picker name order", () => { + const old = { + ...workspace("old", "p", "/p"), + name: "A", + createdAt: new Date("2026-01-01"), + lastUserMessageAt: new Date("2026-03-01"), + }; + const fresh = { + ...workspace("fresh", "p", "/p"), + name: "B", + createdAt: new Date("2026-02-01"), + lastUserMessageAt: null, + }; + const sessions = [ + { + serverId: "host", + projects: [project({ id: "p", key: "p", root: "/p" })], + workspaces: [old, fresh], + }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["host:fresh", "host:old"]); + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "lastPrompt" })[0].workspaceKeys, + ).toEqual(["host:old", "host:fresh"]); + expect(buildWorkspaceStructureProjects({ sessions })[0].workspaceKeys).toEqual([ + "host:old", + "host:fresh", + ]); +}); + +test("time ties use workspace identity so renaming cannot move Created rows", () => { + const a = { ...workspace("a", "p", "/p"), name: "Z", createdAt: new Date("2026-01-01") }; + const b = { ...workspace("b", "p", "/p"), name: "A", createdAt: a.createdAt }; + const sessions = [ + { serverId: "h", projects: [project({ id: "p", key: "p", root: "/p" })], workspaces: [b, a] }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); + a.name = "A"; + b.name = "Z"; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); +}); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 939e1381d2..92e6c68de4 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,6 +30,14 @@ interface WorkspaceStructureSession { workspaces: Iterable; } +export type WorkspaceSort = "created" | "lastPrompt"; + +interface WorkspaceSortItem { + workspaceName: string; + workspaceKey: string; + sortTime: number; +} + interface ProjectDraft { viewKey: string; projectKey: string | null; @@ -38,12 +46,13 @@ interface ProjectDraft { projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; hosts: Map; - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; + workspaces: WorkspaceSortItem[]; } /** The single app boundary that turns host-local projects into grouped display projects. */ export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; + workspaceSort?: WorkspaceSort; }): WorkspaceStructureProject[] { const byProject = new Map(); const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = []; @@ -84,7 +93,7 @@ export function buildWorkspaceStructureProjects(input: { const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); if (!viewKey) continue; byProject.get(viewKey)?.workspaces.push({ - workspaceId: workspace.id, + sortTime: workspaceSortTime(workspace, input.workspaceSort), workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, }); @@ -100,7 +109,7 @@ export function buildWorkspaceStructureProjects(input: { iconWorkingDir: draft.iconWorkingDir, hosts: Array.from(draft.hosts.values()), workspaceKeys: draft.workspaces - .sort(compareWorkspaceStructureItems) + .sort(input.workspaceSort ? compareWorkspaceTimes : compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) .sort( @@ -196,14 +205,27 @@ function getOrCreate(map: Map, key: K, create: () => V): V { return value; } -function compareWorkspaceStructureItems( - left: { workspaceId: string; workspaceName: string }, - right: { workspaceId: string; workspaceName: string }, -): number { +function compareWorkspaceStructureItems(left: WorkspaceSortItem, right: WorkspaceSortItem): number { return ( left.workspaceName.localeCompare(right.workspaceName, undefined, { numeric: true, sensitivity: "base", - }) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" }) + }) || left.workspaceKey.localeCompare(right.workspaceKey) ); } + +function workspaceSortTime( + workspace: WorkspaceDescriptor, + sort: WorkspaceSort | undefined, +): number { + if (!sort) return 0; + const time = + sort === "lastPrompt" + ? (workspace.lastUserMessageAt ?? workspace.createdAt) + : workspace.createdAt; + return time?.getTime() ?? 0; +} + +function compareWorkspaceTimes(left: WorkspaceSortItem, right: WorkspaceSortItem): number { + return right.sortTime - left.sortTime || left.workspaceKey.localeCompare(right.workspaceKey); +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 98e0ea29b3..6fae0a7e92 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1550,7 +1550,14 @@ it("fills every cached workspace beneath live updates received during the SQLite type: "workspace_update", payload: { kind: "upsert", - workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + workspace: { + ...workspaces[0], + createdAt: undefined, + lastUserMessageAt: null, + name: "Live", + activityAt: null, + statusEnteredAt: null, + }, }, }); }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0142548674..33e4f1a316 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -56,6 +56,7 @@ class FakeDaemonClient { supportsBackgroundWorkspaces(): boolean { return false; } + relaySecurityFloor: "legacy" | "protected" | undefined = undefined; private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; @@ -345,6 +346,11 @@ class Deferred { } } +function relayPinOf(host: HostProfile | undefined): "protected" | undefined { + const connection = host?.connections[0]; + return connection?.type === "relay" ? connection.minimumSecurityMode : undefined; +} + async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise { if (store.getSnapshot(serverId)?.connectionStatus === "online") return; await new Promise((resolve) => { @@ -564,6 +570,7 @@ function createPairingStore( connectAttempts?: string[]; serverIdForHost?: (host: HostProfile) => string; storage?: HostRuntimeStorage; + probeSecurityFloor?: "legacy" | "protected"; } = {}, ): HostRuntimeStore { return new HostRuntimeStore({ @@ -575,8 +582,10 @@ function createPairingStore( if (input.password && host.password !== input.password) { throw new DaemonAuthenticationError("password_required"); } + const client = makeConnectedProbeClient(5); + client.relaySecurityFloor = input.probeSecurityFloor; return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, + client: client as unknown as DaemonClient, serverId: input.serverIdForHost?.(host) ?? host.serverId, hostname: input.hostname ?? "paired", }; @@ -787,6 +796,85 @@ describe("HostRuntimeController", () => { expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); }); + it("keeps the active relay client when its security pin is learned", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + // The client negotiated protected itself; the pin it reported is already enforced. + createdClients[0]!.relaySecurityFloor = "protected"; + await controller.updateHost( + makeHost({ + connections: [{ ...relay, minimumSecurityMode: "protected" }], + preferredConnectionId: relay.id, + }), + ); + + expect(createdClients).toHaveLength(1); + expect(createdClients[0]?.isDisposed()).toBe(false); + expect(controller.getSnapshot().client).toBe(createdClients[0]); + }); + + it("replaces the active legacy relay client when a strict offer pins its connection", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + createdClients.push({ client, connection }); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + expect(createdClients[0]?.client.relaySecurityFloor).toBeUndefined(); + const pinned: HostConnection = { ...relay, minimumSecurityMode: "protected" }; + await controller.updateHost( + makeHost({ connections: [pinned], preferredConnectionId: relay.id }), + ); + + expect(createdClients.map((entry) => entry.connection)).toEqual([relay, pinned]); + expect(createdClients[0]?.client.isDisposed()).toBe(true); + expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); + }); + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { const host = makeHost({ connections: [ @@ -3857,6 +3945,96 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("keeps a strict offer's security pin through both pairing paths and a reload", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage }); + answerHostConfirmations(store, true); + const strictOffer = (serverId: string): ConnectionOffer => ({ + ...makeOffer({ serverId, daemonPublicKeyB64: `pk_${serverId}` }), + minimumSecurityMode: "protected", + }); + + await store.importConnectionLink(encodeOfferUrl(strictOffer("srv_import")), "openProject"); + await store.beginLinkPairing().submit(encodeOfferUrl(strictOffer("srv_pair"))); + + const pinOf = (hosts: HostProfile[], serverId: string) => + relayPinOf(hosts.find((host) => host.serverId === serverId)); + expect(pinOf(store.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(store.getHosts(), "srv_pair")).toBe("protected"); + + await waitForHostOnline(store, "srv_pair"); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(pinOf(persisted, "srv_import")).toBe("protected"); + expect(pinOf(persisted, "srv_pair")).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(pinOf(reloaded.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(reloaded.getHosts(), "srv_pair")).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin from an adopted probe client and keeps it across a reload", async () => { + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_saved", + }; + const host = makeHost({ + serverId: "srv_saved", + connections: [relay], + preferredConnectionId: relay.id, + }); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + + store.boot(); + await waitForHostOnline(store, "srv_saved"); + const pinned = onceHostListMatches( + store, + () => relayPinOf(store.getHosts()[0]) === "protected", + ); + await pinned; + + const activeClient = store.getSnapshot("srv_saved")?.client as unknown as FakeDaemonClient; + expect(activeClient.relaySecurityFloor).toBe("protected"); + expect(activeClient.isDisposed()).toBe(false); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage, probeSecurityFloor: "legacy" }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(relayPinOf(reloaded.getHosts()[0])).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin for a host paired from an old offer once it goes online", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await waitForHostOnline(store, "srv_offer"); + await onceHostListMatches(store, () => relayPinOf(store.getHosts()[0]) === "protected"); + + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + + store.syncHosts([]); + }); + it("probes a pairing link immediately and saves only after admission", async () => { const store = createPairingStore({ password: "correct-password" }); answerHostConfirmations(store, true); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fd8c1499d0..bb953220cb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -16,6 +16,7 @@ import { connectionFromListen, createRemoteSshHostConnection, normalizeStoredHostProfile, + pinRelayProtectedSecurityInProfiles, upsertHostConnectionInProfiles, registryHasConnection, relayConnectionFromOffer, @@ -191,6 +192,12 @@ function hashForLog(value: string): string { return `h_${Math.abs(hash).toString(16)}`; } +/** A daemon identity that negotiated protected relay security. */ +export interface RelaySecurityPin { + serverId: string; + daemonPublicKeyB64: string; +} + export interface HostRuntimeControllerDeps { createClient: (input: { host: HostProfile; @@ -503,6 +510,29 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { })); } +/** + * A pin learned from the active client is already enforced by that client, so + * it keeps running. A pin that arrived from elsewhere (a strict offer) while + * the active client is still legacy needs a client that enforces it. + */ +function connectionNeedsNewClient( + previous: HostConnection, + next: HostConnection, + activeClient: DaemonClient | null, +): boolean { + if (previous.type === "relay" && next.type === "relay") { + const { minimumSecurityMode: previousPin, ...previousRest } = previous; + const { minimumSecurityMode: nextPin, ...nextRest } = next; + if (!equal(previousRest, nextRest)) return true; + return ( + nextPin === "protected" && + previousPin !== "protected" && + activeClient?.relaySecurityFloor !== "protected" + ); + } + return !equal(previous, next); +} + function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null { if (!connectionId) { return null; @@ -616,6 +646,9 @@ function createDefaultDeps( e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumMode: connection.minimumSecurityMode } + : {}), }, }); }, @@ -678,6 +711,7 @@ export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; + private onRelayProtectedSecurityNegotiated: ((input: RelaySecurityPin) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; @@ -702,10 +736,12 @@ export class HostRuntimeController { host: HostProfile; deps?: HostRuntimeControllerDeps; onReconcileServerId?: (oldId: string, newId: string) => void; + onRelayProtectedSecurityNegotiated?: (input: RelaySecurityPin) => void; }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); this.onReconcileServerId = input.onReconcileServerId ?? null; + this.onRelayProtectedSecurityNegotiated = input.onRelayProtectedSecurityNegotiated ?? null; this.connectionMachineState = { tag: "booting", }; @@ -807,7 +843,12 @@ export class HostRuntimeController { activeConnectionId && previousActiveConnection && nextActiveConnection && - (!equal(previousActiveConnection, nextActiveConnection) || passwordChanged) + (connectionNeedsNewClient( + previousActiveConnection, + nextActiveConnection, + this.activeClient, + ) || + passwordChanged) ) { this.connectionLastProbedAt.delete(activeConnectionId); await this.switchToConnection({ connectionId: activeConnectionId }); @@ -1433,6 +1474,7 @@ export class HostRuntimeController { void failConnection(new DaemonAuthenticationError(client.authFailureReason)); return; } + if (state.status === "connected") this.learnRelaySecurity(client, connection.id); this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), @@ -1446,6 +1488,25 @@ export class HostRuntimeController { } } + // The active client is the one place every connection path meets: clients + // created here and probe clients adopted from `connectToDaemon` both land in + // `switchToConnection`. A protected handshake observed here becomes the + // persisted pin for that daemon identity. + private learnRelaySecurity(client: DaemonClient, connectionId: string): void { + const connection = findConnectionById(this.host, connectionId); + if ( + connection?.type !== "relay" || + connection.minimumSecurityMode === "protected" || + client.relaySecurityFloor !== "protected" + ) { + return; + } + this.onRelayProtectedSecurityNegotiated?.({ + serverId: this.host.serverId, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + }); + } + adoptReconciledServerId(newServerId: string): void { this.host = { ...this.host, serverId: newServerId }; this.snapshot = { ...this.snapshot, serverId: newServerId }; @@ -1994,39 +2055,6 @@ export class HostRuntimeStore { }); } - private async upsertRelayConnection(input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }): Promise { - const relayEndpoint = normalizeHostPort(input.relayEndpoint); - const useTls = input.useTls ?? false; - const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); - if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required"); - } - const explicitUseTls = input.useTls !== undefined; - const profile = await this.upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - ...(explicitUseTls ? { useTls } : {}), - daemonPublicKeyB64, - }, - }); - if (input.password) { - await this.setHostPassword(profile.serverId, input.password); - return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; - } - return profile; - } - // A pairing link (a URL, a QR code, or a pasted link) asks the user to // confirm when it adds a new host or changes a saved host's key, relay, or // TLS setting. The answer needs the saved hosts, so it waits for them to load. @@ -2094,14 +2122,19 @@ export class HostRuntimeStore { password?: string, ): Promise { const connection = relayConnectionFromOffer(offer); - return this.upsertRelayConnection({ + if (!connection.daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + const profile = await this.upsertHostConnection({ serverId: offer.serverId, - relayEndpoint: connection.relayEndpoint, - useTls: connection.useTls, - daemonPublicKeyB64: connection.daemonPublicKeyB64, label, - password, + connection, }); + if (password) { + await this.setHostPassword(profile.serverId, password); + return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; + } + return profile; } private async probeAndUpsertOffer( @@ -2147,6 +2180,13 @@ export class HostRuntimeStore { }); } + private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise { + const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input }); + if (next === this.hosts) return; + this.setHostsAndSync(next); + await this.persistHosts(); + } + private async updateHost( serverId: string, apply: (host: HostProfile) => HostProfile, @@ -2356,6 +2396,7 @@ export class HostRuntimeStore { host, deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), + onRelayProtectedSecurityNegotiated: (pin) => void this.pinRelayProtectedSecurity(pin), }); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c48777a3e2..eaad715a52 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,37 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("preserves workspace sorting timestamps across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + createdAt: "2026-07-18T08:00:00.000Z", + lastUserMessageAt: "2026-07-18T09:00:00.000Z", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + expect((await reader.readDirectory(SERVER_ID)).workspaces.get(workspace.id)).toEqual(workspace); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace).toEqual(workspace); + }); + + it.each(["createdAt", "lastUserMessageAt"])( + "refetches workspaces cached without %s", + async (field) => { + const cached = await cacheWithoutBackgroundVisibility(); + const key = `${SERVER_ID}:workspace:workspace-1`; + const row = cached.storage.rows.get(key)!; + const payload = JSON.parse(row.payload); + payload.background = false; + delete payload[field]; + cached.storage.rows.set(key, { ...row, payload: JSON.stringify(payload) }); + await expectLegacyWorkspaceRefresh(cached); + }, + ); + it.each([true, false, undefined])( "preserves background visibility (%s) when reopening a cached workspace", async (background) => { diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 681d11a2c4..59578e65d7 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -296,6 +296,9 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. + createdAt: IsoDateSchema.nullable(), + lastUserMessageAt: IsoDateSchema.nullable(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), @@ -688,8 +691,14 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } +function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { + return normalizeWorkspaceDescriptor({ ...stored, createdAt: stored.createdAt ?? undefined }); +} + function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + createdAt: workspace.createdAt?.toISOString() ?? null, + lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, @@ -856,7 +865,7 @@ function applyDirectoryRow( case "workspace": { const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload); if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch"); - result.workspaces.set(row.id, normalizeWorkspaceDescriptor(stored)); + result.workspaces.set(row.id, restoreWorkspace(stored)); return; } case "project": { @@ -946,7 +955,7 @@ export class ReplicaCache { try { const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); - workspace = normalizeWorkspaceDescriptor(stored); + workspace = restoreWorkspace(stored); } catch { await this.deleteInvalidRow(workspaceRow); return undefined; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3e3a4ef553..b90f5ccd24 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -81,7 +81,6 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts- import { EditorSection } from "@/screens/settings/editor-section"; import { FilesSection } from "@/screens/settings/files-section"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; @@ -136,7 +135,7 @@ import { } from "@/utils/host-routes"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- // View model @@ -383,8 +382,6 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) } interface DiagnosticsSectionProps { - useLegacyTerminalRenderer: boolean; - onUseLegacyTerminalRendererChange: (value: boolean) => void; voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; @@ -392,8 +389,6 @@ interface DiagnosticsSectionProps { } function DiagnosticsSection({ - useLegacyTerminalRenderer, - onUseLegacyTerminalRendererChange, voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, @@ -407,26 +402,6 @@ function DiagnosticsSection({ return ( - {isNative ? ( - - - - {t("settings.diagnostics.legacyTerminalRenderer.label")} - - - {t("settings.diagnostics.legacyTerminalRenderer.description")} - - - - - ) : null} {t("settings.diagnostics.app.rowTitle")} @@ -1159,13 +1134,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti [updateSettings], ); - const handleUseLegacyTerminalRendererChange = useCallback( - (useLegacyTerminalRenderer: boolean) => { - void updateSettings({ useLegacyTerminalRenderer }); - }, - [updateSettings], - ); - const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -1390,8 +1358,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti case "diagnostics": return ( (null); const isScreenFocused = useIsFocused(); + const contextBridge = useWorkspaceSheetEnvironment(); ensurePanelsRegistered(); const registration = getPanelRegistration(tab.target.kind); if (!registration) { @@ -236,7 +238,7 @@ function SheetTabSheet({ return ( "file-tab"), - focusWorkspaceTab: vi.fn(), - }; -} - -describe("openWorkspaceFileFromExplorer", () => { - it("closes the phone overlay after opening a file", () => { - const input = createInput(true); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).toHaveBeenCalledOnce(); +const workspaceKey = "server:workspace"; +function openFile(filePath: string, persistenceKey: string | null = workspaceKey) { + openWorkspaceFileFromExplorer({ + filePath, + persistenceKey, + openWorkspaceTabInFocusedPane: (key, target, placement) => + useWorkspaceLayoutStore + .getState() + .openTab({ workspaceKey: key, target, placement, intent: "reveal" }), + revealWorkspaceTab, }); - - it("keeps the tablet dock open after opening a file", () => { - const input = createInput(false); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).not.toHaveBeenCalled(); - expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce(); - expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab"); +} +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "compact", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); + usePanelStore.getState().showMobileFileExplorer(); +}); +describe("file search destination", () => { + it("preserves Explorer under a new file sheet", () => { + openFile("new.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]).toHaveLength(1); + }); + it.each([null, workspaceKey])("preserves context when no file opens (%s)", (key) => { + openFile("", key); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + }); + it("reveals an existing main tab", () => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("split-panes"); + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + it("reveals the existing Explorer tab from the left sidebar", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + usePanelStore.getState().showMobileAgentList(); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); }); }); diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts index 0d990f3557..db96fd1204 100644 --- a/packages/app/src/screens/workspace/workspace-file-open-command.ts +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -11,20 +11,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; interface OpenWorkspaceFileFromExplorerInput { filePath: string; persistenceKey: string | null; - closeExplorerAfterOpen: boolean; - showMobileAgent: () => void; openWorkspaceTabInFocusedPane: ( workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement, ) => string | null; - focusWorkspaceTab: (workspaceKey: string, tabId: string) => void; + revealWorkspaceTab: (workspaceKey: string, tabId: string) => void; } export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void { - if (input.closeExplorerAfterOpen) { - input.showMobileAgent(); - } if (!input.persistenceKey) { return; } @@ -38,6 +33,6 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor FOCUSED_PANE_PLACEMENT, ); if (tabId) { - input.focusWorkspaceTab(input.persistenceKey, tabId); + input.revealWorkspaceTab(input.persistenceKey, tabId); } } diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 0ef496a94b..21476a85a4 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -27,7 +27,7 @@ export interface BuildWorkspacePaneContentModelInput { normalizedServerId: string; normalizedWorkspaceId: string; host: PaneHost; - fileNavigationRevision?: number; + panelRevision?: number; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onOpenPreferredTarget: ( target: WorkspaceTabDescriptor["target"], @@ -46,7 +46,7 @@ export function buildWorkspacePaneContentModel({ normalizedServerId, normalizedWorkspaceId, host, - fileNavigationRevision, + panelRevision, onOpenTab, onOpenPreferredTarget, onOpenTargetToSide, @@ -69,7 +69,7 @@ export function buildWorkspacePaneContentModel({ tabId: tab.tabId, target: tab.target, state: tab.state, - fileNavigationRevision, + panelRevision, openTab: onOpenTab, openPreferredTarget: onOpenPreferredTarget, openTargetToSide: onOpenTargetToSide, @@ -112,7 +112,7 @@ export function WorkspacePaneContent({ tabId: paneContextValue.tabId, target: paneContextValue.target, state: paneContextValue.state, - fileNavigationRevision: paneContextValue.fileNavigationRevision, + panelRevision: paneContextValue.panelRevision, openTab, openPreferredTarget, openTargetToSide: paneContextValue.openTargetToSide ? openTargetToSide : undefined, @@ -130,7 +130,7 @@ export function WorkspacePaneContent({ openPreferredTarget, openTargetToSide, paneContextValue.serverId, - paneContextValue.fileNavigationRevision, + paneContextValue.panelRevision, paneContextValue.tabId, paneContextValue.target, paneContextValue.state, diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 813cef01c0..1bb74ef4ea 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -5,6 +5,8 @@ import { resolveWorkspaceRouteState } from "./workspace-route-state"; function createWorkspaceDescriptor(): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8c1adf7520..bcc2593611 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -786,10 +786,11 @@ function WorkspacePanelSurface({ children: ReactNode; tabIds: readonly string[]; }) { + const isCompact = useIsCompactFormFactor(); return ( - + {children} @@ -1130,22 +1131,15 @@ function WorkspaceScreenContent({ ) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }), [openTab], ); - // File targets stay identity-stable so the same path reuses its tab. Keep navigation - // requests separate so clicking an unchanged path:line can still recenter the pane. - const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState< - Record - >({}); - const requestFileNavigation = useCallback((tabId: string) => { - setFileNavigationRevisionByTabId((current) => ({ - ...current, - [tabId]: (current[tabId] ?? 0) + 1, - })); - }, []); + const panelRevisions = useWorkspaceLayoutStore((state) => + persistenceKey ? state.panelRevisionByWorkspace[persistenceKey] : undefined, + ); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); const pendingOpenTarget = usePendingWorkspaceOpen({ + onOpened: revealWorkspaceTab, workspaceKey: persistenceKey, hasHydratedWorkspaces, workspaceExists: workspaceDescriptor !== null, @@ -1669,9 +1663,6 @@ function WorkspaceScreenContent({ if (!normalizedLocation) { return; } - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -1680,19 +1671,10 @@ function WorkspaceScreenContent({ ? revealWorkspaceChildTab(persistenceKey, target, parentTabId, FOCUSED_PANE_PLACEMENT) : openWorkspaceTabFocused(persistenceKey, target, FOCUSED_PANE_PLACEMENT); if (tabId) { - requestFileNavigation(tabId); navigateToTabId(tabId); } }, - [ - isMobile, - navigateToTabId, - openWorkspaceTabFocused, - revealWorkspaceChildTab, - persistenceKey, - requestFileNavigation, - showMobileAgent, - ], + [navigateToTabId, openWorkspaceTabFocused, revealWorkspaceChildTab, persistenceKey], ); const handleOpenPreferredAssistantFile = useCallback( @@ -1705,18 +1687,15 @@ function WorkspaceScreenContent({ return; } - const tabId = openPreferredWorkspaceTarget({ + openPreferredWorkspaceTarget({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", preferences: openInSidePane, parentTabId: input.parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } }, - [openInSidePane, persistenceKey, requestFileNavigation], + [openInSidePane, persistenceKey], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1736,14 +1715,11 @@ function WorkspaceScreenContent({ if (request.disposition === "side") { const location = normalizeWorkspaceFileLocation(request.location); if (!location || !persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } return; } if (request.disposition === "preferred") { @@ -2947,7 +2923,7 @@ function WorkspaceScreenContent({ host: input.host ?? (input.paneId !== null && input.paneId === explorerSidebarPaneId ? "explorer" : "main"), - fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, + panelRevision: panelRevisions?.[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -2964,7 +2940,7 @@ function WorkspaceScreenContent({ }, onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; - const tabId = openPreferredWorkspacePreview({ + openPreferredWorkspacePreview({ workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -2974,7 +2950,6 @@ function WorkspaceScreenContent({ source, preferences: openInSidePane, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); }, onOpenTargetToSide: canRenderDesktopPaneSplits && @@ -2982,12 +2957,11 @@ function WorkspaceScreenContent({ input.paneId === explorerSidebarPaneId ? (target) => { if (!persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target, parentTabId: input.tab.tabId, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); } : undefined, onCloseCurrentTab: () => { @@ -3016,7 +2990,7 @@ function WorkspaceScreenContent({ }), [ handleCloseTabById, - fileNavigationRevisionByTabId, + panelRevisions, handleOpenWorkspaceFileFromPane, navigateToTabId, normalizedServerId, @@ -3024,7 +2998,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - requestFileNavigation, revealWorkspaceChildTab, persistenceKey, replaceWorkspaceTabTarget, @@ -3552,7 +3525,7 @@ function WorkspaceScreenContent({ composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), }); - // Compact sheets use the shared workspace scope, above both center and Explorer. + // Compact sheets escape to the app overlay host, above both sidebars. const workspaceCenterColumn = isMobile ? ( { + const content = editing(cache(files(children))); + return ( + + + + {serverId ? ( + {content} + ) : ( + content + )} + + + + ); + }, + [editing, cache, files, active, serverId, navigation, route], + ); +} diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 7e0b10e929..a24e49a672 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -14,6 +14,8 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; function createWorkspaceDescriptor(input: Partial = {}): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "/repo/main", projectId: "remote:github.com/getpaseo/paseo", projectDisplayName: "getpaseo/paseo", diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 8b499429f0..fb73376b8b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,6 +1,8 @@ +import type { Theme } from "@/styles/theme"; +import { StatusIcon } from "@/components/status-icon"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { Check, CircleAlert } from "lucide-react-native"; +import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; @@ -9,14 +11,6 @@ import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-regist import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { getStatusDotColor } from "@/utils/status-dot-color"; -import { StatusRing } from "@/components/status-ring"; -import { getStatusRingOffset } from "@/components/status-ring/geometry"; -import { - STATUS_INDICATOR_ALERT_SIZE, - STATUS_INDICATOR_DOT_SIZE, -} from "@/utils/status-indicator-geometry"; -import type { Theme } from "@/styles/theme"; import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes"; export interface WorkspaceTabPresentation { @@ -32,9 +26,6 @@ export interface WorkspaceTabPresentation { statusBucket: SidebarStateBucket | null; } -const DEFAULT_STATUS_DOT_OFFSET = -2; -const STATUS_ALERT_OFFSET = -3; - interface WorkspaceTabPresentationResolverProps { tab: WorkspaceTabDescriptor; serverId: string; @@ -129,13 +120,7 @@ interface WorkspaceTabIconProps { } const ThemedCheckIcon = withUnistyles(Check); -const ThemedCircleAlert = withUnistyles(CircleAlert); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const needsInputAlertMapping = (theme: Theme) => ({ - color: theme.colors.surface0, - fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); - export function WorkspaceTabIcon({ presentation, active = false, @@ -144,48 +129,17 @@ export function WorkspaceTabIcon({ statusDotBorderColor, backdrop, }: WorkspaceTabIconProps): ReactElement { - const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; - const bucket = presentation.statusBucket; - const isRunning = bucket === "running"; - let statusDotColor: string | undefined; - if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; - else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; - const showNeedsInputAlert = bucket === "needs_input"; const Icon = presentation.icon; - const agentIconWrapperStyle = useMemo( - () => [styles.agentIconWrapper, { width: size, height: size }], - [size], - ); - const statusDotStyle = useMemo( - () => [ - styles.statusDot, - { - backgroundColor: statusDotColor, - borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, - }, - ], - [statusDotColor, statusDotBorderColor], - ); - + const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; return ( - + - {isRunning ? ( - - - - ) : null} - {statusDotColor ? : null} - {showNeedsInputAlert ? ( - - - - ) : null} - + ); } @@ -262,43 +216,6 @@ export function WorkspaceTabOptionRow({ } const styles = StyleSheet.create((theme) => ({ - agentIconWrapper: { - position: "relative", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - statusDot: { - position: "absolute", - right: DEFAULT_STATUS_DOT_OFFSET, - bottom: DEFAULT_STATUS_DOT_OFFSET, - width: STATUS_INDICATOR_DOT_SIZE, - height: STATUS_INDICATOR_DOT_SIZE, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - }, - statusRing: { - position: "absolute", - right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - }, - statusDotBorderDefault: { - borderColor: theme.colors.surface0, - }, - statusAlertOverlay: { - position: "absolute", - right: STATUS_ALERT_OFFSET, - bottom: STATUS_ALERT_OFFSET, - }, - statusDotFailed: { - color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, - }, - statusDotRunning: { - color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, - }, - statusDotAttention: { - color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, - }, iconActive: { color: theme.colors.foreground, }, diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 0404ea34ed..16f3a23f0b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -1,3 +1,5 @@ +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; +import { usePanelStore } from "@/stores/panel-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useLocalSearchParams, usePathname } from "expo-router"; import { useEffect, useSyncExternalStore } from "react"; @@ -34,6 +36,12 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( function navigateDeps(): NavigateToWorkspaceDeps { return { + revealTab: revealWorkspaceTab, + revealWorkspace: () => { + if (useWorkspaceLayoutStore.getState().layoutMode === "compact") { + usePanelStore.getState().showMobileAgent(); + } + }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 055049baee..a3bb69821a 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -22,7 +22,14 @@ function createFakeDeps(overrides: Partial = {}) { const remembered: ActiveWorkspaceSelection[] = []; const openedTabs: RecordedTab[] = []; const deferredTabs: unknown[] = []; + const revealed: Array = []; const deps: NavigateToWorkspaceDeps = { + revealTab: (_key, tabId) => { + revealed.push(tabId); + }, + revealWorkspace: () => { + revealed.push(null); + }, deferOpenTab: (input) => { deferredTabs.push(input); return null; @@ -38,7 +45,7 @@ function createFakeDeps(overrides: Partial = {}) { navigateToRoute: (route) => navigations.push(route), ...overrides, }; - return { deps, navigations, remembered, openedTabs, deferredTabs }; + return { deps, navigations, remembered, openedTabs, deferredTabs, revealed }; } function createLastSelectionDeps( @@ -65,6 +72,24 @@ function createLastSelectionDeps( } describe("workspace navigation", () => { + it("reveals even a current workspace without agents", () => { + const { deps, revealed } = createFakeDeps(); + navigateToWorkspace({ serverId: "server", workspaceId: "workspace" }, deps); + expect(revealed).toEqual([null]); + }); + + it("reveals an explicit target after opening instead of forcing center", () => { + const workspace = { id: "workspace" } as WorkspaceDescriptor; + const { deps, revealed } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + }); + navigateToWorkspace( + { serverId: "server", workspaceId: "workspace", target: { kind: "agent", agentId: "root" } }, + deps, + ); + expect(revealed).toEqual(["root"]); + }); + it("reports when no last workspace is known", () => { const { deps } = createLastSelectionDeps(null); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f234b1c2a6..d856a9bb36 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -38,6 +38,8 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { deferOpenTab: (input: OpenWorkspaceTabInput) => null; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; + revealTab: (workspaceKey: string, tabId: string | null) => void; + revealWorkspace: () => void; } export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { @@ -93,8 +95,13 @@ export function navigateToWorkspace( workspaceId: input.workspaceId, }); const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated(); + const openTab: NavigateToWorkspaceDeps["openTab"] = (request) => { + if (shouldDeferOpen) return deps.deferOpenTab(request); + const tabId = deps.openTab(request); + deps.revealTab(request.workspaceKey, tabId); + return tabId; + }; if (input.target) { - const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab; prepareWorkspaceTab({ ...input, target: input.target }, { openTab }); } else { const workspaceAgents = resolvedWorkspaceId @@ -104,11 +111,13 @@ export function navigateToWorkspace( : []; const attentionAgentId = pickAttentionAgent(workspaceAgents); if (attentionAgentId && resolvedWorkspaceId) { - deps.openTab({ + openTab({ workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`, target: { kind: "agent", agentId: attentionAgentId }, intent: "reveal", }); + } else { + deps.revealWorkspace(); } } diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 89978811ec..48c5594f21 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -15,7 +16,6 @@ import { selectWorkspaceExists, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, workspaceEqualityFns, type WorkspaceStructure, @@ -99,10 +99,13 @@ export function useWorkspaceDirectory( ); } -export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { +export function useWorkspaceStructure( + serverIds: string[], + workspaceSort?: WorkspaceSort, +): WorkspaceStructure { const selectProjects = useMemo( - () => createWorkspaceStructureProjectsSelector(serverIds), - [serverIds], + () => createWorkspaceStructureProjectsSelector(serverIds, workspaceSort), + [serverIds, workspaceSort], ); const projects = useStoreWithEqualityFn( useSessionStore, @@ -114,20 +117,13 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); - const workspaceOrderByScope = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => selectWorkspaceOrderByScope(state), - workspaceEqualityFns.deep, - ); - return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, - workspaceOrderByScope, }), - [projectOrder, projects, workspaceOrderByScope], + [projectOrder, projects], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a79d30fe0d..a9c9d07772 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -14,7 +14,6 @@ import { selectWorkspaceDirectory, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, selectWorkspaceStructureProjects, workspaceEqualityFns, @@ -36,6 +35,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -118,7 +119,6 @@ function trackSelector( function emptySidebarOrder(): SidebarOrderSnapshot { return { projectOrder: [], - workspaceOrderByProject: {}, }; } @@ -364,7 +364,6 @@ describe("workspace structure composition", () => { return composeWorkspaceStructure({ projects: selectWorkspaceStructureProjects(useSessionStore.getState(), [serverId]), projectOrder: selectProjectOrder(sidebar), - workspaceOrderByScope: selectWorkspaceOrderByScope(sidebar), }); } @@ -619,3 +618,25 @@ describe("selectWorkspaceStatusesForBadges", () => { tracked.stop(); }); }); + +it("a prompt changes only its project's structure and output preserves both project identities", () => { + const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") }; + const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") }; + const c = { ...createWorkspace({ id: "c", projectId: "q" }), createdAt: new Date("2026-02-01") }; + const projects = new Map([a, c].map((w) => [w.projectId, projectDescriptorFromTestWorkspace(w)])); + const select = createWorkspaceStructureProjectsSelector([SERVER_ID], "lastPrompt"); + const state = (workspaces: WorkspaceDescriptor[]) => ({ + sessions: { [SERVER_ID]: { projects, workspaces: new Map(workspaces.map((w) => [w.id, w])) } }, + }); + const before = select(state([a, b, c])); + const prompted = { ...a, lastUserMessageAt: new Date("2026-03-01") }; + const after = select(state([prompted, b, c])); + expect(after.find((p) => p.viewKey === "p")?.workspaceKeys).toEqual([ + `${SERVER_ID}:a`, + `${SERVER_ID}:b`, + ]); + expect(after.find((p) => p.viewKey === "q")).toBe(before.find((p) => p.viewKey === "q")); + const output = select(state([{ ...prompted, status: "running" }, b, c])); + expect(output[0]).toBe(after[0]); + expect(output[1]).toBe(after[1]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 019b9d781b..6c1100edd6 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -1,6 +1,7 @@ import equal from "fast-deep-equal"; import { buildWorkspaceStructureProjects, + type WorkspaceSort, type WorkspaceStructure, type WorkspaceStructureProject, } from "@/projects/workspace-structure"; @@ -25,7 +26,6 @@ export interface SessionsSnapshot { export interface SidebarOrderSnapshot { projectOrder: string[]; - workspaceOrderByProject: Record; } const EMPTY_WORKSPACE_KEYS: string[] = []; @@ -154,6 +154,7 @@ export function selectWorkspaceDirectoryServerIds( export function selectWorkspaceStructureProjects( state: SessionsSnapshot, serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): WorkspaceStructureProject[] { const sessions: Array<{ serverId: string; @@ -179,11 +180,12 @@ export function selectWorkspaceStructureProjects( return EMPTY_WORKSPACE_STRUCTURE.projects; } - return buildWorkspaceStructureProjects({ sessions }); + return buildWorkspaceStructureProjects({ sessions, workspaceSort }); } export function createWorkspaceStructureProjectsSelector( serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): (state: SessionsSnapshot) => WorkspaceStructureProject[] { let previousInputs: Array<{ workspaces: Map | undefined; @@ -209,7 +211,12 @@ export function createWorkspaceStructureProjectsSelector( } previousInputs = inputs; - previousProjects = selectWorkspaceStructureProjects(state, serverIds); + const next = selectWorkspaceStructureProjects(state, serverIds, workspaceSort); + const previousByKey = new Map(previousProjects?.map((project) => [project.viewKey, project])); + previousProjects = next.map((project) => { + const previous = previousByKey.get(project.viewKey); + return previous && equal(previous, project) ? previous : project; + }); return previousProjects; }; } @@ -244,31 +251,16 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { return state.projectOrder ?? EMPTY_WORKSPACE_KEYS; } -export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record { - return state.workspaceOrderByProject ?? {}; -} - export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; - workspaceOrderByScope: Record; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; } const orderedProjects = applyStoredOrdering({ - items: input.projects.map((project) => { - const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS; - return { - ...project, - workspaceKeys: applyStoredOrdering({ - items: project.workspaceKeys, - storedOrder: workspaceOrder, - getKey: (workspaceKey) => workspaceKey, - }), - }; - }), + items: input.projects, storedOrder: input.projectOrder, getKey: (project) => project.viewKey, }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index c53eae4c49..86ea81afc5 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -64,6 +64,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dfeeadb7b0..aed06eef11 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -103,6 +103,8 @@ export interface Agent { } export interface WorkspaceDescriptor { + createdAt: Date | null; + lastUserMessageAt: Date | null; id: string; projectId: string; projectDisplayName: string; @@ -139,6 +141,9 @@ export function normalizeWorkspaceDescriptor( ? new Date(statusEnteredAtRaw) : null; return { + // COMPAT(workspaceSortTimes): added in v0.13.0, remove after 2027-04-22. + createdAt: payload.createdAt ? new Date(payload.createdAt) : null, + lastUserMessageAt: payload.lastUserMessageAt ? new Date(payload.lastUserMessageAt) : null, id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, projectId: payload.projectId, projectDisplayName: payload.projectDisplayName, diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index a68ca206f9..b34fb02240 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("prefixes legacy per-server workspace order with the source server id", () => { + it("discards legacy workspace ordering while retaining project ordering", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,15 +21,12 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], - workspaceOrderByProject: { - "project-a": ["host-a:main", "host-a:feature", "host-b:main"], - }, }); }); - it("normalizes pinned workspace order", () => { + it("deduplicates pinned workspace order without rewriting identities", () => { const migrated = migrateSidebarOrderState({ - pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"], + pinnedWorkspaceOrder: ["host-a:one", "host-a:one", "", "host-b:two"], }); expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); @@ -46,7 +43,6 @@ describe("sidebar order keys that end in whitespace", () => { useSidebarOrderStore.setState({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, }); }); @@ -56,17 +52,6 @@ describe("sidebar order keys that end in whitespace", () => { expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]); }); - it("scopes a workspace order under the project key the sidebar indexes by", () => { - useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]); - - // use-sidebar-workspaces-list reads workspaceOrderByProject by view key - // directly, so the scope has to be stored under that same key. - expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([ - "srv-1:main", - ]); - expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]); - }); - it("settles the sidebar order reconcile instead of rewriting it forever", () => { const projects: SidebarProjectEntry[] = [ { @@ -82,7 +67,6 @@ describe("sidebar order keys that end in whitespace", () => { computeSidebarOrderUpdates({ projects, persistedProjectOrder: useSidebarOrderStore.getState().projectOrder, - getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key), }); // First pass adds the newly visible project. @@ -95,11 +79,31 @@ describe("sidebar order keys that end in whitespace", () => { expect(runPass().projectOrder).toBeNull(); }); - it("still trims keys when migrating persisted state", () => { + it("still trims legacy keys when migrating per-host persisted state", () => { const migrated = migrateSidebarOrderState({ - projectOrder: [" host-a:one ", "host-a:one"], + projectOrderByServerId: { host: [" host-a:one ", "host-a:one"] }, }); expect(migrated.projectOrder).toEqual(["host-a:one"]); }); }); + +it("preserves project and pinned order while discarding ordinary workspace order", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }), + ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); +}); + +it("preserves whitespace in current project and pin identities during upgrade", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }), + ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 10831480f8..54f27e714f 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; - getWorkspaceOrder: (projectViewKey: string) => string[]; - setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -28,16 +25,12 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), + // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); -interface SidebarWorkspaceOrderScope { - serverId: string; - projectViewKey: string; -} - // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -78,46 +71,17 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } -function normalizeWorkspaceOrderByProject( - workspaceOrderByProject: Record | undefined, -): Record { - const normalized: Record = {}; - for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { - const scope = projectViewKey.trim(); - if (!scope) continue; - normalized[scope] = normalizeKeys(order); - } - return normalized; -} - -function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { - const separatorIndex = scopeKey.indexOf("::"); - if (separatorIndex < 0) return null; - const serverId = scopeKey.slice(0, separatorIndex).trim(); - const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); - if (!serverId || !projectViewKey) return null; - return { serverId, projectViewKey }; -} - -function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { - const workspaceKey = rawWorkspaceKey.trim(); - if (!workspaceKey) return null; - const serverPrefix = `${serverId}:`; - return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; -} - export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; + return { projectOrder: [], pinnedWorkspaceOrder: [] }; } const state: SidebarOrderPersistedState = result.data; - const projectOrder = normalizeKeys(state.projectOrder ?? []); + const projectOrder = dedupeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); for (const keys of Object.values(state.projectOrderByServerId ?? {})) { for (const key of normalizeKeys(keys)) { @@ -127,26 +91,9 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } - const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); - for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { - const scope = extractWorkspaceOrderScope(scopeKey); - if (!scope) continue; - const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; - const merged = [...existing]; - const seen = new Set(merged); - for (const key of order) { - const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); - if (!workspaceKey || seen.has(workspaceKey)) continue; - seen.add(workspaceKey); - merged.push(workspaceKey); - } - workspaceOrderByProject[scope.projectViewKey] = merged; - } - return { projectOrder, - pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []), - workspaceOrderByProject, + pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), }; } @@ -155,7 +102,6 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -164,19 +110,6 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, - getWorkspaceOrder: (projectViewKey) => { - if (!projectViewKey.trim()) return []; - return get().workspaceOrderByProject[projectViewKey] ?? []; - }, - setWorkspaceOrder: (projectViewKey, keys) => { - if (!projectViewKey.trim()) return; - set((state) => ({ - workspaceOrderByProject: { - ...state.workspaceOrderByProject, - [projectViewKey]: dedupeKeys(keys), - }, - })); - }, }), { name: "sidebar-project-workspace-order", @@ -184,9 +117,8 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, - workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 1, + version: 2, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index e21c8f8a15..a9e44e146b 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record): MemoryStor describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -85,6 +86,7 @@ describe("sidebar view store", () => { }, }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: [], @@ -100,6 +102,7 @@ describe("sidebar view store", () => { hostFilter: "host-a", }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a"], @@ -115,6 +118,7 @@ describe("sidebar view store", () => { hostFilters: ["host-a", "host-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a", "host-b"], @@ -226,6 +230,7 @@ describe("sidebar view store", () => { projectFilters: ["project-a", "project-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: ["host-a"], @@ -236,6 +241,7 @@ describe("sidebar view store", () => { it("never keeps project filters from state the schema rejects", () => { expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -287,3 +293,10 @@ describe("sidebar view store", () => { expect(storage.reads).toEqual(["sidebar-view"]); }); }); + +it("defaults to Created and persists the Last prompt choice across migration", () => { + expect(migrateSidebarViewState({}).workspaceSort).toBe("created"); + expect( + migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), + ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 0e12c304a8..229e95f96b 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; @@ -9,7 +10,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 7; +const SIDEBAR_VIEW_STORE_VERSION = 8; /** * The key standing for "this workspace carries no labels at all". @@ -47,6 +48,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] { interface SidebarViewStoreState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; @@ -64,6 +66,7 @@ interface SidebarViewStoreState { labelFilter: SidebarLabelFilter; toggleBackground: () => void; setGroupMode: (mode: SidebarGroupMode) => void; + setWorkspaceSort: (sort: WorkspaceSort) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; toggleProjectFilter: (viewKey: string) => void; @@ -76,6 +79,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; hostFilters: string[]; projectFilters: string[]; @@ -87,6 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ + workspaceSort: z.enum(["created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), @@ -128,6 +133,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -140,6 +146,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: legacyGroupMode, + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -149,6 +156,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: state.showBackground ?? false, groupMode: state.groupMode === "status" ? "status" : "project", + workspaceSort: state.workspaceSort ?? "created", hostFilters: readHostFilters(state), projectFilters: state.projectFilters ?? [], labelFilter: state.labelFilter @@ -187,11 +195,13 @@ export const useSidebarViewStore = create()( (set) => ({ showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })), setGroupMode: (mode) => set({ groupMode: mode }), + setWorkspaceSort: (workspaceSort) => set({ workspaceSort }), toggleHostFilter: (serverId) => set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), clearHostFilters: () => set({ hostFilters: [] }), @@ -239,6 +249,7 @@ export const useSidebarViewStore = create()( partialize: (state) => ({ showBackground: state.showBackground ?? false, groupMode: state.groupMode, + workspaceSort: state.workspaceSort, hostFilters: state.hostFilters, projectFilters: state.projectFilters, labelFilter: state.labelFilter, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index cfac2f999a..431d550823 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -79,6 +79,39 @@ function createDeterministicWorkspaceLayoutIds() { const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +it("bumps panel params revision when the same tab is opened again", () => { + const storage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }; + const store = createWorkspaceLayoutStore(workspaceLayoutIds, storage); + const workspaceKey = "server-1:workspace-params"; + const targets = [ + { kind: "file" as const, path: "notes.md", lineStart: 3 }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "workspace", + context: "workspace" as const, + }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "agent", + context: "agent" as const, + agentId: "agent-1", + }, + ]; + for (const target of targets) { + const first = store.getState().openTab({ workspaceKey, target, intent: "reveal" })!; + const revision = store.getState().panelRevisionByWorkspace[workspaceKey][first]; + const second = store.getState().openTab({ workspaceKey, target, intent: "reveal" }); + expect(second).toBe(first); + expect(store.getState().panelRevisionByWorkspace[workspaceKey][first]).toBe(revision + 1); + } +}); + it("observes open chats across unmounted workspaces until their tabs close", () => { const store = createWorkspaceLayoutStore(workspaceLayoutIds); store.setState({ layoutByWorkspace: {} }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 4091b7d53c..1c0b7a5037 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -128,6 +128,7 @@ export type WorkspaceTabDestination = | { kind: "sheet"; tabId: string }; interface WorkspaceLayoutStore { + panelRevisionByWorkspace: Record>; /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; pendingOpenByWorkspace: Record; @@ -997,6 +998,22 @@ function createPersistedStateProjection() { }; } +function advancePanelRevision( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, + target: WorkspaceTabTarget, +) { + if (target.kind !== "file" && target.kind !== "plugin") return {}; + const revisions = state.panelRevisionByWorkspace[workspaceKey] ?? {}; + return { + panelRevisionByWorkspace: { + ...state.panelRevisionByWorkspace, + [workspaceKey]: { ...revisions, [tabId]: (revisions[tabId] ?? 0) + 1 }, + }, + }; +} + export function createWorkspaceLayoutStore( ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, storage: StateStorage = AsyncStorage, @@ -1023,6 +1040,7 @@ export function createWorkspaceLayoutStore( } return tabId; }, + panelRevisionByWorkspace: {}, pendingOpenByWorkspace: {}, deferOpenTab: (input) => { set((state) => ({ @@ -1095,7 +1113,15 @@ export function createWorkspaceLayoutStore( ? [existing] : []; }); - set((state) => withSheets(state, normalizedWorkspaceKey, next)); + set((state) => ({ + ...withSheets(state, normalizedWorkspaceKey, next), + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + sheet.tab.tabId, + normalizedTarget, + ), + })); return sheet.tab.tabId; } const placement = getOpenTabPlacement( @@ -1151,6 +1177,7 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), // A tab brought to the front dismisses the sheet over it. ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: @@ -1530,7 +1557,15 @@ export function createWorkspaceLayoutStore( })), }; if (policy) { - set({ sheetsByWorkspace: next.sheetsByWorkspace }); + set((state) => ({ + sheetsByWorkspace: next.sheetsByWorkspace, + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + normalizedTabId, + normalizedTarget, + ), + })); return normalizedTabId; } // A target sheets do not host becomes the tab it would have opened as. @@ -1548,6 +1583,7 @@ export function createWorkspaceLayoutStore( if (!result) return null; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -2380,12 +2416,14 @@ interface PendingWorkspaceOpenReadiness { workspaceKey: string | null; hasHydratedWorkspaces: boolean; workspaceExists: boolean; + onOpened: (workspaceKey: string, tabId: string | null) => void; } export function usePendingWorkspaceOpen({ workspaceKey, hasHydratedWorkspaces, workspaceExists, + onOpened, }: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null { const workspaceReady = hasHydratedWorkspaces && workspaceExists; const pending = useWorkspaceLayoutStore((state) => @@ -2394,8 +2432,8 @@ export function usePendingWorkspaceOpen({ const layoutHydrated = useWorkspaceLayoutStoreHydrated(); useLayoutEffect(() => { if (workspaceKey && workspaceReady && layoutHydrated && pending) { - useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey); + onOpened(workspaceKey, useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey)); } - }, [workspaceKey, workspaceReady, layoutHydrated, pending]); + }, [workspaceKey, workspaceReady, layoutHydrated, pending, onOpened]); return pending ? pending.target : null; } diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index 01147eb7b9..5a439f316b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,22 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { DesktopHostBridge } from "@/desktop/host"; -import { - extractTerminalDropPaths, - isTerminalDragLeaveOutside, - isTerminalFileDrag, - prepareDroppedPathForTerminal, - prepareDroppedPathsForTerminal, -} from "./terminal-file-drop"; +import { routeTerminalDrop } from "./terminal-file-drop"; -function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer { - return { - types: input.types ?? [], - files: input.files ?? [], - } as unknown as DataTransfer; -} - -function fakeFile(input: { name: string; legacyPath?: string }): File { - const file = { name: input.name } as unknown as File; +function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File { + const file = { name: input.name, type: input.type ?? "" } as unknown as File; if (input.legacyPath !== undefined) { Object.defineProperty(file, "path", { configurable: true, @@ -26,45 +13,34 @@ function fakeFile(input: { name: string; legacyPath?: string }): File { return file; } -function makeNode(children: readonly EventTarget[] = []): EventTarget { - return { - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - contains: (other: EventTarget | null) => other !== null && children.includes(other), - } as unknown as EventTarget; -} - -describe("terminal file drop", () => { - it("detects file drags", () => { - expect(isTerminalFileDrag(dataTransfer({ types: ["Files"] }))).toBe(true); - expect(isTerminalFileDrag(dataTransfer({ types: ["text/plain"] }))).toBe(false); - expect(isTerminalFileDrag(null)).toBe(false); - }); - - it("keeps drag highlight active when moving between terminal children", () => { - const child = makeNode(); - const outside = makeNode(); - const root = makeNode([child]); - - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false); - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true); - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true); - expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true); - }); - - it("extracts paths through Electron webUtils", () => { - const file = fakeFile({ name: "photo.png" }); - const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png"); - const bridge: DesktopHostBridge = { webUtils: { getPathForFile } }; +describe("terminal file drop routing", () => { + const notes = fakeFile({ name: "notes.txt", type: "text/plain" }); + const electron: DesktopHostBridge = { + webUtils: { getPathForFile: (file) => `/Users/me/Desktop/${file.name}` }, + }; + it("pastes the file's own path when the host is the client's machine", () => { expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), - ).toEqual(["/Users/me/Desktop/photo.png"]); - expect(getPathForFile).toHaveBeenCalledWith(file); + routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: electron }), + ).toEqual({ kind: "path", path: "/Users/me/Desktop/notes.txt" }); }); - it("falls back to legacy Electron file paths", () => { + it("uploads the same file when the host is another machine", () => { + expect( + routeTerminalDrop( + { kind: "web-file", file: notes }, + { hostIsLocal: false, bridge: electron }, + ), + ).toMatchObject({ kind: "upload" }); + }); + + it("uploads browser files, which have no path on any machine", () => { + expect( + routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: null }), + ).toMatchObject({ kind: "upload" }); + }); + + it("falls back to legacy Electron file paths on a local host", () => { const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" }); const bridge: DesktopHostBridge = { webUtils: { @@ -74,45 +50,21 @@ describe("terminal file drop", () => { }, }; - expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), - ).toEqual(["/tmp/legacy-photo.png"]); + expect(routeTerminalDrop({ kind: "web-file", file }, { hostIsLocal: true, bridge })).toEqual({ + kind: "path", + path: "/tmp/legacy-photo.png", + }); }); - it("drops browser files that have no filesystem path", () => { - const file = fakeFile({ name: "photo.png" }); + it("routes desktop paths by host locality too", () => { + const item = { kind: "desktop-path", path: "/tmp/report.pdf" } as const; - expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null), - ).toEqual([]); - }); - - it("prepares POSIX paths with conservative escaping", () => { - const bridge: DesktopHostBridge = { platform: "darwin" }; - - expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'"); - expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe( - "'/tmp/a(touch bad).png'", - ); - expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'"); - }); - - it("prepares Windows paths with space quoting", () => { - const bridge: DesktopHostBridge = { platform: "win32" }; - - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe( - "C:\\Users\\me\\photo.png", - ); - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe( - '"C:\\Users\\me\\photo one.png"', - ); - }); - - it("joins multiple dropped paths for one terminal input", () => { - const bridge: DesktopHostBridge = { platform: "darwin" }; - - expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe( - "'/tmp/a.png' '/tmp/b c.png'", - ); + expect(routeTerminalDrop(item, { hostIsLocal: true, bridge: null })).toEqual({ + kind: "path", + path: "/tmp/report.pdf", + }); + expect(routeTerminalDrop(item, { hostIsLocal: false, bridge: null })).toMatchObject({ + kind: "upload", + }); }); }); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index 44dab5b144..18221271e5 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,13 +1,16 @@ +import { getMimeTypeFromPath } from "@/attachments/file-types"; +import { readDesktopFileBytes } from "@/attachments/selected-file"; +import type { DroppedItem } from "@/components/file-drop/types"; import type { DesktopHostBridge } from "@/desktop/host"; - -const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; +import { readTerminalFile, type TerminalFile } from "../file-paste"; function getLegacyFilePath(file: File): string | null { const path = Reflect.get(file, "path"); return typeof path === "string" && path.length > 0 ? path : null; } -function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null { +/** Where the client's own filesystem holds a dropped file; null for files that are only bytes. */ +function getClientFilePath(file: File, bridge: DesktopHostBridge | null): string | null { const getPathForFile = bridge?.webUtils?.getPathForFile; if (typeof getPathForFile === "function") { try { @@ -22,87 +25,39 @@ function getFilePath(file: File, bridge: DesktopHostBridge | null): string | nul return getLegacyFilePath(file); } -export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean { - return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files")); +function fileNameFromPath(path: string): string { + return path.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? path; } -interface ContainsTarget { - contains: (other: EventTarget | null) => boolean; +async function readDesktopFile(path: string): Promise { + return { + fileName: fileNameFromPath(path), + mimeType: getMimeTypeFromPath(path), + bytes: await readDesktopFileBytes(path), + }; } -function asContainsTarget(value: EventTarget | null): ContainsTarget | null { - if (!value || typeof (value as Partial).contains !== "function") { - return null; +/** How a dropped item reaches the terminal. */ +export type TerminalDropDelivery = + | { kind: "path"; path: string } + | { kind: "upload"; read: () => Promise }; + +/** + * A dropped file pastes its own path only when the host is the machine the file + * sits on. Anywhere else the host cannot read the client's disk, so the bytes are + * uploaded and the host path is pasted instead. The file's type plays no part. + */ +export function routeTerminalDrop( + item: DroppedItem, + input: { hostIsLocal: boolean; bridge: DesktopHostBridge | null }, +): TerminalDropDelivery { + if (item.kind === "desktop-path") { + return input.hostIsLocal + ? { kind: "path", path: item.path } + : { kind: "upload", read: () => readDesktopFile(item.path) }; } - return value as unknown as ContainsTarget; -} - -export function isTerminalDragLeaveOutside(input: { - currentTarget: EventTarget | null; - relatedTarget: EventTarget | null; -}): boolean { - const currentTarget = asContainsTarget(input.currentTarget); - if (!currentTarget || !input.relatedTarget) { - return true; - } - return !currentTarget.contains(input.relatedTarget); -} - -export function extractTerminalDropPaths( - dataTransfer: DataTransfer | null, - bridge: DesktopHostBridge | null, -): string[] { - if (!dataTransfer) { - return []; - } - - const paths: string[] = []; - for (const file of Array.from(dataTransfer.files)) { - const path = getFilePath(file, bridge); - if (path) { - paths.push(path); - } - } - return paths; -} - -function escapeNonWindowsPath(path: string): string { - let nextPath = path; - if (nextPath.includes("\\")) { - nextPath = nextPath.replace(/\\/g, "\\\\"); - } - - nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, ""); - - if (nextPath.includes("'") && nextPath.includes('"')) { - return `$'${nextPath.replace(/'/g, "\\'")}'`; - } - if (nextPath.includes("'")) { - return `'${nextPath.replace(/'/g, "\\'")}'`; - } - return `'${nextPath}'`; -} - -function escapeWindowsPath(path: string): string { - if (!path.includes(" ")) { - return path; - } - return `"${path}"`; -} - -export function prepareDroppedPathForTerminal( - path: string, - bridge: DesktopHostBridge | null, -): string { - if (bridge?.platform === "win32") { - return escapeWindowsPath(path); - } - return escapeNonWindowsPath(path); -} - -export function prepareDroppedPathsForTerminal( - paths: readonly string[], - bridge: DesktopHostBridge | null, -): string { - return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" "); + const clientPath = input.hostIsLocal ? getClientFilePath(item.file, input.bridge) : null; + return clientPath + ? { kind: "path", path: clientPath } + : { kind: "upload", read: () => readTerminalFile(item.file) }; } diff --git a/packages/app/src/terminal/drop/terminal-path.test.ts b/packages/app/src/terminal/drop/terminal-path.test.ts new file mode 100644 index 0000000000..6c4b580e48 --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path"; + +describe("terminal path quoting", () => { + it("prepares POSIX paths with conservative escaping", () => { + expect(prepareTerminalPath("/tmp/my image.png")).toBe("'/tmp/my image.png'"); + expect(prepareTerminalPath("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'"); + expect(prepareTerminalPath("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'"); + }); + + it("prepares Windows paths with space quoting", () => { + expect(prepareTerminalPath("C:\\Users\\me\\photo.png")).toBe("C:\\Users\\me\\photo.png"); + expect(prepareTerminalPath("C:\\Users\\me\\photo one.png")).toBe( + '"C:\\Users\\me\\photo one.png"', + ); + expect(prepareTerminalPath("\\\\server\\share\\photo one.png")).toBe( + '"\\\\server\\share\\photo one.png"', + ); + }); + + it("joins multiple dropped paths for one terminal input", () => { + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + "'/tmp/a.png' '/tmp/b c.png'", + ); + }); +}); diff --git a/packages/app/src/terminal/drop/terminal-path.ts b/packages/app/src/terminal/drop/terminal-path.ts new file mode 100644 index 0000000000..bc29edd54b --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.ts @@ -0,0 +1,40 @@ +// Bundled into the terminal WebView as well as the app: keep this module dependency-free. +const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; + +function escapeNonWindowsPath(path: string): string { + let nextPath = path; + if (nextPath.includes("\\")) { + nextPath = nextPath.replace(/\\/g, "\\\\"); + } + + nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, ""); + + if (nextPath.includes("'") && nextPath.includes('"')) { + return `$'${nextPath.replace(/'/g, "\\'")}'`; + } + if (nextPath.includes("'")) { + return `'${nextPath.replace(/'/g, "\\'")}'`; + } + return `'${nextPath}'`; +} + +function escapeWindowsPath(path: string): string { + if (!path.includes(" ")) { + return path; + } + return `"${path}"`; +} + +const WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/; + +/** Quotes a path for the shell of the machine it belongs to, identified by the path's shape. */ +export function prepareTerminalPath(path: string): string { + if (WINDOWS_PATH.test(path)) { + return escapeWindowsPath(path); + } + return escapeNonWindowsPath(path); +} + +export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { + return paths.map(prepareTerminalPath).join(" "); +} diff --git a/packages/app/src/terminal/file-paste/index.test.ts b/packages/app/src/terminal/file-paste/index.test.ts new file mode 100644 index 0000000000..63a1e5e73d --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; +import { TerminalFilePasteQueue, type TerminalFile, type TerminalFilePasteTarget } from "./index"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function file(fileName: string, bytes: number[] = [1, 2, 3]): TerminalFile { + return { fileName, mimeType: "image/png", bytes: new Uint8Array(bytes) }; +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +class FakeHost implements TerminalFilePasteTarget { + readonly uploads: TerminalFile[] = []; + readonly pasted: string[] = []; + private readonly results = new Map>(); + + holdUpload(fileName: string): ReturnType> { + const result = deferred(); + this.results.set(fileName, result.promise); + return result; + } + + async upload(input: TerminalFile): Promise { + this.uploads.push(input); + return this.results.get(input.fileName) ?? `/host/uploads/${input.fileName}`; + } + + paste(text: string): void { + this.pasted.push(text); + } +} + +function createQueue() { + const events = { uploading: [] as boolean[], uploaded: 0, failures: [] as string[] }; + const queue = new TerminalFilePasteQueue({ + onUploadingChange: (uploading) => { + events.uploading.push(uploading); + }, + onUploaded: () => { + events.uploaded += 1; + }, + onFailure: (error) => { + events.failures.push(error instanceof Error ? error.message : String(error)); + }, + }); + return { queue, events, host: new FakeHost() }; +} + +describe("terminal file paste queue", () => { + it("pastes each uploaded host path in gesture order", async () => { + const { queue, host, events } = createQueue(); + const firstRead = deferred(); + const firstUpload = host.holdUpload("a.png"); + + queue.add(firstRead.promise, host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + expect(host.uploads).toEqual([]); + + firstRead.resolve(file("a.png")); + await settle(); + expect(host.uploads.map((upload) => upload.fileName)).toEqual(["a.png"]); + + firstUpload.resolve("/host/uploads/a.png"); + await settle(); + expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]); + expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] }); + }); + + it("quotes Windows host paths for the host shell", async () => { + const { queue, host } = createQueue(); + host.holdUpload("shot.png").resolve("C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"); + + queue.add(Promise.resolve(file("shot.png")), host); + await settle(); + + expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']); + }); + + it("uploads under a file name that needs no shell quoting", async () => { + const { queue, host } = createQueue(); + + queue.add(Promise.resolve(file("Screen Shot #1 (it's $HOME).png")), host); + await settle(); + + expect(host.uploads.map((upload) => upload.fileName)).toEqual([ + "Screen_Shot__1__it_s__HOME_.png", + ]); + }); + + it("does nothing for a gesture that carried no file", async () => { + const { queue, host, events } = createQueue(); + + queue.add(Promise.resolve(null), host); + await settle(); + + expect(host.uploads).toEqual([]); + expect(host.pasted).toEqual([]); + expect(events).toEqual({ uploading: [], uploaded: 0, failures: [] }); + }); + + it("reports a failed upload and keeps pasting later files", async () => { + const { queue, host, events } = createQueue(); + host.holdUpload("a.png").reject(new Error("disk full")); + + queue.add(Promise.resolve(file("a.png")), host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + + expect(events.failures).toEqual(["disk full"]); + expect(events.uploaded).toBe(1); + expect(host.pasted).toEqual(["'/host/uploads/b.png'"]); + }); + + it("reports unreadable and oversized files without uploading them", async () => { + const { queue, host, events } = createQueue(); + const oversized = { + ...file("big.png"), + bytes: new Uint8Array(FILE_UPLOAD_MAX_BYTES + 1), + }; + + queue.add(Promise.reject(new Error("unreadable")), host); + queue.add(Promise.resolve(oversized), host); + await settle(); + + expect(events.failures).toEqual(["unreadable", "big.png exceeds the upload limit"]); + expect(host.uploads).toEqual([]); + }); + + it("never pastes the path of an upload cancelled mid-flight", async () => { + const { queue, host, events } = createQueue(); + const upload = host.holdUpload("a.png"); + + queue.add(Promise.resolve(file("a.png")), host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + expect(queue.cancel()).toBe(true); + expect(events.uploading).toEqual([true, false]); + + upload.resolve("/host/uploads/a.png"); + await settle(); + expect(host.pasted).toEqual([]); + expect(host.uploads.map((entry) => entry.fileName)).toEqual(["a.png"]); + expect(events.failures).toEqual([]); + expect(events.uploaded).toBe(0); + }); + + it("does not hold new pastes behind a cancelled upload that never settles", async () => { + const { queue, host, events } = createQueue(); + host.holdUpload("stuck.png"); + + queue.add(Promise.resolve(file("stuck.png")), host); + await settle(); + queue.cancel(); + queue.add(Promise.resolve(file("next.png")), host); + await settle(); + + expect(host.pasted).toEqual(["'/host/uploads/next.png'"]); + expect(events.uploading).toEqual([true, false, true, false]); + expect(queue.cancel()).toBe(false); + }); +}); diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts new file mode 100644 index 0000000000..bc96b17c5d --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.ts @@ -0,0 +1,135 @@ +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; +import { prepareTerminalPath } from "../drop/terminal-path"; + +/** File bytes from a paste or drop gesture, before they become a file on the host. */ +export interface TerminalFile { + fileName: string; + mimeType: string; + bytes: Uint8Array; +} + +/** A file over the upload limit; the pane words it for the user. */ +export class TerminalFileTooLargeError extends Error { + constructor(readonly fileName: string) { + super(`${fileName} exceeds the upload limit`); + } +} + +/** The pane's host and terminal: where a file is stored and where its path is pasted. */ +export interface TerminalFilePasteTarget { + /** Stores the file on the host and returns its host path. */ + upload(file: TerminalFile): Promise; + paste(text: string): void; +} + +interface TerminalFilePasteEvents { + onUploadingChange(uploading: boolean): void; + onUploaded(): void; + onFailure(error: unknown): void; +} + +/** + * Turns a pane's file gestures into pasted host paths, one at a time and in + * gesture order. Ordinary input never waits on it. + */ +export class TerminalFilePasteQueue { + private tail: Promise = Promise.resolve(); + private generation = 0; + private pending = 0; + private uploading = false; + + constructor(private readonly events: TerminalFilePasteEvents) {} + + /** Queues a gesture. `file` resolves to null when the gesture carried no file. */ + add(file: Promise, target: TerminalFilePasteTarget): void { + const generation = this.generation; + this.pending += 1; + this.tail = this.tail.then(() => this.paste(file, target, generation)); + } + + /** Drops queued and in-flight gestures. Returns whether any were dropped. */ + cancel(): boolean { + const dropped = this.pending > 0; + this.generation += 1; + this.pending = 0; + // A cancelled upload can stay unsettled until its connection resolves. + this.tail = Promise.resolve(); + this.setUploading(false); + return dropped; + } + + private async paste( + read: Promise, + target: TerminalFilePasteTarget, + generation: number, + ): Promise { + const isCurrent = () => generation === this.generation; + try { + const file = await read; + if (!file || !isCurrent()) { + return; + } + if (file.bytes.byteLength > FILE_UPLOAD_MAX_BYTES) { + throw new TerminalFileTooLargeError(file.fileName); + } + this.setUploading(true); + const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) }); + if (isCurrent()) { + target.paste(prepareTerminalPath(path)); + this.events.onUploaded(); + } + } catch (error) { + if (isCurrent()) { + this.events.onFailure(error); + } + } finally { + if (isCurrent()) { + this.pending -= 1; + this.setUploading(false); + } + } + } + + private setUploading(uploading: boolean): void { + if (this.uploading === uploading) { + return; + } + this.uploading = uploading; + this.events.onUploadingChange(uploading); + } +} + +// The host path is pasted into a shell or TUI; keep its last segment unquotable. +function toShellSafeFileName(fileName: string): string { + return fileName.replace(/[^\w.-]/g, "_"); +} + +export async function readTerminalFile(file: File): Promise { + return { + fileName: file.name, + mimeType: file.type, + bytes: new Uint8Array(await file.arrayBuffer()), + }; +} + +export function terminalFileFromBase64(input: { + fileName: string; + mimeType: string; + data: string; +}): TerminalFile { + const binary = atob(input.data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return { fileName: input.fileName, mimeType: input.mimeType, bytes }; +} + +/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */ +export function imageExtension(mimeType: string): string { + const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? ""; + if (subtype === "jpeg") { + return "jpg"; + } + return subtype || "png"; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts index 52e920c90e..ecec86d43e 100644 --- a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts @@ -4,29 +4,14 @@ import { resolveTerminalRendererSelection } from "./terminal-renderer-capability describe("terminal renderer capability", () => { it("requires input mode replay before attaching the native renderer", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: false, - supportsTerminalInputModeReplay: false, - }), - ).toBe("update-host"); + expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: false })).toBe( + "update-host", + ); }); it("allows the native renderer when the daemon advertises input mode replay", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: false, - supportsTerminalInputModeReplay: true, - }), - ).toBe("native-grid"); - }); - - it("keeps the explicit legacy renderer available with older daemons", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: true, - supportsTerminalInputModeReplay: false, - }), - ).toBe("webview"); + expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: true })).toBe( + "native-grid", + ); }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts index 5c5c7ca608..8e4b08baf1 100644 --- a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts @@ -1,11 +1,7 @@ -export type TerminalRendererSelection = "native-grid" | "webview" | "update-host"; +export type TerminalRendererSelection = "native-grid" | "update-host"; export function resolveTerminalRendererSelection(input: { - useLegacyTerminalRenderer: boolean; supportsTerminalInputModeReplay: boolean; }): TerminalRendererSelection { - if (input.useLegacyTerminalRenderer) { - return "webview"; - } return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host"; } diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index b173545abc..5dcefb8ebf 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -1,7 +1,12 @@ import { page, userEvent } from "@vitest/browser/context"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; +import type { TerminalFile } from "../file-paste"; +import { + encodeTerminalOutput, + TerminalEmulatorRuntime, + type TerminalClipboard, +} from "./terminal-emulator-runtime"; vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: class WebglAddon { @@ -41,6 +46,36 @@ interface MountedTerminal { terminalKeys: TerminalKeyRecord[]; inputModeChanges: TerminalInputModeState[]; openedUrls: string[]; + clipboard: FakeClipboard; + filePastes: Promise[]; +} + +class FakeClipboard implements TerminalClipboard { + text = ""; + image: { mimeType: string; bytes: number[] } | null = null; + imageReads = 0; + + async readText(): Promise { + return this.text; + } + + async read(): Promise[]> { + this.imageReads += 1; + const image = this.image; + if (!image) { + return []; + } + return [ + { + types: ["text/html", image.mimeType], + getType: async (type: string) => new Blob([new Uint8Array(image.bytes)], { type }), + }, + ]; + } + + async writeText(text: string): Promise { + this.text = text; + } } const mountedTerminals: MountedTerminal[] = []; @@ -100,8 +135,11 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; const openedUrls: string[] = []; + const clipboard = new FakeClipboard(); + const filePastes: Promise[] = []; const runtime = new TerminalEmulatorRuntime( input.isMac === undefined ? undefined : { isMac: input.isMac }, + clipboard, ); runtime.setCallbacks({ callbacks: { @@ -120,6 +158,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { onOpenExternalUrl: (url) => { openedUrls.push(url); }, + onFilePaste: (file) => { + filePastes.push(file); + }, }, }); runtime.mount({ @@ -143,6 +184,8 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { terminalKeys, inputModeChanges, openedUrls, + clipboard, + filePastes, }; mountedTerminals.push(mounted); return mounted; @@ -413,6 +456,78 @@ describe("terminal emulator runtime in a real browser", () => { expect(mounted.inputs).toEqual(["legacy renderer paste"]); }); + it("pastes clipboard text on Ctrl+V without reading the clipboard image", async () => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.clipboard.text = "clipboard text"; + mounted.clipboard.image = { mimeType: "image/png", bytes: [1, 2, 3] }; + + dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true }); + + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toBeNull(); + expect(mounted.inputs).toEqual(["clipboard text"]); + expect(mounted.clipboard.imageReads).toBe(0); + }); + + it("hands the clipboard image to onFilePaste on Ctrl+V when there is no text", async () => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.clipboard.image = { mimeType: "image/jpeg", bytes: [1, 2, 3] }; + + dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true }); + + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toEqual({ + fileName: "clipboard.jpg", + mimeType: "image/jpeg", + bytes: new Uint8Array([1, 2, 3]), + }); + expect(mounted.inputs).toEqual([]); + }); + + it("takes file-only paste events at xterm's input before xterm pastes empty text", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { + clipboardData: transfer, + bubbles: true, + cancelable: true, + }); + + mounted.host.querySelector(".xterm-helper-textarea")!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toEqual({ + fileName: "shot.png", + mimeType: "image/png", + bytes: new Uint8Array([1, 2, 3]), + }); + await nextFrame(); + expect(mounted.inputs).toEqual([]); + }); + + it("leaves paste events with text to xterm", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const transfer = new DataTransfer(); + transfer.setData("text/plain", "echo hi"); + transfer.items.add(new File([new Uint8Array([1])], "shot.png", { type: "image/png" })); + + mounted.host + .querySelector(".xterm-helper-textarea")! + .dispatchEvent( + new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }), + ); + + await waitFor({ predicate: () => mounted.inputs.length > 0 }); + expect(mounted.inputs).toEqual(["echo hi"]); + expect(mounted.filePastes).toEqual([]); + }); + it("refreshes visible rows on a forced same-size resize", async () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 720, height: 360 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index cc4462ff7b..0a55cd7126 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -33,6 +33,7 @@ import { import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut"; import { isMacUserAgent } from "@/utils/mac-user-agent"; import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font"; +import { imageExtension, readTerminalFile, type TerminalFile } from "../file-paste"; export type TerminalOutputData = Uint8Array; @@ -83,6 +84,8 @@ export interface TerminalEmulatorRuntimeCallbacks { disposition: "main" | "side", ) => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + /** A paste gesture that can carry a file; resolves to null when it carried none. */ + onFilePaste?: (file: Promise) => void; } export interface TerminalResizeEvent { @@ -122,6 +125,7 @@ interface TerminalEmulatorRuntimeDisposables { clearFitTimeouts: () => void; removeFontListeners: () => void; removeTouchListeners: () => void; + removeFilePasteListener: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; disposeFitAddon: () => void; @@ -180,8 +184,51 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { }; } +export interface TerminalClipboard { + readText(): Promise; + read(): Promise[]>; + writeText(text: string): Promise; +} + +const browserClipboard: TerminalClipboard = { + readText: () => navigator.clipboard.readText(), + read: () => navigator.clipboard.read(), + writeText: (text) => navigator.clipboard.writeText(text), +}; + +async function readClipboardImage(clipboard: TerminalClipboard): Promise { + for (const item of await clipboard.read()) { + const mimeType = item.types.find((type) => type.startsWith("image/")); + if (mimeType) { + const blob = await item.getType(mimeType); + return { + fileName: `clipboard.${imageExtension(mimeType)}`, + mimeType, + bytes: new Uint8Array(await blob.arrayBuffer()), + }; + } + } + return null; +} + +/** The file of a paste that carries no text. xterm would paste it as empty text. */ +function findFileOnlyPaste(transfer: DataTransfer | null): File | null { + if (!transfer || transfer.getData("text/plain").length > 0) { + return null; + } + for (const item of Array.from(transfer.items)) { + if (item.kind === "file") { + return item.getAsFile(); + } + } + return null; +} + export class TerminalEmulatorRuntime { - constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {} + constructor( + private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }, + private readonly clipboard: TerminalClipboard = browserClipboard, + ) {} private callbacks: TerminalEmulatorRuntimeCallbacks = {}; private pendingModifiers: PendingTerminalModifiers = { @@ -327,19 +374,14 @@ export class TerminalEmulatorRuntime { // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT if (key === "c" && terminal.hasSelection()) { - void navigator.clipboard.writeText(terminal.getSelection()); + void this.clipboard.writeText(terminal.getSelection()); return false; } - // Ctrl+V: paste from clipboard into terminal + // Ctrl+V: paste clipboard text, or the clipboard image when there is no text if (key === "v") { event.preventDefault(); - void navigator.clipboard.readText().then((text) => { - if (text) { - terminal.paste(text); - } - return; - }); + this.pasteClipboard(terminal); return false; } @@ -666,6 +708,20 @@ export class TerminalEmulatorRuntime { this.attachKeyEventHandler(terminal); + // Cmd+V and context-menu paste arrive as DOM paste events. Take file-only + // pastes before xterm's own listener consumes them as empty text. + const filePasteHandler = (event: ClipboardEvent): void => { + const onFilePaste = this.callbacks.onFilePaste; + const file = findFileOnlyPaste(event.clipboardData); + if (!onFilePaste || !file) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onFilePaste(readTerminalFile(file)); + }; + input.host.addEventListener("paste", filePasteHandler, true); + const removeTouchListeners = this.setupTouchScrollHandlers({ root: input.root, host: input.host, @@ -755,6 +811,9 @@ export class TerminalEmulatorRuntime { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, removeTouchListeners, + removeFilePasteListener: () => { + input.host.removeEventListener("paste", filePasteHandler, true); + }, restoreDocumentStyles, restoreViewportStyles, disposeFitAddon: () => { @@ -784,6 +843,7 @@ export class TerminalEmulatorRuntime { disposables.clearFitTimeouts(); disposables.removeFontListeners(); disposables.removeTouchListeners(); + disposables.removeFilePasteListener(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); disposables.disposeTerminal(); @@ -824,6 +884,20 @@ export class TerminalEmulatorRuntime { this.terminal?.paste(text); } + private pasteClipboard(terminal: Pick): void { + const text = this.clipboard.readText().catch(() => ""); + const onFilePaste = this.callbacks.onFilePaste; + // Report the gesture now so it keeps its place among earlier file pastes. + const file = text.then((value) => { + if (value) { + terminal.paste(value); + return null; + } + return onFilePaste ? readClipboardImage(this.clipboard).catch(() => null) : null; + }); + onFilePaste?.(file); + } + renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { if (!input.state) { this.clear(input); diff --git a/packages/app/src/terminal/runtime/terminal-paste.test.ts b/packages/app/src/terminal/runtime/terminal-paste.test.ts index 48832003c9..b052a62c94 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.test.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.test.ts @@ -27,22 +27,24 @@ describe("terminal paste encoding", () => { it("reads clipboard through the explicit paste adapter", async () => { const pasted: string[] = []; - await pasteTerminalClipboard({ + const result = await pasteTerminalClipboard({ clipboard: { readText: async () => "printf one\nprintf two" }, terminal: { paste: (text) => pasted.push(text) }, }); expect(pasted).toEqual(["printf one\nprintf two"]); + expect(result).toBe(true); }); it("does not dispatch terminal input when clipboard is empty", async () => { const pasted: string[] = []; - await pasteTerminalClipboard({ + const result = await pasteTerminalClipboard({ clipboard: { readText: async () => "" }, terminal: { paste: (text) => pasted.push(text) }, }); expect(pasted).toEqual([]); + expect(result).toBe(false); }); }); diff --git a/packages/app/src/terminal/runtime/terminal-paste.ts b/packages/app/src/terminal/runtime/terminal-paste.ts index f02d301e07..8830ff0b6f 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.ts @@ -28,11 +28,13 @@ export function encodeTerminalPaste(input: TerminalPasteInput): string { return `${BRACKETED_PASTE_START}${payload}${BRACKETED_PASTE_END}`; } -export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { +/** Pastes clipboard text. Returns whether there was text to paste. */ +export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { const text = await input.clipboard.readText(); if (text.length === 0) { - return; + return false; } input.terminal.paste(text); + return true; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts deleted file mode 100644 index 50f080b37c..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ /dev/null @@ -1,568 +0,0 @@ -import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime"; -import type { ITheme } from "@xterm/xterm"; -import xtermCss from "@xterm/xterm/css/xterm.css"; -import type { TerminalState } from "@getpaseo/protocol/messages"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { PendingTerminalModifiers } from "@/utils/terminal-keys"; -import { - encodeTerminalOutput, - TerminalEmulatorRuntime, -} from "../runtime/terminal-emulator-runtime"; -import type { - TerminalLocalFileLinkSource, - TerminalLocalFileLinkTarget, -} from "../local-links/terminal-local-link-provider"; - -interface MountMessage { - type: "mount"; - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -} - -type InboundMessage = - | MountMessage - | { type: "unmount"; streamKey: string } - | { type: "writeOutput"; streamKey: string; text: string } - | { type: "restoreOutput"; streamKey: string; text: string } - | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } - | { type: "paste"; streamKey: string; text: string } - | { type: "clear"; streamKey: string } - | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } - | { type: "clearFind"; streamKey: string } - | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } - | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } - | { type: "setTheme"; streamKey: string; theme: ITheme } - | { type: "setScrollback"; streamKey: string; lines: number } - | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } - | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } - | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } - | { - type: "resolveLocalFileLinkResponse"; - streamKey: string; - requestId: number; - target: TerminalLocalFileLinkTarget | null; - }; - -type OutboundMessage = - | { type: "bridgeReady" } - | { type: "rendererReady"; streamKey: string; isReady: boolean } - | { type: "input"; streamKey: string; data: string } - | { type: "findRequest"; streamKey: string } - | { type: "findResult"; streamKey: string; result: TerminalFindResult } - | { - type: "resize"; - streamKey: string; - rows: number; - cols: number; - shouldClaim?: boolean; - forceClaim?: boolean; - } - | { - type: "terminalKey"; - streamKey: string; - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - } - | { type: "pendingModifiersConsumed"; streamKey: string } - | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } - | { type: "openExternalUrl"; streamKey: string; url: string } - | { - type: "resolveLocalFileLink"; - streamKey: string; - requestId: number; - source: TerminalLocalFileLinkSource; - } - | { - type: "openLocalFileLink"; - streamKey: string; - target: TerminalLocalFileLinkTarget; - disposition: "main" | "side"; - } - | { type: "swipeLeft"; streamKey: string } - | { type: "swipeRight"; streamKey: string } - | { type: "debug"; message: string; details?: unknown }; - -declare global { - interface Window { - ReactNativeWebView?: { - postMessage?: (data: string) => void; - }; - __PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void; - __PASEO_TERMINAL_WEBVIEW_BLUR__?: () => void; - } -} - -const sendToNative = (message: OutboundMessage): void => { - window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); -}; - -const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background"; -const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b"; -const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; - -function getTerminalBackground(theme: ITheme): string { - return theme.background ?? DEFAULT_TERMINAL_BACKGROUND; -} - -const installStyles = (): void => { - const style = document.createElement("style"); - style.textContent = ` -${xtermCss} -:root { - ${TERMINAL_BACKGROUND_CSS_VAR}: transparent; -} -html, -body, -#terminal-root { - width: 100%; - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; - overscroll-behavior: none; - background: var(${TERMINAL_BACKGROUND_CSS_VAR}); -} -#terminal-root { - display: flex; - min-width: 0; - min-height: 0; -} -#terminal-host { - flex: 1; - min-width: 0; - min-height: 0; - width: 100%; - height: 100%; - overflow: hidden; - background: var(${TERMINAL_BACKGROUND_CSS_VAR}); -} -#terminal-root .xterm, -#terminal-root .xterm-screen, -#terminal-root .xterm-viewport { - background-color: var(${TERMINAL_BACKGROUND_CSS_VAR}) !important; -} -[data-terminal-scrollbar-root="true"] .xterm-viewport { - scrollbar-width: none; - -ms-overflow-style: none; -} -[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar { - width: 0; - height: 0; -} -#terminal-root .xterm .xterm-helper-textarea { - opacity: 0.01; - width: 1px; - height: 1px; - min-width: 1px; - min-height: 1px; - color: transparent; - background: transparent; - caret-color: transparent; - z-index: 5 !important; -} -`; - document.head.appendChild(style); -}; - -class TerminalWebViewBridge { - private runtime: TerminalEmulatorRuntime | null = null; - private streamKey: string | null = null; - private nextLocalFileLinkRequestId = 1; - private readonly pendingLocalFileLinkResolutions = new Map< - number, - (target: TerminalLocalFileLinkTarget | null) => void - >(); - private swipeGesturesEnabled = false; - private trackingSwipe = false; - private activePointerId: number | null = null; - private startX = 0; - private startY = 0; - private firedSwipe = false; - private tapTouchIdentifier: number | null = null; - private tapStartX = 0; - private tapStartY = 0; - private tapMoved = false; - - constructor( - private readonly root: HTMLDivElement, - private readonly host: HTMLDivElement, - ) { - this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true }); - this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true }); - this.root.addEventListener("touchmove", this.handleTouchMove, { passive: true }); - this.root.addEventListener("touchend", this.handleTouchEnd, { passive: true }); - this.root.addEventListener("touchcancel", this.handleTouchCancel, { passive: true }); - this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false }); - this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true }); - this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true }); - } - - receive = (message: InboundMessage): void => { - try { - this.receiveUnsafe(message); - } catch (error) { - sendToNative({ - type: "debug", - message: "terminal webview receive failed", - details: error instanceof Error ? { message: error.message, stack: error.stack } : error, - }); - } - }; - - private receiveUnsafe(message: InboundMessage): void { - if (message.type === "mount") { - this.mount(message); - return; - } - if (message.type === "unmount") { - this.unmount(message.streamKey); - return; - } - if (!this.matches(message.streamKey)) { - return; - } - if (message.type === "resolveLocalFileLinkResponse") { - this.resolveLocalFileLinkRequest(message.requestId, message.target); - return; - } - if (message.type === "findWidgetSize") { - this.runtime?.find.setWidgetSize(message.size); - return; - } - if (message.type === "find") { - this.runtime?.find.search(message.query, message.direction); - return; - } - if (message.type === "clearFind") { - this.runtime?.find.clear(); - return; - } - this.receiveMounted(message); - } - - private receiveMounted( - message: Exclude< - InboundMessage, - MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } - >, - ): void { - if (this.receiveConfigurationMessage(message)) return; - - switch (message.type) { - case "writeOutput": - this.runtime?.write({ data: encodeTerminalOutput(message.text) }); - break; - case "restoreOutput": - this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) }); - break; - case "renderSnapshot": - this.runtime?.renderSnapshot({ state: message.state }); - break; - case "paste": - this.runtime?.paste(message.text); - break; - case "clear": - this.runtime?.clear(); - break; - case "focus": - this.runtime?.focus({ forceRefocus: message.forceRefocus }); - break; - case "resize": - this.runtime?.resize({ - shouldClaim: message.shouldClaim ?? true, - forceClaim: message.forceClaim, - }); - break; - } - } - - private receiveConfigurationMessage( - message: Exclude< - InboundMessage, - MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } - >, - ): boolean { - switch (message.type) { - case "setTheme": - this.applyThemeBackground(message.theme); - this.runtime?.setTheme({ theme: message.theme }); - return true; - case "setScrollback": - this.runtime?.setScrollback({ lines: message.lines }); - return true; - case "setFont": - this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize }); - return true; - case "setPendingModifiers": - this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); - return true; - case "setSwipeGesturesEnabled": - this.swipeGesturesEnabled = message.enabled; - return true; - default: - return false; - } - } - - private mount(message: MountMessage): void { - this.unmount(this.streamKey); - this.streamKey = message.streamKey; - this.swipeGesturesEnabled = message.swipeGesturesEnabled; - this.applyThemeBackground(message.theme); - - const runtime = new TerminalEmulatorRuntime(); - this.runtime = runtime; - runtime.setCallbacks({ - callbacks: { - onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }), - onFindResult: (result) => - sendToNative({ type: "findResult", streamKey: message.streamKey, result }), - onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), - onResize: ({ rows, cols, shouldClaim, forceClaim }) => - sendToNative({ - type: "resize", - streamKey: message.streamKey, - rows, - cols, - shouldClaim, - forceClaim, - }), - onTerminalKey: (input) => - sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), - onPendingModifiersConsumed: () => - sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }), - onInputModeChange: (state) => - sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }), - onOpenExternalUrl: (url) => - sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }), - onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source), - onOpenLocalFileLink: (target, disposition) => - sendToNative({ - type: "openLocalFileLink", - streamKey: message.streamKey, - target, - disposition, - }), - }, - }); - runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); - runtime.mount({ - root: this.root, - host: this.host, - initialSnapshot: message.initialSnapshot, - scrollback: message.scrollbackLines, - theme: message.theme, - fontFamily: message.fontFamily, - fontSize: message.fontSize, - }); - sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true }); - } - - private applyThemeBackground(theme: ITheme): void { - const background = getTerminalBackground(theme); - document.documentElement.style.setProperty(TERMINAL_BACKGROUND_CSS_VAR, background); - document.body.style.backgroundColor = background; - this.root.style.backgroundColor = background; - this.host.style.backgroundColor = background; - } - - blur = (): void => { - this.runtime?.blur(); - }; - - private unmount(streamKey: string | null): void { - if (!this.runtime) { - return; - } - const previousStreamKey = this.streamKey; - this.runtime.unmount(); - this.runtime = null; - this.streamKey = null; - this.resolveAllLocalFileLinkRequests(null); - if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) { - sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false }); - } - } - - private matches(streamKey: string): boolean { - return this.streamKey === streamKey; - } - - private requestLocalFileLinkResolution( - source: TerminalLocalFileLinkSource, - ): Promise { - const streamKey = this.streamKey; - if (!streamKey) { - return Promise.resolve(null); - } - - const requestId = this.nextLocalFileLinkRequestId++; - return new Promise((resolve) => { - this.pendingLocalFileLinkResolutions.set(requestId, resolve); - sendToNative({ - type: "resolveLocalFileLink", - streamKey, - requestId, - source, - }); - }); - } - - private resolveLocalFileLinkRequest( - requestId: number, - target: TerminalLocalFileLinkTarget | null, - ): void { - const resolve = this.pendingLocalFileLinkResolutions.get(requestId); - if (!resolve) { - return; - } - this.pendingLocalFileLinkResolutions.delete(requestId); - resolve(target); - } - - private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void { - const requests = Array.from(this.pendingLocalFileLinkResolutions.values()); - this.pendingLocalFileLinkResolutions.clear(); - for (const resolve of requests) { - resolve(target); - } - } - - private handlePointerDown = (event: PointerEvent): void => { - if (!event.isPrimary) { - return; - } - if (!this.swipeGesturesEnabled) { - return; - } - this.trackingSwipe = true; - this.firedSwipe = false; - this.activePointerId = event.pointerId; - this.startX = event.clientX; - this.startY = event.clientY; - }; - - private handleTouchStart = (event: TouchEvent): void => { - if (event.touches.length !== 1) { - this.resetTap(); - return; - } - const touch = event.touches[0]; - this.tapTouchIdentifier = touch.identifier; - this.tapStartX = touch.clientX; - this.tapStartY = touch.clientY; - this.tapMoved = false; - }; - - private handleTouchMove = (event: TouchEvent): void => { - const touch = this.findTrackedTouch(event.touches); - if (!touch) { - return; - } - const dx = touch.clientX - this.tapStartX; - const dy = touch.clientY - this.tapStartY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - this.tapMoved = true; - } - }; - - private handleTouchEnd = (event: TouchEvent): void => { - const completedTap = Boolean(this.findTrackedTouch(event.changedTouches) && !this.tapMoved); - this.resetTap(); - if (completedTap) { - this.runtime?.focus({ forceRefocus: true }); - } - }; - - private handleTouchCancel = (): void => { - this.resetTap(); - }; - - private handlePointerMove = (event: PointerEvent): void => { - if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) { - return; - } - if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { - return; - } - - const dx = event.clientX - this.startX; - const dy = event.clientY - this.startY; - const absDx = Math.abs(dx); - const absDy = Math.abs(dy); - if (absDy >= 12 && absDy > absDx) { - this.resetSwipe(); - return; - } - if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) { - return; - } - - this.firedSwipe = true; - sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey }); - if (event.cancelable) event.preventDefault(); - }; - - private handlePointerUp = (event: PointerEvent): void => { - if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { - return; - } - this.resetSwipe(); - }; - - private resetSwipe(): void { - this.trackingSwipe = false; - this.activePointerId = null; - this.startX = 0; - this.startY = 0; - this.firedSwipe = false; - } - - private findTrackedTouch(touches: TouchList): Touch | null { - if (this.tapTouchIdentifier === null) { - return null; - } - for (let index = 0; index < touches.length; index += 1) { - const touch = touches.item(index); - if (touch?.identifier === this.tapTouchIdentifier) { - return touch; - } - } - return null; - } - - private resetTap(): void { - this.tapTouchIdentifier = null; - this.tapStartX = 0; - this.tapStartY = 0; - this.tapMoved = false; - } -} - -installStyles(); - -const root = document.createElement("div"); -root.id = "terminal-root"; -root.dataset.terminalScrollbarRoot = "true"; -const host = document.createElement("div"); -host.id = "terminal-host"; -root.appendChild(host); -document.body.appendChild(root); - -const bridge = new TerminalWebViewBridge(root, host); -window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive; -window.__PASEO_TERMINAL_WEBVIEW_BLUR__ = bridge.blur; -sendToNative({ type: "bridgeReady" }); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts deleted file mode 100644 index c7fc27652e..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Generated by packages/app/scripts/build-terminal-webview-html.mjs. -// Do not edit by hand. - -export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts deleted file mode 100644 index 37e83c6ba9..0000000000 --- a/packages/app/src/terminal/webview/terminal-find.browser.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Terminal } from "@xterm/xterm"; -import { afterEach, expect, test } from "vitest"; -import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html"; - -interface Message { - type: string; - streamKey?: string; - result?: { resultCount: number; resultIndex: number }; - data?: string; -} -interface TerminalFrameWindow extends Window { - __PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void; - __paseoTerminal?: Terminal; -} -let frame: HTMLIFrameElement; -afterEach(() => frame?.remove()); - -test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => { - const messages: Message[] = []; - frame = document.createElement("iframe"); - frame.style.width = "500px"; - frame.style.height = "300px"; - document.body.append(frame); - const win = frame.contentWindow as TerminalFrameWindow; - Object.assign(win, { - ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) }, - }); - win.document.open(); - win.document.write(terminalEmulatorWebViewHtml); - win.document.close(); - await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true); - const send = (message: object) => - win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message }); - send({ - type: "mount", - initialSnapshot: null, - scrollbackLines: 1000, - theme: { background: "#ffffff", foreground: "#111111" }, - pendingModifiers: { ctrl: false, shift: false, alt: false }, - swipeGesturesEnabled: false, - }); - await expect - .poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned")) - .toBe(true); - send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" }); - await expect - .poll(() => { - const b = win.__paseoTerminal?.buffer.active; - return b - ? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join( - "\n", - ) - : ""; - }) - .toContain("last A.B"); - send({ type: "findWidgetSize", size: { width: 356, height: 58 } }); - send({ type: "find", query: "a.b" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 2, resultIndex: 1 }); - send({ type: "find", query: "a.b", direction: "previous" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 2, resultIndex: 0 }); - send({ type: "find", streamKey: "stale", query: "absent" }); - expect(win.__paseoTerminal!.getSelection()).toBe("a.b"); - send({ type: "clearFind" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 0 }); - expect(messages.filter((m) => m.type === "input")).toEqual([]); - send({ type: "unmount" }); -}); diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 0f0590bb9e..8d7fb87b35 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -4,6 +4,8 @@ import { createRemoteSshHostConnection, normalizeStoredHostProfile, orderHostsLocalFirst, + pinRelayProtectedSecurityInProfiles, + relayConnectionFromOffer, resolveActiveHostServerId, upsertHostConnectionInProfiles, type HostConnection, @@ -314,6 +316,155 @@ describe("upsertHostConnectionInProfiles", () => { }); }); +describe("relay endpoint security pin", () => { + const pinned: HostConnection = { + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const unpinned: HostConnection = { + id: pinned.id, + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + + it("survives storage", () => { + const profile = normalizeStoredHostProfile({ serverId: "srv", connections: [pinned] }); + expect(profile?.connections[0]).toEqual(pinned); + }); + + it("comes from a pairing offer that requires protected security", () => { + const offer: Parameters[0] = { + v: 2, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }; + expect(relayConnectionFromOffer(offer)).toEqual(pinned); + expect(relayConnectionFromOffer({ ...offer, minimumSecurityMode: undefined })).toEqual( + unpinned, + ); + }); + + it("is kept when the same daemon identity is paired again without it", () => { + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: unpinned, + }); + expect(profiles[0].connections).toEqual([pinned]); + }); + + it("is persisted when a strict offer re-imports an unpinned route", () => { + const profiles = [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ]; + const next = upsertHostConnectionInProfiles({ + profiles, + serverId: "srv", + connection: pinned, + }); + expect(next).not.toBe(profiles); + expect(next[0].connections).toEqual([pinned]); + }); + + it("tightens every route to the same daemon identity when a strict offer arrives through another relay", () => { + const strictElsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ], + serverId: "srv", + connection: strictElsewhere, + }); + expect(profiles[0].connections).toEqual([pinned, strictElsewhere]); + }); + + it("follows the daemon identity to a relay at another address", () => { + const elsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: elsewhere, + }); + expect(profiles[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + ]); + }); + + it("does not carry over to a different daemon identity", () => { + const rotated: HostConnection = { ...unpinned, daemonPublicKeyB64: "pk_b" }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: rotated, + }); + expect(profiles[0].connections).toEqual([rotated]); + }); + + it("is learned for every relay route to the identity that negotiated protected security", () => { + const elsewhere: HostConnection = { + ...unpinned, + id: "relay:wss:other.example.com:443", + relayEndpoint: "other.example.com:443", + }; + const rotated: HostConnection = { + ...unpinned, + id: "relay:wss:rotated.example.com:443", + relayEndpoint: "rotated.example.com:443", + daemonPublicKeyB64: "pk_b", + }; + const profiles = [{ ...makeHost("srv"), connections: [unpinned, elsewhere, rotated] }]; + const next = pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + now: "2026-02-01T00:00:00.000Z", + }); + expect(next[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + rotated, + ]); + expect(next[0].updatedAt).toBe("2026-02-01T00:00:00.000Z"); + + expect( + pinRelayProtectedSecurityInProfiles({ + profiles: next, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + }), + ).toBe(next); + expect( + pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_other", + }), + ).toBe(profiles); + }); +}); + describe("resolveActiveHostServerId", () => { it("uses the selected host when one is set", () => { expect( diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index fb3150a168..e7449b9a67 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -49,6 +49,12 @@ export interface RelayHostConnection { relayEndpoint: string; useTls?: boolean; daemonPublicKeyB64: string; + /** + * Pinned once this daemon identity has negotiated protected endpoint + * security, or when its pairing offer required it. Never cleared by a + * connection failure; a new daemon identity is a different connection. + */ + minimumSecurityMode?: "protected"; } export interface RoutedHostConnection { @@ -192,7 +198,69 @@ function upsertHostConnectionById( replaced = true; } if (!replaced) next.push(connection); - return next; + // A pin is never lost: the replaced record counts as much as the incoming one. + return shareRelaySecurityPin(next, [...connections, connection]); +} + +/** + * The security pin belongs to the daemon identity, not to one relay address. + * Once any relay route to a key is pinned, by a strict offer or a learned + * handshake, every route to that key is pinned. + */ +function shareRelaySecurityPin( + connections: HostConnection[], + pinSources: HostConnection[], +): HostConnection[] { + const pinnedKeys = new Set(); + for (const connection of pinSources) { + if (connection.type === "relay" && connection.minimumSecurityMode === "protected") { + pinnedKeys.add(connection.daemonPublicKeyB64); + } + } + return connections.map((connection) => + connection.type === "relay" && + connection.minimumSecurityMode !== "protected" && + pinnedKeys.has(connection.daemonPublicKeyB64) + ? { ...connection, minimumSecurityMode: "protected" as const } + : connection, + ); +} + +function relaySecurityPinEquals(left: HostConnection, right: HostConnection): boolean { + const pinOf = (connection: HostConnection) => + connection.type === "relay" ? connection.minimumSecurityMode : undefined; + return pinOf(left) === pinOf(right); +} + +/** + * Records that the relay daemon identity `daemonPublicKeyB64` negotiated + * protected endpoint security. Every relay route to that identity is pinned. + * Returns the same array when nothing changes. + */ +export function pinRelayProtectedSecurityInProfiles(input: { + profiles: HostProfile[]; + serverId: string; + daemonPublicKeyB64: string; + now?: string; +}): HostProfile[] { + const now = input.now ?? new Date().toISOString(); + let changed = false; + const next = input.profiles.map((host) => { + if (host.serverId !== input.serverId) return host; + const connections = host.connections.map((connection) => { + if ( + connection.type !== "relay" || + connection.daemonPublicKeyB64 !== input.daemonPublicKeyB64 || + connection.minimumSecurityMode === "protected" + ) { + return connection; + } + changed = true; + return { ...connection, minimumSecurityMode: "protected" as const }; + }); + return changed ? { ...host, connections, updatedAt: now } : host; + }); + return changed ? next : input.profiles; } function hasProfileChanged(input: { @@ -218,7 +286,10 @@ function hasProfileChanged(input: { connections.length !== previous.connections.length || connections.some((candidate, index) => { const old = previous.connections[index]; - return !old || !hostConnectionEquals(candidate, old); + // Identity equality decides profile matching; the pin is policy on top of it. + return ( + !old || !hostConnectionEquals(candidate, old) || !relaySecurityPinEquals(candidate, old) + ); }) ); } @@ -377,6 +448,7 @@ export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConne relayEndpoint, useTls, daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + ...(offer.minimumSecurityMode ? { minimumSecurityMode: offer.minimumSecurityMode } : {}), }; } @@ -441,6 +513,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [ relayEndpoint: z.string(), useTls: z.boolean().optional(), daemonPublicKeyB64: z.string(), + minimumSecurityMode: z.literal("protected").optional(), }), ]); const StoredHostProfileSchema = z.strictObject({ @@ -504,6 +577,9 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec relayEndpoint, ...(useTls !== undefined ? { useTls } : {}), daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumSecurityMode: connection.minimumSecurityMode } + : {}), }; } catch { return null; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 0362aa56de..1acd5d8320 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -262,6 +262,8 @@ describe("replaceFetchedAgentDirectory", () => { workspaceKind: "worktree", name: "repo", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index c3416d19b1..e217f8d271 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -22,6 +22,8 @@ function descriptor( function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 5942b45621..18ffa62dc4 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -202,7 +202,11 @@ export async function buildClientConfig( useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), - e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, + e2ee: { + enabled: true, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode ? { minimumMode: connection.minimumSecurityMode } : {}), + }, }; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 0bed394be0..755a88c91f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -11,6 +11,8 @@ function workspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/utils/workspace-identity.test.ts b/packages/app/src/utils/workspace-identity.test.ts index b3324b939d..1287552ce7 100644 --- a/packages/app/src/utils/workspace-identity.test.ts +++ b/packages/app/src/utils/workspace-identity.test.ts @@ -6,6 +6,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/workspace-status/index.ts b/packages/app/src/workspace-status/index.ts new file mode 100644 index 0000000000..45bf68ab8e --- /dev/null +++ b/packages/app/src/workspace-status/index.ts @@ -0,0 +1,74 @@ +import { useShallow } from "zustand/shallow"; +import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; + +interface EffectiveWorkspaceStatus { + status: WorkspaceDescriptor["status"]; + enteredAt: Date | null; +} + +export function deriveEffectiveWorkspaceStatus(input: { + serverId: string; + workspace: WorkspaceDescriptor; + pendingCreateAttempts?: Record; + workspaceAgentActivity?: ReadonlyMap; +}): EffectiveWorkspaceStatus { + if (input.workspace.status !== "done") { + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; + } + + const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ + serverId: input.serverId, + workspaceId: input.workspace.id, + pendingCreateAttempts: input.pendingCreateAttempts, + }); + if (pendingStartedAt) { + return { status: "running", enteredAt: pendingStartedAt }; + } + + const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); + if (rootAgentActivity && rootAgentActivity.status !== "done") { + return rootAgentActivity; + } + + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; +} + +function getPendingInitialAgentCreateStartedAt(input: { + serverId: string; + workspaceId: string; + pendingCreateAttempts: Record | undefined; +}): Date | null { + let latestStartedAt: Date | null = null; + for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { + if (pending.serverId !== input.serverId) continue; + if (pending.workspaceId !== input.workspaceId) continue; + if (pending.lifecycle === "abandoned") continue; + const startedAt = new Date(pending.timestamp); + if (!latestStartedAt || startedAt > latestStartedAt) { + latestStartedAt = startedAt; + } + } + return latestStartedAt; +} + +export function useWorkspaceStatus(serverId: string, workspaceId: string) { + const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId); + return useSessionStore( + useShallow((state) => { + const session = state.sessions[serverId]; + if (!session) return null; + const key = resolveWorkspaceMapKeyByIdentity({ workspaces: session.workspaces, workspaceId }); + const workspace = key ? session.workspaces.get(key) : null; + if (!workspace) return null; + return deriveEffectiveWorkspaceStatus({ + serverId, + workspace, + pendingCreateAttempts, + workspaceAgentActivity: session.workspaceAgentActivity, + }).status; + }), + ); +} diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts index ab3d1539ab..c32fc91cf9 100644 --- a/packages/app/src/workspace-tabs/navigation.test.ts +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -85,3 +85,26 @@ it("preserves dock visibility intent while compact targets are opened and reveal false, ); }); +it.each(["agent-list", "file-explorer"] as const)( + "preserves %s under a deferred file sheet and reveals deferred agents", + (panel) => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + if (panel === "agent-list") usePanelStore.getState().showMobileAgentList(); + const before = usePanelStore.getState().mobilePanel; + store.deferOpenTab({ + workspaceKey, + target: { kind: "file", path: "pending.ts" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel).toEqual(before); + store.deferOpenTab({ + workspaceKey, + target: { kind: "agent", agentId: "root" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }, +); diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index e7fe660f3b..3facdee34e 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -32,6 +32,8 @@ function archivePayload(input: { function workspace(input?: Partial): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index fd12cd6189..2d7149442e 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -121,10 +121,32 @@ export default defineConfig({ // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { + // Expo's public navigation barrel also loads screens. Match Metro's web-first resolution + // while optimizing its CJS dependencies, including DebugContainer.web.js. + esbuildOptions: { + resolveExtensions: [ + ".web.js", + ".web.jsx", + ".web.ts", + ".web.tsx", + ".js", + ".jsx", + ".ts", + ".tsx", + ".json", + ], + }, // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. include: [ "react/jsx-runtime", "react/compiler-runtime", + "expo-router/react-navigation", + "@testing-library/react", + "@tanstack/react-query", + "react-i18next", + "i18next", + "@marijn/find-cluster-break", + "tiny-invariant", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 2481184da6..8efbc81267 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/server": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/server": "0.13.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index ab208102aa..4636b1841f 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -92,6 +92,7 @@ export async function resolveLocalPairingOffer(options: { relayPublicEndpoint: config.relayPublicEndpoint, relayUseTls: config.relayUseTls, relayPublicUseTls: config.relayPublicUseTls, + relayRequireProtectedSecurity: config.relayRequireProtectedSecurity, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/client/package.json b/packages/client/package.json index 3f5b7c51a5..b0d7961ace 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 237699e600..258c0aa9b5 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,7 +1,9 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { - createClientChannel, - type EncryptedChannel, + openClientSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + type SecureChannel, + type SecurityMode, type Transport as RelayTransport, } from "@getpaseo/relay/e2ee"; import type { @@ -20,39 +22,86 @@ type CloseHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void; type MessageHandler = (data: unknown, isBinary: boolean) => void; -export function createRelayE2eeTransportFactory(args: { - baseFactory: DaemonTransportFactory; +export interface RelayE2eeTransportOptions { daemonPublicKeyB64: string; logger: TransportLogger; assertOutboundCapacity?: (wireBytes: number) => void; -}): DaemonTransportFactory { - return ({ url }) => { - const base = args.baseFactory({ url }); - return createEncryptedTransport( - base, - args.daemonPublicKeyB64, - args.logger, - args.assertOutboundCapacity, - ); - }; + /** Refuse to open unless the daemon negotiates at least this endpoint security mode. */ + minimumMode?: SecurityMode; + /** Reports the negotiated mode once the daemon has authenticated the handshake. */ + onSecurityMode?: (mode: SecurityMode) => void; } -export function createRelayTransportFactory( +export function createRelayE2eeTransportFactory( + args: RelayE2eeTransportOptions & { baseFactory: DaemonTransportFactory }, +): DaemonTransportFactory { + const { baseFactory, ...options } = args; + return ({ url }) => createEncryptedTransport(baseFactory({ url }), options); +} + +const DAEMON_REQUIRES_PROTECTED_REASON = "daemon requires protected endpoint security"; + +/** + * Returns a user-facing message when this client closed the session itself + * because endpoint security could not be established, so reconnecting cannot help. + * + * The daemon's own policy close never reaches the client: both relays replace a + * daemon-side close with their own code (1012 on the wrangler relay). + */ +export function describeEndpointSecurityClose(event: unknown): string | null { + if (!event || typeof event !== "object") return null; + const code = "code" in event ? event.code : undefined; + const reason = "reason" in event && typeof event.reason === "string" ? event.reason : ""; + if (code === PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE) { + return `Endpoint security handshake failed: ${reason || "protected mode unavailable"}`; + } + return null; +} + +/** + * The first session message from the daemon is `server_info`. A daemon that can + * negotiate protected security advertises it there, so a legacy session with such + * a daemon was downgraded in transit and must not proceed to admission. + */ +function readServerInfoProtectedSupport(text: string): boolean | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(parsed) || parsed.type !== "session" || !isRecord(parsed.message)) return null; + const message = parsed.message; + if (message.type !== "status" || !isRecord(message.payload)) return null; + if (message.payload.status !== "server_info") return null; + const features = message.payload.features; + return isRecord(features) && features.relayProtectedSecurity === true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Relay upgrades carry routing only. Daemon credentials and subprotocols must + * never reach the relay; they travel inside the encrypted hello. + */ +export function createRelayUpgradeTransportFactory( baseFactory: DaemonTransportFactory, ): DaemonTransportFactory { - // The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials. return ({ url }) => baseFactory({ url }); } export function createEncryptedTransport( base: DaemonTransport, - daemonPublicKeyB64: string, - logger: TransportLogger, - assertOutboundCapacity: (wireBytes: number) => void = () => {}, + options: RelayE2eeTransportOptions, ): DaemonTransport { - let channel: EncryptedChannel | null = null; + const { daemonPublicKeyB64, logger, minimumMode, onSecurityMode } = options; + const assertOutboundCapacity = options.assertOutboundCapacity ?? (() => {}); + let channel: SecureChannel | null = null; let opened = false; let closed = false; + let awaitingServerInfo = true; const openHandlers = new Set(); const closeHandlers = new Set(); @@ -86,6 +135,20 @@ export function createEncryptedTransport( if (closed) { return; } + if (awaitingServerInfo && typeof data === "string") { + const daemonSupportsProtected = readServerInfoProtectedSupport(data); + if (daemonSupportsProtected !== null) { + awaitingServerInfo = false; + if (daemonSupportsProtected && channel?.securityMode() === "legacy") { + logger.warn( + { reason: DAEMON_REQUIRES_PROTECTED_REASON }, + "relay_e2ee_downgrade_rejected", + ); + channel.close(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, DAEMON_REQUIRES_PROTECTED_REASON); + return; + } + } + } emitHandlers(messageHandlers, data, data instanceof ArrayBuffer); }; @@ -105,7 +168,13 @@ export function createEncryptedTransport( } base.send(String(data)); }, - close: (code?: number, reason?: string) => base.close(code, reason), + // The channel decides the close code. Report it now: a socket close event + // arrives asynchronously, and the consumer must not act on an earlier + // error before it has seen the code. + close: (code?: number, reason?: string) => { + base.close(code, reason); + emitClose({ code, reason }); + }, onmessage: null, onclose: null, onerror: null, @@ -113,11 +182,23 @@ export function createEncryptedTransport( const startHandshake = async () => { try { - channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { - onopen: emitOpen, - onmessage: (data) => emitMessage(data), - onclose: (code, reason) => emitClose({ code, reason }), - onerror: (error) => emitError(error), + channel = await openClientSecureChannel(relayTransport, { + daemonPublicKeyB64, + ...(minimumMode ? { minimumMode } : {}), + events: { + onopen: () => { + onSecurityMode?.(channel?.securityMode() ?? "legacy"); + emitOpen(); + }, + onmessage: (data) => emitMessage(data), + onclose: (code, reason) => emitClose({ code, reason }), + // Every fatal channel error is followed by a close that carries the + // code and reason; forwarding the error first would make the consumer + // reconnect before it sees them. + onerror: (error) => { + logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_channel_error"); + }, + }, }); } catch (error) { logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed"); diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 6f80717dd1..963a8b541c 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -10,17 +10,9 @@ import { extractRelayMessage, } from "./daemon-client-transport.js"; -const createClientChannelMock = vi.hoisted(() => vi.fn()); - -vi.mock("@getpaseo/relay/e2ee", () => ({ - createClientChannel: createClientChannelMock, -})); - describe("daemon-client transport helpers", () => { test("createEncryptedTransport closes handshake failures with browser-safe code", async () => { - createClientChannelMock.mockReset(); - createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - + // An unusable daemon key fails the handshake before any frame is sent. const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); @@ -40,8 +32,7 @@ describe("daemon-client transport helpers", () => { onError: () => () => {}, onMessage: () => () => {}, }, - "daemon-public-key", - { warn: vi.fn() }, + { daemonPublicKeyB64: "not-a-key", logger: { warn: vi.fn() } }, ); expect(connection.open).not.toBeNull(); diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index 01933fd1ec..90c4613f84 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -17,7 +17,8 @@ export { export { createEncryptedTransport, createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, + describeEndpointSecurityClose, } from "./daemon-client-relay-e2ee-transport.js"; export { bindWsHandler, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6fef9fea40..f663aeb8be 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -919,6 +919,29 @@ test("keeps relay upgrade credentials out of the socket request", async () => { await expect(connecting).rejects.toThrow("Daemon client closed"); }); +test("keeps relay upgrade credentials out of the socket request without E2EE", async () => { + const mock = createMockTransport(); + const requests: Array<{ url: string; headers?: Record; protocols?: string[] }> = + []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_relay_header_test", + authHeader: "Bearer synthetic-review-token", + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return mock.transport; + }, + }); + clients.push(client); + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); +}); + test("refuses relay password auth without an encrypted hello", async () => { const requests: unknown[] = []; const client = new DaemonClient({ @@ -4287,6 +4310,40 @@ test("reconnects after relay close with replaced-by-new-connection reason", asyn } }); +test("stops reconnecting after the transport closes for an endpoint security failure", async () => { + const close = { code: 4002, reason: "legacy security below required minimum" }; + const error = "Endpoint security handshake failed: legacy security below required minimum"; + useHeartbeatClock(); + try { + let attempts = 0; + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_close_test", + e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + return mock.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(attempts).toBe(1)); + mock.triggerClose(close); + + await expect(connecting).rejects.toThrow(error); + await vi.advanceTimersByTimeAsync(50); + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(client.lastError).toContain(error); + } finally { + vi.useRealTimers(); + } +}); + test("requires non-empty clientId", () => { expect(() => { const _client = new DaemonClient({ @@ -7898,3 +7955,264 @@ test("initial acquisition preserves structured transport failures and ordinary o cause: failure, }); }); + +// --------------------------------------------------------------------------- +// Relay endpoint security through the real secure channel +// --------------------------------------------------------------------------- + +import { + createDaemonChannel, + exportPublicKey, + generateKeyPair, + openDaemonSecureChannel, + type SecureChannel, + type Transport as RelayWire, +} from "@getpaseo/relay/e2ee"; + +/** + * An in-memory socket pair: the client side is a DaemonTransport, the daemon + * side is the relay Transport the daemon's secure channel runs over. Frames + * are delivered asynchronously like a real socket, and a close from either + * side reaches the other with its code, as the browser WebSocket does. + */ +function createRelayLink(options?: { toDaemon?: (text: string) => string }) { + const clientOpen = new Set<() => void>(); + const clientClose = new Set<(event?: unknown) => void>(); + const clientError = new Set<(event?: unknown) => void>(); + const clientMessage = new Set<(data: unknown, isBinary: boolean) => void>(); + let closed = false; + const wire: RelayWire = { + send: (data) => { + if (closed) throw new Error("socket closed"); + const isBinary = typeof data !== "string"; + setTimeout(() => { + for (const handler of clientMessage) handler(data, isBinary); + }, 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + const shutdown = (code: number, reason: string) => { + if (closed) return; + closed = true; + setTimeout(() => { + wire.onclose?.(code, reason); + for (const handler of clientClose) handler({ code, reason }); + }, 0); + }; + const transport: DaemonTransport = { + send: (data) => { + if (closed) throw new Error("socket closed"); + if (typeof data === "string") { + const text = options?.toDaemon ? options.toDaemon(data) : data; + setTimeout(() => wire.onmessage?.({ data: text, isBinary: false }), 0); + return; + } + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const copy = bytes.slice().buffer as ArrayBuffer; + setTimeout(() => wire.onmessage?.({ data: copy, isBinary: true }), 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onOpen: (handler) => { + clientOpen.add(handler); + return () => clientOpen.delete(handler); + }, + onClose: (handler) => { + clientClose.add(handler); + return () => clientClose.delete(handler); + }, + onError: (handler) => { + clientError.add(handler); + return () => clientError.delete(handler); + }, + onMessage: (handler) => { + clientMessage.add(handler); + return () => clientMessage.delete(handler); + }, + }; + return { + transport, + wire, + open: () => { + for (const handler of clientOpen) handler(); + }, + isClosed: () => closed, + }; +} + +function serverInfoFrame(features: Record): string { + return wrapSessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_test", + hostname: null, + version: null, + features, + }, + }); +} + +/** Opens the daemon side of a link and answers the application hello with the daemon's feature flags. */ +function serveProtectedDaemon( + link: ReturnType, + keys: ReturnType, + features: Record, + received: unknown[] = [], +): void { + let channel: SecureChannel | null = null; + const pendingAnswers: string[] = []; + const opened = openDaemonSecureChannel(link.wire, { + daemonKeyPair: keys, + events: { + onmessage: (data) => { + received.push(data); + if (typeof data !== "string") return; + const frame = JSON.parse(data) as { type?: string }; + if (frame.type !== "hello") return; + const answer = serverInfoFrame(features); + if (channel) void channel.send(answer); + else pendingAnswers.push(answer); + }, + }, + }); + void (async () => { + try { + channel = await opened; + for (const answer of pendingAnswers.splice(0)) void channel.send(answer); + } catch { + // the test asserts on the client side + } + })(); +} + +test("connects in legacy mode to a daemon that only speaks the legacy construction", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_legacy_daemon", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: false }, + transportFactory: () => { + const link = createRelayLink(); + const opened = createDaemonChannel(link.wire, keys, { + onmessage: (data) => { + if (typeof data !== "string") return; + if ((JSON.parse(data) as { type?: string }).type !== "hello") return; + void opened.then((ch) => ch.send(serverInfoFrame({ ownedSubscriptions: true }))); + }, + }); + void opened.catch(() => {}); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["legacy"]); +}); + +test("refuses legacy endpoint security on reconnect once the daemon negotiated protected", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const links: ReturnType[] = []; + const legacyReceived: unknown[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_latch", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + const link = createRelayLink(); + links.push(link); + if (links.length === 1) { + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }); + } else { + // A daemon that only speaks the legacy construction (older release). + void createDaemonChannel(link.wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["protected"]); + + // The relay drops the session; the client reconnects to a legacy-only daemon. + links[0].wire.close(1012, "Server disconnected"); + await vi.waitFor(() => expect(links).toHaveLength(2)); + await vi.waitFor(() => expect(links[1].isClosed()).toBe(true)); + await vi.waitFor(() => expect(client.getConnectionState().status).toBe("disconnected")); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(links).toHaveLength(2); + expect(modes).toEqual(["protected"]); + expect(legacyReceived).toEqual([]); + expect(client.lastError).toContain( + "Endpoint security handshake failed: daemon offered only legacy endpoint security", + ); +}); + +test("closes before admission when a protected-capable daemon is reached in legacy mode", async () => { + const keys = generateKeyPair(); + const daemonReceived: unknown[] = []; + let attempts = 0; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_downgrade", + e2ee: { enabled: true, daemonPublicKeyB64: exportPublicKey(keys.publicKey) }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + // A relay in the middle strips the protected offer from the client hello. + const link = createRelayLink({ + toDaemon: (text) => { + if (!text.startsWith("{")) return text; + const parsed = JSON.parse(text) as Record; + if (parsed.type !== "e2ee_hello") return text; + const { protected: _dropped, ...legacyHello } = parsed; + return JSON.stringify(legacyHello); + }, + }); + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }, daemonReceived); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + const queued = client.ping({ timeoutMs: 10_000 }); + void queued.catch(() => {}); + + await expect(connecting).rejects.toThrow( + "Endpoint security handshake failed: daemon requires protected endpoint security", + ); + await expect(queued).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + // Only the application hello reached the daemon; the queued command never did. + expect(daemonReceived).toHaveLength(1); + expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index de127c5b6d..524df56704 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -162,10 +162,11 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, + describeEndpointSecurityClose, describeTransportClose, describeTransportError, type DaemonTransport, @@ -394,6 +395,9 @@ export type DaemonEventHandler = (event: DaemonEvent) => void; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; +/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ +export type RelaySecurityMode = "legacy" | "protected"; + export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ @@ -415,6 +419,10 @@ export interface DaemonClientConfig { e2ee?: { enabled?: boolean; daemonPublicKeyB64?: string; + /** Refuse connections whose negotiated endpoint security is below this mode. */ + minimumMode?: RelaySecurityMode; + /** Called with the negotiated mode after each authenticated relay handshake. */ + onSecurityMode?: (mode: RelaySecurityMode) => void; }; reconnect?: { enabled?: boolean; @@ -1334,6 +1342,12 @@ export class DaemonClient { private completedBinaryFileReads = new Map(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; + /** + * Lowest relay endpoint security this client accepts on its next attempt. + * Starts at the configured minimum and rises to "protected" once a daemon + * negotiates it, so a later reconnect cannot be downgraded to legacy. + */ + private relaySecurityFloorValue: RelaySecurityMode | undefined; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; @@ -1349,6 +1363,7 @@ export class DaemonClient { constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; + this.relaySecurityFloorValue = config.e2ee?.minimumMode; this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct"; let parsedUrlForLog: URL | null = null; try { @@ -1450,14 +1465,15 @@ export class DaemonClient { this.config.transportFactory ?? createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory); const isRelayTransport = isRelayClientWebSocketUrl(this.config.url); - const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport; this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport); + // Relay upgrades are routing only: credentials never reach the relay socket. let transportFactory = isRelayTransport - ? createRelayTransportFactory(baseTransportFactory) + ? createRelayUpgradeTransportFactory(baseTransportFactory) : baseTransportFactory; - if (shouldUseRelayE2ee) { - const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; + // Endpoint security is selected by configuration, never inferred from the URL. + if (this.config.e2ee?.enabled === true) { + const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64; if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required for relay E2EE"); } @@ -1465,6 +1481,11 @@ export class DaemonClient { baseFactory: transportFactory, daemonPublicKeyB64, logger: this.logger, + ...(this.relaySecurityFloorValue ? { minimumMode: this.relaySecurityFloorValue } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") this.relaySecurityFloorValue = "protected"; + this.config.e2ee?.onSecurityMode?.(mode); + }, }); } const transportUrl = this.resolveTransportUrlForAttempt(); @@ -1522,9 +1543,12 @@ export class DaemonClient { this.pendingGenericTransportErrorTimeout = null; } this.authFailureReasonValue ??= authFailureFromLegacyClose(event); + // An endpoint security policy close cannot be fixed by retrying. + const securityFailure = describeEndpointSecurityClose(event); + if (securityFailure) this.setReconnectEnabled(false); const reason = this.authFailureReasonValue ? new DaemonAuthenticationError(this.authFailureReasonValue).message - : describeTransportClose(event); + : (securityFailure ?? describeTransportClose(event)); if (reason) { this.lastErrorValue = reason; } @@ -1826,6 +1850,11 @@ export class DaemonClient { return this.connectionState.status === "connecting"; } + /** The relay endpoint security this client requires on its next attempt. */ + get relaySecurityFloor(): RelaySecurityMode | undefined { + return this.relaySecurityFloorValue; + } + get lastError(): string | null { return this.lastErrorValue; } diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts index 6b31107f24..6e916d4bec 100644 --- a/packages/client/src/node/connector.test.ts +++ b/packages/client/src/node/connector.test.ts @@ -151,3 +151,59 @@ test("encrypted connector rejects overload synchronously and preserves the physi ).toBe(true); expect(server.wss.clients.size).toBe(1); }); + +import { openDaemonSecureChannel } from "@getpaseo/relay/e2ee"; +test("encrypted connector refuses legacy endpoint security on a later attempt once protected was negotiated", async () => { + const server = await endpoint(); + const keys = generateKeyPair(); + const legacyReceived: unknown[] = []; + let connections = 0; + server.wss.on("connection", (socket) => { + connections += 1; + const wire: Transport = { + send: (data) => socket.send(data), + close: (code, reason) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + socket.on("message", (data, isBinary) => { + const bytes = Buffer.from(data as Buffer); + wire.onmessage?.({ + data: isBinary + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : bytes.toString(), + isBinary, + }); + }); + socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString())); + if (connections === 1) { + void openDaemonSecureChannel(wire, { daemonKeyPair: keys }).catch(() => {}); + } else { + // The daemon behind the relay now only speaks the legacy construction. + void createDaemonChannel(wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + }); + const port = new URL(server.url).port; + const factory = createHostTransportFactory( + `relay://127.0.0.1:${port}/srv_latch?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + ); + + const first = factory({ url: server.url }); + cleanup.push(async () => first.close()); + await new Promise((resolve) => first.onOpen(resolve)); + first.close(); + + const second = factory({ url: server.url }); + cleanup.push(async () => second.close()); + let opened = false; + second.onOpen(() => { + opened = true; + }); + const closed = await new Promise((resolve) => second.onClose(resolve)); + expect(closed).toMatchObject({ code: 4002, reason: expect.stringContaining("legacy") }); + expect(opened).toBe(false); + expect(legacyReceived).toEqual([]); +}); diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 857c2ba432..7132d09f10 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -7,6 +7,7 @@ import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js"; import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { DaemonTransport, DaemonTransportFactory, @@ -24,6 +25,10 @@ export function createHostTransportFactory( budget = new HostTransportBudget(), ): DaemonTransportFactory { const target = parseHostTarget(connection); + // Rises to "protected" once the daemon negotiates it, so a later attempt + // cannot be downgraded to legacy endpoint security. + let securityFloor: SecurityMode | undefined = + target.kind === "socket" ? target.offer?.minimumSecurityMode : undefined; return (attemptOptions) => { const opens = new Set<() => void>(); const closes = new Set<(event?: unknown) => void>(); @@ -120,6 +125,10 @@ export function createHostTransportFactory( factory = createRelayE2eeTransportFactory({ baseFactory: factory, daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64, + ...(securityFloor ? { minimumMode: securityFloor } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") securityFloor = "protected"; + }, logger: { warn() {} }, assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes), }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cbeffbb56d..8da5decfa4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b6392bafa1..5c265cc23c 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index f714608069..b7c478bce3 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b6f1243c6c..17f9f76bd5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index d69f5f44b0..e2547154bc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -70,6 +70,26 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien return violations; } +function typeErrors(code: string): string[] { + const configPath = path.resolve(sourceDirectory, "../tsconfig.examples.json"); + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath)); + const file = path.resolve( + sourceDirectory, + "../../../plugin-examples/skill-example/client/example.tsx", + ); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile; + host.getSourceFile = (name, language, ...rest) => + path.resolve(name) === file + ? ts.createSourceFile(name, code, language) + : getSourceFile.call(host, name, language, ...rest); + host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); @@ -142,3 +162,33 @@ describe("plugin example import boundaries", () => { expect(violations).toEqual([]); }); }); + +describe("plugin panel SDK boundary", () => { + it("accepts uniform params and persisted tab state alongside 0.12 panels", () => { + expect( + typeErrors(` + import { type PluginClientContext, type PluginFilePanelProps, type PluginWorkspacePanelProps, type PluginAgentPanelProps, type PluginTab, useTabState } from "@getpaseo/plugin/client"; + import { z } from "zod"; + function FilePanel({ params, revision, tab, file }: PluginFilePanelProps) { + const [state, setState] = useTabState(tab, z.object({ view: z.enum(["preview", "source"]) }), { view: "preview" }); + setState({ view: "source" }); + tab.setState({ nested: [true, null, 3] }); + // @ts-expect-error only JSON state crosses the host boundary + tab.setState(() => {}); + // @ts-expect-error params belong to the opener + params.path = "other"; + return null; + } + function OldWorkspace({ context, workspaceId }: PluginWorkspacePanelProps) { return null; } + function OldAgent({ context, workspaceId, agentId }: PluginAgentPanelProps) { return null; } + function Agent({ params }: PluginAgentPanelProps) { params.agentId.toUpperCase(); return null; } + function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ id: "old-workspace", title: "Old", icon: "File", context: "workspace", Component: OldWorkspace }); + client.addWorkspacePanel({ id: "old-agent", title: "Old", icon: "File", context: "agent", Component: OldAgent }); + client.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component: OldWorkspace }); + client.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component: Agent }); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 5e268d0849..7675a655ff 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,9 +1,9 @@ import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "./file.js"; +import type { PluginFile } from "./file.js"; import type { PluginRpcContract } from "../rpc.js"; import type { PluginButtonRegistration, @@ -111,23 +111,44 @@ interface PluginWorkspacePanelBase { locations?: readonly PluginPanelLocation[]; } -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; +export interface PluginTab { + /** Shows the tab's unsaved dot and asks before closing. */ + setModified(modified: boolean): void; + /** Opaque persisted JSON. Undefined until written; the panel validates it. */ + readonly state: unknown; + setState(state: JsonValue): void; } -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; +export interface PluginPanelProps extends PluginNavigableHostProps { workspaceId: string; + readonly params: Readonly; + revision: number; + tab: PluginTab; +} +export interface PluginWorkspacePanelParams {} +export interface PluginAgentPanelParams { + readonly agentId: string; +} +export interface PluginFilePanelParams { + readonly path: string; + readonly lineStart?: number; + readonly lineEnd?: number; +} +export interface PluginWorkspacePanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "workspace"; +} +export interface PluginAgentPanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "agent"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Read `params.agentId`. */ agentId: string; } - -export interface PluginFilePanelProps extends PluginNavigableHostProps { - workspaceId: string; - /** The file itself. The panel decides how to read it, show it, and save it. */ +export interface PluginFilePanelProps extends PluginPanelProps { file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; } export interface PluginFilePanelContribution { @@ -160,6 +181,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl addScreen(contribution: PluginScreenContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addAgentPanel(contribution: PluginAgentPanelContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; addCommandCenterItem( contribution: PluginCommandCenterItemContribution, @@ -187,14 +209,24 @@ export type PluginClientContribution = (client: PluginClientContext) => PluginCl export type PluginWorkspacePanelContribution = | (PluginWorkspacePanelBase & { - context: "workspace"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Omit context. */ + context?: "workspace"; Component: ComponentType; }) | (PluginWorkspacePanelBase & { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Register with `addAgentPanel`. */ context: "agent"; Component: ComponentType; }); +export interface PluginAgentPanelContribution< + Text extends PluginText = string, +> extends PluginWorkspacePanelBase { + Component: ComponentType; +} + export interface PluginSettingsScreenContribution { id: string; title: Text; diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts index 2c11fcdd0a..7f37e31c9c 100644 --- a/packages/plugin/src/client/file.ts +++ b/packages/plugin/src/client/file.ts @@ -44,19 +44,6 @@ export interface PluginFileWriteOptions { /** What happened to the file on disk. Switch on `type`; later versions may add fields. */ export type PluginFileEvent = { type: "changed" } | { type: "deleted" }; -/** What the panel reports back to its tab. */ -export interface PluginFileTab { - /** Shows the tab's unsaved dot and asks before the tab closes. */ - setModified(modified: boolean): void; -} - -/** The line a link or search result opened the file at. `revision` bumps on every new target. */ -export interface PluginFileLocation { - lineStart?: number; - lineEnd?: number; - revision: number; -} - /** The file changed on disk since the handle last read or wrote it. */ export class PluginFileConflictError extends Error { constructor(message = "The file changed on disk.") { diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index db4292453e..62cf7e2dc3 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -1,5 +1,11 @@ export type { PluginHostProps, + PluginTab, + PluginPanelProps, + PluginWorkspacePanelParams, + PluginAgentPanelParams, + PluginFilePanelParams, + PluginAgentPanelContribution, PluginFilePanelContribution, PluginFilePanelProps, PluginSurfaceProps, @@ -52,8 +58,6 @@ export type { PluginFile, PluginFileBytesState, PluginFileEvent, - PluginFileLocation, - PluginFileTab, PluginFileTextState, PluginFileUrlState, PluginFileWriteOptions, @@ -91,3 +95,5 @@ export declare function openExternalUrl(url: string): Promise; export { defineTranslations, useTranslation } from "./translations.js"; export type { PluginI18n } from "./translations.js"; export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; + +export { useTabState } from "./tab.js"; diff --git a/packages/plugin/src/client/tab.ts b/packages/plugin/src/client/tab.ts new file mode 100644 index 0000000000..a0d11058a0 --- /dev/null +++ b/packages/plugin/src/client/tab.ts @@ -0,0 +1,16 @@ +import { useCallback, useMemo } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { ZodType } from "zod"; +import type { PluginTab } from "./contracts.js"; + +/** Validates the panel's persisted state, using the default for missing or invalid values. */ +export function useTabState( + tab: PluginTab, + schema: ZodType, + defaultState: T, +): [T, (next: T) => void] { + const parsed = useMemo(() => schema.safeParse(tab.state), [schema, tab.state]); + const writeState = tab.setState; + const setState = useCallback((next: T) => writeState(next), [writeState]); + return [parsed.success ? parsed.data : defaultState, setState]; +} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index f089ca926b..75948ccf72 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/connection-offer.ts b/packages/protocol/src/connection-offer.ts index 073cf0be0c..3b5d455ad6 100644 --- a/packages/protocol/src/connection-offer.ts +++ b/packages/protocol/src/connection-offer.ts @@ -14,6 +14,9 @@ export const ConnectionOfferV2Schema = z.object({ endpoint: z.string().min(1), useTls: z.boolean().optional(), }), + // COMPAT(relayProtectedSecurity): added in v0.13.0. Present when the daemon requires + // protected endpoint security; clients pin it before their first handshake. + minimumSecurityMode: z.literal("protected").optional(), }); export type ConnectionOfferV2 = z.infer; diff --git a/packages/protocol/src/daemon-endpoints.test.ts b/packages/protocol/src/daemon-endpoints.test.ts index 3ac1c111ac..58c08f95ed 100644 --- a/packages/protocol/src/daemon-endpoints.test.ts +++ b/packages/protocol/src/daemon-endpoints.test.ts @@ -232,3 +232,40 @@ describe("relay connection URI", () => { ).toEqual(parts); }); }); + +describe("relay connection URI security mode", () => { + test("round-trips a protected minimum security mode", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }, + }); + + expect(new URL(uri).searchParams.get("security")).toBe("protected"); + expect(parseRelayConnectionUri(uri).offer.minimumSecurityMode).toBe("protected"); + }); + + test("omits the security parameter when the offer has no minimum", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + }, + }); + + expect(new URL(uri).searchParams.has("security")).toBe(false); + expect(parseRelayConnectionUri(uri).offer).not.toHaveProperty("minimumSecurityMode"); + }); + + test("rejects an unknown security mode instead of silently downgrading", () => { + expect(() => + parseRelayConnectionUri("relay://relay.example.com:443/srv_test?key=k&security=legacy"), + ).toThrow("unsupported security mode"); + }); +}); diff --git a/packages/protocol/src/daemon-endpoints.ts b/packages/protocol/src/daemon-endpoints.ts index a4b72c9416..5c4270bcb9 100644 --- a/packages/protocol/src/daemon-endpoints.ts +++ b/packages/protocol/src/daemon-endpoints.ts @@ -42,12 +42,17 @@ export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri } const endpoint = normalizeHostPort(url.host); const password = url.searchParams.get("password") || undefined; + const security = url.searchParams.get("security"); + if (security !== null && security !== "protected") { + throw new Error("Relay connection URI has an unsupported security mode"); + } return { offer: { v: 2, serverId, daemonPublicKeyB64: key, relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" }, + ...(security === "protected" ? { minimumSecurityMode: "protected" as const } : {}), }, ...(password ? { password } : {}), }; @@ -59,6 +64,9 @@ export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): st ); url.searchParams.set("key", parts.offer.daemonPublicKeyB64); if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true"); + if (parts.offer.minimumSecurityMode) { + url.searchParams.set("security", parts.offer.minimumSecurityMode); + } if (parts.password) url.searchParams.set("password", parts.password); return url.toString(); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3e5deeb88e..4beaf0a0b2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -187,6 +187,9 @@ const MutableBrowserToolsConfigSchema = z const MutableRelayConfigSchema = z .object({ enabled: z.boolean(), + // COMPAT(relayProtectedSecurity): added in v0.13.0, keep optional while old daemons are supported. + // When true the daemon admits only relay sessions that negotiated protected endpoint security. + requireProtectedSecurity: z.boolean().optional(), }) .passthrough(); @@ -3745,6 +3748,9 @@ export const ServerInfoStatusPayloadSchema = z workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), + // COMPAT(relayProtectedSecurity): added in v0.13.0. A capable daemon always advertises + // it; relay clients that negotiated legacy security reject the connection on seeing it. + relayProtectedSecurity: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. projectGithubClone: z.boolean().optional(), // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. @@ -3775,6 +3781,7 @@ export const ServerInfoStatusPayloadSchema = z explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), + workspaceSortTimes: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), @@ -4120,6 +4127,8 @@ export const WorkspaceDescriptorPayloadSchema = z .nullish() .transform((value) => value ?? null), activityAt: z.string().nullable(), + createdAt: z.string().optional(), + lastUserMessageAt: z.string().nullable().optional(), diffStat: z .object({ additions: z.number(), diff --git a/packages/relay/package.json b/packages/relay/package.json index 6b28a6e5d3..8f5c495a83 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", @@ -35,6 +35,9 @@ "test:watch": "vitest" }, "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts index c3a3b852fb..231e517058 100644 --- a/packages/relay/src/e2ee.ts +++ b/packages/relay/src/e2ee.ts @@ -1,3 +1,20 @@ +export { + openClientSecureChannel, + openDaemonSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + PROTECTED_RECORD_FAILED_CLOSE_CODE, + SecureChannelRejectedError, +} from "./secure-channel.js"; +export type { + ClientSecureChannelOptions, + DaemonSecureChannelOptions, + SecureChannel, + SecureChannelEvents, + SecurityMode, +} from "./secure-channel.js"; + export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index b9f455e0f1..9f8dbc43d8 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -16,4 +16,5 @@ export { EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js"; +export { maxSecuredPlaintextByteLength } from "./secure-channel.js"; export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/noise/cipher-state.ts b/packages/relay/src/noise/cipher-state.ts new file mode 100644 index 0000000000..e54ab6bc07 --- /dev/null +++ b/packages/relay/src/noise/cipher-state.ts @@ -0,0 +1,76 @@ +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; + +/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */ + +export const NOISE_TAG_BYTES = 16; +export const NOISE_MAX_MESSAGE_BYTES = 65535; +const MAX_NONCE = (1n << 64n) - 1n; + +export class NoiseProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "NoiseProtocolError"; + } +} + +export class CipherState { + private k: Uint8Array | null = null; + private n = 0n; + + initializeKey(key: Uint8Array | null, nonce = 0n): void { + this.k = key; + this.n = nonce; + } + + hasKey(): boolean { + return this.k !== null; + } + + nonce(): bigint { + return this.n; + } + + encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array { + if (this.k === null) return plaintext; + const nonce = this.takeNonce(); + return chacha20poly1305(this.k, nonce, ad).encrypt(plaintext); + } + + decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array { + if (this.k === null) return ciphertext; + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + let plaintext: Uint8Array; + try { + plaintext = chacha20poly1305(this.k, encodeNonce(this.n), ad).decrypt(ciphertext); + } catch { + throw new NoiseProtocolError("authentication failed"); + } + // Only a successful authentication advances the receive counter. + this.n += 1n; + return plaintext; + } + + /** Reserves the next nonce synchronously so concurrent senders never share one. */ + private takeNonce(): Uint8Array { + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + const nonce = encodeNonce(this.n); + this.n += 1n; + return nonce; + } + + clear(): void { + this.k?.fill(0); + this.k = null; + this.n = MAX_NONCE; + } +} + +function encodeNonce(n: bigint): Uint8Array { + const nonce = new Uint8Array(12); + let value = n; + for (let i = 4; i < 12; i += 1) { + nonce[i] = Number(value & 0xffn); + value >>= 8n; + } + return nonce; +} diff --git a/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json new file mode 100644 index 0000000000..07b258c384 --- /dev/null +++ b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json @@ -0,0 +1,85 @@ +{ + "vectors": [ + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47d2891e5ea18ae136c3b4c23ac384efb0" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451c9afa7f4cdbbcff00c04a8ee0913c88" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/cacophony.txt" + }, + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "50726f6c6f677565313233", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "50726f6c6f677565313233", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47f49a76e297aa164052a3d842aa8ff7d8" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451628b075da65d4114bd343e2d93006c4" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "handshake_hash": "d5c4ce9ffe8bcd940aa50f842a5d4d90d3f7163f4b3916deb87a5d747712d718", + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/noise-c-basic.txt" + } + ] +} diff --git a/packages/relay/src/noise/index.ts b/packages/relay/src/noise/index.ts new file mode 100644 index 0000000000..758d6a540b --- /dev/null +++ b/packages/relay/src/noise/index.ts @@ -0,0 +1,241 @@ +/** + * Conforming Noise_NK_25519_ChaChaPoly_SHA256 (Noise Protocol Framework rev 34). + * + * The pattern is executed token by token from its definition, so the vector + * tests exercise the same code path as production. Only NK is provided; the + * daemon's static X25519 key is the responder's `s`, known to the initiator + * from pairing. + * + * NK: + * <- s + * ... + * -> e, es + * <- e, ee + */ +import { x25519 } from "@noble/curves/ed25519.js"; +import { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES } from "./cipher-state.js"; +import { SymmetricState, concat } from "./symmetric-state.js"; + +export { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./cipher-state.js"; + +export const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256"; +export const DH_BYTES = 32; + +export interface NoiseKeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +export interface NoiseInitiatorOptions { + /** Responder's static public key, trusted out of band. */ + remoteStatic: Uint8Array; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface NoiseResponderOptions { + localStatic: NoiseKeyPair; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface TransportKeys { + /** Initiator → responder. */ + send: CipherState; + /** Responder → initiator. */ + receive: CipherState; +} + +export interface NoiseHandshake { + /** Builds the next handshake message this side is due to write. */ + writeMessage(payload: Uint8Array): Uint8Array; + /** Consumes the next handshake message this side is due to read; returns its payload. */ + readMessage(message: Uint8Array): Uint8Array; + /** True once both handshake messages have been processed. */ + isComplete(): boolean; + handshakeHash(): Uint8Array; + /** Derives transport keys, oriented for this side. Ends the handshake state. */ + split(): { send: CipherState; receive: CipherState }; +} + +type Token = "e" | "es" | "ee"; +const NK_MESSAGES: ReadonlyArray> = [ + ["e", "es"], + ["e", "ee"], +]; + +export function generateNoiseKeyPair(): NoiseKeyPair { + const secretKey = x25519.utils.randomSecretKey(); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +export function createNoiseInitiator(options: NoiseInitiatorOptions): NoiseHandshake { + requireKeyLength(options.remoteStatic, "remote static key"); + return new HandshakeState({ + initiator: true, + prologue: options.prologue, + s: null, + rs: options.remoteStatic, + ephemeral: options.ephemeral ?? null, + }); +} + +export function createNoiseResponder(options: NoiseResponderOptions): NoiseHandshake { + requireKeyLength(options.localStatic.publicKey, "local static public key"); + requireKeyLength(options.localStatic.secretKey, "local static secret key"); + return new HandshakeState({ + initiator: false, + prologue: options.prologue, + s: options.localStatic, + rs: null, + ephemeral: options.ephemeral ?? null, + }); +} + +class HandshakeState implements NoiseHandshake { + private readonly symmetric = new SymmetricState(NOISE_NK_PROTOCOL_NAME); + private readonly initiator: boolean; + private readonly s: NoiseKeyPair | null; + private e: NoiseKeyPair | null; + private rs: Uint8Array | null; + private re: Uint8Array | null = null; + private messageIndex = 0; + private finished = false; + + constructor(params: { + initiator: boolean; + prologue: Uint8Array; + s: NoiseKeyPair | null; + rs: Uint8Array | null; + ephemeral: NoiseKeyPair | null; + }) { + this.initiator = params.initiator; + this.s = params.s; + this.rs = params.rs; + this.e = params.ephemeral; + this.symmetric.mixHash(params.prologue); + // Pre-message "<- s": the responder's static public key. + const preMessageStatic = this.initiator ? this.rs : this.s?.publicKey; + if (!preMessageStatic) throw new NoiseProtocolError("missing responder static key"); + this.symmetric.mixHash(preMessageStatic); + } + + writeMessage(payload: Uint8Array): Uint8Array { + const tokens = this.nextTokens(this.initiator); + const parts: Uint8Array[] = []; + for (const token of tokens) { + switch (token) { + case "e": { + if (!this.e) this.e = generateNoiseKeyPair(); + parts.push(this.e.publicKey); + this.symmetric.mixHash(this.e.publicKey); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + parts.push(this.symmetric.encryptAndHash(payload)); + const message = concat(...parts); + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + this.advance(); + return message; + } + + readMessage(message: Uint8Array): Uint8Array { + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + const tokens = this.nextTokens(!this.initiator); + let offset = 0; + for (const token of tokens) { + switch (token) { + case "e": { + if (message.byteLength < offset + DH_BYTES) { + throw new NoiseProtocolError("handshake message truncated"); + } + this.re = message.slice(offset, offset + DH_BYTES); + offset += DH_BYTES; + this.symmetric.mixHash(this.re); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + const payload = this.symmetric.decryptAndHash(message.slice(offset)); + this.advance(); + return payload; + } + + isComplete(): boolean { + return this.messageIndex >= NK_MESSAGES.length; + } + + handshakeHash(): Uint8Array { + return this.symmetric.handshakeHash(); + } + + split(): { send: CipherState; receive: CipherState } { + if (!this.isComplete()) throw new NoiseProtocolError("handshake incomplete"); + if (this.finished) throw new NoiseProtocolError("handshake already split"); + this.finished = true; + const [c1, c2] = this.symmetric.split(); + this.e?.secretKey.fill(0); + this.e = null; + return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 }; + } + + private nextTokens(expectedWriterIsInitiator: boolean): ReadonlyArray { + if (this.isComplete() || this.finished) throw new NoiseProtocolError("handshake complete"); + const writerIsInitiator = this.messageIndex % 2 === 0; + if (writerIsInitiator !== expectedWriterIsInitiator) { + throw new NoiseProtocolError("handshake message out of turn"); + } + return NK_MESSAGES[this.messageIndex]; + } + + private advance(): void { + this.messageIndex += 1; + } + + private mixDh(local: NoiseKeyPair | null, remote: Uint8Array | null): void { + if (!local || !remote) throw new NoiseProtocolError("missing DH key"); + let shared: Uint8Array; + try { + shared = x25519.getSharedSecret(local.secretKey, remote); + } catch { + throw new NoiseProtocolError("invalid DH public key"); + } + if (isAllZero(shared)) throw new NoiseProtocolError("invalid DH public key"); + this.symmetric.mixKey(shared); + shared.fill(0); + } +} + +function requireKeyLength(key: Uint8Array, label: string): void { + if (key.byteLength !== DH_BYTES) throw new NoiseProtocolError(`${label} must be 32 bytes`); +} + +function isAllZero(bytes: Uint8Array): boolean { + let acc = 0; + for (const byte of bytes) acc |= byte; + return acc === 0; +} diff --git a/packages/relay/src/noise/noise.test.ts b/packages/relay/src/noise/noise.test.ts new file mode 100644 index 0000000000..b8d1228d1a --- /dev/null +++ b/packages/relay/src/noise/noise.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vitest"; +import { + CipherState, + createNoiseInitiator, + createNoiseResponder, + generateNoiseKeyPair, + NoiseProtocolError, + NOISE_NK_PROTOCOL_NAME, +} from "./index.js"; +import vectors from "./fixtures/nk-25519-chachapoly-sha256.json" with { type: "json" }; +import { x25519 } from "@noble/curves/ed25519.js"; + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} +function fromHex(hexText: string): Uint8Array { + return new Uint8Array(Buffer.from(hexText, "hex")); +} +function keyPairFromSecret(secretHex: string) { + const secretKey = fromHex(secretHex); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +describe(NOISE_NK_PROTOCOL_NAME, () => { + for (const vector of vectors.vectors) { + test(`matches vector from ${vector.source}`, () => { + expect(vector.name).toBe(NOISE_NK_PROTOCOL_NAME); + const responderStatic = keyPairFromSecret(vector.resp_static); + expect(hex(responderStatic.publicKey)).toBe(vector.init_remote_static); + const initiator = createNoiseInitiator({ + remoteStatic: fromHex(vector.init_remote_static), + prologue: fromHex(vector.init_prologue), + ephemeral: keyPairFromSecret(vector.init_ephemeral), + }); + const responder = createNoiseResponder({ + localStatic: responderStatic, + prologue: fromHex(vector.resp_prologue), + ephemeral: keyPairFromSecret(vector.resp_ephemeral), + }); + + const [m1, m2, ...transport] = vector.messages; + const msg1 = initiator.writeMessage(fromHex(m1.payload)); + expect(hex(msg1)).toBe(m1.ciphertext); + expect(hex(responder.readMessage(msg1))).toBe(m1.payload); + const msg2 = responder.writeMessage(fromHex(m2.payload)); + expect(hex(msg2)).toBe(m2.ciphertext); + expect(hex(initiator.readMessage(msg2))).toBe(m2.payload); + expect(initiator.isComplete()).toBe(true); + expect(responder.isComplete()).toBe(true); + if ("handshake_hash" in vector) { + expect(hex(initiator.handshakeHash())).toBe(vector.handshake_hash); + expect(hex(responder.handshakeHash())).toBe(vector.handshake_hash); + } + + const initiatorKeys = initiator.split(); + const responderKeys = responder.split(); + const empty = new Uint8Array(0); + transport.forEach((message, index) => { + const fromInitiator = index % 2 === 0; + const sender = fromInitiator ? initiatorKeys.send : responderKeys.send; + const receiver = fromInitiator ? responderKeys.receive : initiatorKeys.receive; + const ciphertext = sender.encryptWithAd(empty, fromHex(message.payload)); + expect(hex(ciphertext)).toBe(message.ciphertext); + expect(hex(receiver.decryptWithAd(empty, ciphertext))).toBe(message.payload); + }); + }); + } + + test("rejects a replayed, reflected, or out-of-order transport message", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const empty = new Uint8Array(0); + responder.readMessage(initiator.writeMessage(empty)); + initiator.readMessage(responder.writeMessage(empty)); + const client = initiator.split(); + const server = responder.split(); + + const first = client.send.encryptWithAd(empty, new Uint8Array([1])); + const second = client.send.encryptWithAd(empty, new Uint8Array([2])); + expect(server.receive.decryptWithAd(empty, first)).toEqual(new Uint8Array([1])); + // duplicate + expect(() => server.receive.decryptWithAd(empty, first)).toThrow(NoiseProtocolError); + // a failed attempt did not advance the counter: the genuine next record still decrypts + expect(server.receive.decryptWithAd(empty, second)).toEqual(new Uint8Array([2])); + // reflection: a record the client sent, handed back to the client + expect(() => client.receive.decryptWithAd(empty, second)).toThrow(NoiseProtocolError); + // out of order + const third = client.send.encryptWithAd(empty, new Uint8Array([3])); + const fourth = client.send.encryptWithAd(empty, new Uint8Array([4])); + expect(() => server.receive.decryptWithAd(empty, fourth)).toThrow(NoiseProtocolError); + expect(server.receive.decryptWithAd(empty, third)).toEqual(new Uint8Array([3])); + expect(server.receive.decryptWithAd(empty, fourth)).toEqual(new Uint8Array([4])); + }); + + test("a recorded first message cannot derive the keys of a fresh responder", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const msg1 = initiator.writeMessage(empty); + const original = createNoiseResponder({ localStatic: daemon, prologue }); + original.readMessage(msg1); + initiator.readMessage(original.writeMessage(empty)); + const clientKeys = initiator.split(); + const record = clientKeys.send.encryptWithAd(empty, new Uint8Array([7])); + + const replayTarget = createNoiseResponder({ localStatic: daemon, prologue }); + replayTarget.readMessage(msg1); + replayTarget.writeMessage(empty); + const replayKeys = replayTarget.split(); + expect(() => replayKeys.receive.decryptWithAd(empty, record)).toThrow(NoiseProtocolError); + }); + + test("rejects a low-order remote ephemeral and a wrong responder key", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const zeroEphemeral = new Uint8Array(32 + 16); + expect(() => responder.readMessage(zeroEphemeral)).toThrow(NoiseProtocolError); + + const other = generateNoiseKeyPair(); + const initiator = createNoiseInitiator({ remoteStatic: other.publicKey, prologue }); + const victim = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => victim.readMessage(initiator.writeMessage(empty))).toThrow(NoiseProtocolError); + }); + + test("stops at the last nonce in both directions and keeps state on a failed tag", () => { + const MAX = (1n << 64n) - 1n; + const key = new Uint8Array(32).map((_, i) => i + 1); + const ad = new Uint8Array(0); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice(), MAX - 1n); + receiver.initializeKey(key.slice(), MAX - 1n); + const last = sender.encryptWithAd(ad, new Uint8Array([1, 2, 3])); + expect(sender.nonce()).toBe(MAX); + expect(() => sender.encryptWithAd(ad, new Uint8Array([4]))).toThrow("nonce exhausted"); + expect(sender.nonce()).toBe(MAX); + + const tampered = last.slice(); + tampered[0] ^= 1; + expect(() => receiver.decryptWithAd(ad, tampered)).toThrow("authentication failed"); + expect(receiver.nonce()).toBe(MAX - 1n); + expect(Array.from(receiver.decryptWithAd(ad, last))).toEqual([1, 2, 3]); + expect(receiver.nonce()).toBe(MAX); + expect(() => receiver.decryptWithAd(ad, last)).toThrow("nonce exhausted"); + }); + + test("refuses to proceed out of turn or after completion", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new Uint8Array(0); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => responder.writeMessage(empty)).toThrow(NoiseProtocolError); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + responder.readMessage(initiator.writeMessage(empty)); + expect(() => initiator.writeMessage(empty)).toThrow(NoiseProtocolError); + initiator.readMessage(responder.writeMessage(empty)); + expect(() => initiator.readMessage(empty)).toThrow(NoiseProtocolError); + initiator.split(); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/noise/symmetric-state.ts b/packages/relay/src/noise/symmetric-state.ts new file mode 100644 index 0000000000..a4afcc3c40 --- /dev/null +++ b/packages/relay/src/noise/symmetric-state.ts @@ -0,0 +1,83 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from "@noble/hashes/hmac.js"; +import { CipherState } from "./cipher-state.js"; + +/** Noise spec §5.2 SymmetricState for SHA256 (HASHLEN 32). */ + +const HASHLEN = 32; +const EMPTY = new Uint8Array(0); + +export class SymmetricState { + readonly cipher = new CipherState(); + private ck: Uint8Array; + private h: Uint8Array; + + constructor(protocolName: string) { + const name = new TextEncoder().encode(protocolName); + if (name.byteLength <= HASHLEN) { + this.h = new Uint8Array(HASHLEN); + this.h.set(name); + } else { + this.h = sha256(name); + } + this.ck = this.h.slice(); + this.cipher.initializeKey(null); + } + + mixKey(inputKeyMaterial: Uint8Array): void { + const [ck, tempK] = hkdf2(this.ck, inputKeyMaterial); + this.ck = ck; + this.cipher.initializeKey(tempK); + } + + mixHash(data: Uint8Array): void { + this.h = sha256(concat(this.h, data)); + } + + handshakeHash(): Uint8Array { + return this.h.slice(); + } + + encryptAndHash(plaintext: Uint8Array): Uint8Array { + const ciphertext = this.cipher.encryptWithAd(this.h, plaintext); + this.mixHash(ciphertext); + return ciphertext; + } + + decryptAndHash(ciphertext: Uint8Array): Uint8Array { + const plaintext = this.cipher.decryptWithAd(this.h, ciphertext); + this.mixHash(ciphertext); + return plaintext; + } + + split(): [CipherState, CipherState] { + const [k1, k2] = hkdf2(this.ck, EMPTY); + const c1 = new CipherState(); + const c2 = new CipherState(); + c1.initializeKey(k1); + c2.initializeKey(k2); + this.ck.fill(0); + this.cipher.clear(); + return [c1, c2]; + } +} + +/** Noise spec §4.3 HKDF with two outputs: HMAC chain with 0x01 and 0x02 info bytes. */ +function hkdf2(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array): [Uint8Array, Uint8Array] { + const tempKey = hmac(sha256, chainingKey, inputKeyMaterial); + const output1 = hmac(sha256, tempKey, new Uint8Array([0x01])); + const output2 = hmac(sha256, tempKey, concat(output1, new Uint8Array([0x02]))); + tempKey.fill(0); + return [output1, output2]; +} + +export function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} diff --git a/packages/relay/src/protected-records.test.ts b/packages/relay/src/protected-records.test.ts new file mode 100644 index 0000000000..fd3247b344 --- /dev/null +++ b/packages/relay/src/protected-records.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "vitest"; +import { CipherState, NoiseProtocolError } from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + MAX_CHUNK_DATA_BYTES, + protectedRecordByteLength, +} from "./protected-records.js"; + +function pair(): { sender: CipherState; receiver: CipherState } { + const key = new Uint8Array(32).map((_, i) => i + 1); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice()); + receiver.initializeKey(key.slice()); + return { sender, receiver }; +} + +function frameOf(length: number): Uint8Array { + return new Uint8Array(length).map((_, i) => (i * 7 + 3) & 0xff); +} + +const EMPTY = new Uint8Array(0); + +/** Re-encrypts one chunk's plaintext under the next sender nonce. */ +function reencrypt(sender: CipherState, plaintext: Uint8Array): Uint8Array { + const ct = sender.encryptWithAd(EMPTY, plaintext); + const out = new Uint8Array(2 + ct.byteLength); + out[0] = ct.byteLength >>> 8; + out[1] = ct.byteLength & 0xff; + out.set(ct, 2); + return out; +} + +function chunkPlaintext(kind: number, flags: number, total: number, data: Uint8Array): Uint8Array { + const pt = new Uint8Array(6 + data.byteLength); + pt[0] = kind; + pt[1] = flags; + pt[2] = (total >>> 24) & 0xff; + pt[3] = (total >>> 16) & 0xff; + pt[4] = (total >>> 8) & 0xff; + pt[5] = total & 0xff; + pt.set(data, 6); + return pt; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.byteLength; + } + return out; +} + +describe("protected records", () => { + test.each([ + ["empty text", "text", 0], + ["empty binary", "binary", 0], + ["small binary", "binary", 100], + ["exactly one chunk", "text", MAX_CHUNK_DATA_BYTES], + ["one byte over a chunk", "binary", MAX_CHUNK_DATA_BYTES + 1], + ["exact multiple of chunks", "text", MAX_CHUNK_DATA_BYTES * 3], + ["three and a bit chunks", "binary", MAX_CHUNK_DATA_BYTES * 3 + 17], + ] as const)("round-trips %s", (_label, kind, length) => { + const { sender, receiver } = pair(); + const frame = frameOf(length); + const record = encodeProtectedRecord(kind, frame, sender); + expect(record.byteLength).toBe(protectedRecordByteLength(length)); + const decoded = decodeProtectedRecord(record, receiver); + expect(decoded.kind).toBe(kind); + expect(Buffer.from(decoded.data).equals(Buffer.from(frame))).toBe(true); + expect(receiver.nonce()).toBe(sender.nonce()); + }); + + test("rejects two valid records concatenated into one message", () => { + const { sender, receiver } = pair(); + const a = encodeProtectedRecord("text", frameOf(10), sender); + const b = encodeProtectedRecord("text", frameOf(10), sender); + expect(() => decodeProtectedRecord(concat(a, b), receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a multi-chunk record split across messages", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + const firstOnly = record.subarray(0, firstChunkLength); + expect(() => decodeProtectedRecord(firstOnly, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a record whose complete final chunk is removed", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + expect(() => decodeProtectedRecord(record.subarray(0, firstChunkLength), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects zero chunks and trailing bytes", () => { + const { sender, receiver } = pair(); + expect(() => decodeProtectedRecord(new Uint8Array(0), receiver)).toThrow(NoiseProtocolError); + const record = encodeProtectedRecord("text", frameOf(3), sender); + expect(() => decodeProtectedRecord(concat(record, new Uint8Array([0])), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects an extra zero-byte FINAL chunk after an exact-multiple frame", () => { + const { sender, receiver } = pair(); + const total = MAX_CHUNK_DATA_BYTES; + const data = frameOf(total); + const forged = concat( + reencrypt(sender, chunkPlaintext(0x00, 0x01, total, data)), + reencrypt(sender, chunkPlaintext(0x00, 0x02, total, EMPTY)), + ); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects reserved flags, invalid kind, changed kind, and changed total length", () => { + const cases: Array<[Uint8Array[]]> = [ + [[chunkPlaintext(0x00, 0x01 | 0x02 | 0x04, 1, new Uint8Array([1]))]], + [[chunkPlaintext(0x02, 0x03, 1, new Uint8Array([1]))]], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x01, 0x02, MAX_CHUNK_DATA_BYTES + 1, new Uint8Array([1])), + ], + ], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x00, 0x02, MAX_CHUNK_DATA_BYTES + 2, new Uint8Array([1, 2])), + ], + ], + ]; + for (const [plaintexts] of cases) { + const { sender, receiver } = pair(); + const forged = concat(...plaintexts.map((pt) => reencrypt(sender, pt))); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + } + }); + + test("rejects a tampered chunk without advancing past it, and later chunks never escape", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const tampered = record.slice(); + tampered[2 + 10] ^= 0x01; + expect(() => decodeProtectedRecord(tampered, receiver)).toThrow(NoiseProtocolError); + expect(receiver.nonce()).toBe(0n); + }); + + test("rejects a record whose header claims more bytes than it carries before allocating", () => { + const { sender, receiver } = pair(); + const claimed = 64 * 1024 * 1024; + const forged = reencrypt(sender, chunkPlaintext(0x01, 0x01, claimed, new Uint8Array([1]))); + const before = process.memoryUsage().arrayBuffers; + expect(() => decodeProtectedRecord(forged, receiver)).toThrow("record length mismatch"); + expect(process.memoryUsage().arrayBuffers - before).toBeLessThan(1024 * 1024); + // A relay that truncates a genuine large record gets the same answer. + const { sender: s2, receiver: r2 } = pair(); + const genuine = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2 + 1), s2); + const firstChunkOnly = genuine.subarray(0, 2 + 6 + MAX_CHUNK_DATA_BYTES + 16); + expect(() => decodeProtectedRecord(firstChunkOnly, r2)).toThrow("record length mismatch"); + expect(r2.nonce()).toBe(1n); + }); + + test("a record delivered twice fails the second time", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("text", frameOf(20), sender); + decodeProtectedRecord(record, receiver); + expect(() => decodeProtectedRecord(record, receiver)).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/protected-records.ts b/packages/relay/src/protected-records.ts new file mode 100644 index 0000000000..a8eb661b80 --- /dev/null +++ b/packages/relay/src/protected-records.ts @@ -0,0 +1,229 @@ +/** + * Authenticated application-frame framing over Noise transport messages. + * + * One application frame becomes one wire record. Every Noise transport message + * (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST + * and FINAL flags, and the total frame length, so a relay cannot merge two + * records, split one across WebSocket messages, truncate a final chunk, or + * re-chunk a frame without an authentication failure or a framing violation. + * Transport AD is always empty, as in Noise §5. + * + * chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data + * wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext + */ +import { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./noise/index.js"; + +export type FrameKind = "text" | "binary"; + +const KIND_TEXT = 0x00; +const KIND_BINARY = 0x01; +const FLAG_FIRST = 0x01; +const FLAG_FINAL = 0x02; +const HEADER_BYTES = 6; +const LENGTH_PREFIX_BYTES = 2; +const EMPTY_AD = new Uint8Array(0); + +/** Largest data payload a single chunk carries. */ +export const MAX_CHUNK_DATA_BYTES = NOISE_MAX_MESSAGE_BYTES - NOISE_TAG_BYTES - HEADER_BYTES; +/** Largest application frame a record may carry; bounds allocation before decryption. */ +export const MAX_FRAME_BYTES = 0xffff_ffff; +const CHUNK_OVERHEAD_BYTES = LENGTH_PREFIX_BYTES + HEADER_BYTES + NOISE_TAG_BYTES; + +export function chunkCount(frameBytes: number): number { + return Math.max(1, Math.ceil(frameBytes / MAX_CHUNK_DATA_BYTES)); +} + +/** Wire bytes a frame of `frameBytes` occupies once protected. */ +export function protectedRecordByteLength(frameBytes: number): number { + return frameBytes + chunkCount(frameBytes) * CHUNK_OVERHEAD_BYTES; +} + +export function encodeProtectedRecord( + kind: FrameKind, + frame: Uint8Array, + sender: CipherState, +): Uint8Array { + if (frame.byteLength > MAX_FRAME_BYTES) throw new NoiseProtocolError("frame too large"); + const chunks = chunkCount(frame.byteLength); + const out = new Uint8Array(protectedRecordByteLength(frame.byteLength)); + let offset = 0; + for (let index = 0; index < chunks; index += 1) { + const start = index * MAX_CHUNK_DATA_BYTES; + const data = frame.subarray(start, Math.min(frame.byteLength, start + MAX_CHUNK_DATA_BYTES)); + const plaintext = new Uint8Array(HEADER_BYTES + data.byteLength); + plaintext[0] = kind === "text" ? KIND_TEXT : KIND_BINARY; + plaintext[1] = (index === 0 ? FLAG_FIRST : 0) | (index === chunks - 1 ? FLAG_FINAL : 0); + writeU32(plaintext, 2, frame.byteLength); + plaintext.set(data, HEADER_BYTES); + const ciphertext = sender.encryptWithAd(EMPTY_AD, plaintext); + writeU16(out, offset, ciphertext.byteLength); + out.set(ciphertext, offset + LENGTH_PREFIX_BYTES); + offset += LENGTH_PREFIX_BYTES + ciphertext.byteLength; + } + return out; +} + +export interface DecodedFrame { + kind: FrameKind; + data: Uint8Array; +} + +/** + * Decodes one wire record. Chunks are authenticated in order and the receive + * counter advances per authenticated chunk; any framing violation throws and + * nothing partial is returned. The caller must close the channel on error. + */ +export function decodeProtectedRecord(record: Uint8Array, receiver: CipherState): DecodedFrame { + let offset = 0; + let assembly: RecordAssembly | null = null; + while (offset < record.byteLength) { + if (assembly !== null && assembly.index >= assembly.expectedChunks) { + throw new NoiseProtocolError("unexpected chunk after final"); + } + const { chunk, next } = readChunk(record, offset, receiver); + offset = next; + assembly = + assembly === null + ? startAssembly(chunk, record.byteLength) + : continueAssembly(assembly, chunk); + } + if (assembly === null) throw new NoiseProtocolError("empty record"); + if (assembly.index !== assembly.expectedChunks || assembly.written !== assembly.totalLength) { + throw new NoiseProtocolError("record incomplete"); + } + return { kind: assembly.kind, data: assembly.frame }; +} + +interface Chunk { + kind: FrameKind; + isFirst: boolean; + isFinal: boolean; + totalLength: number; + data: Uint8Array; +} + +interface RecordAssembly { + kind: FrameKind; + totalLength: number; + expectedChunks: number; + frame: Uint8Array; + written: number; + index: number; +} + +function readChunk( + record: Uint8Array, + offset: number, + receiver: CipherState, +): { chunk: Chunk; next: number } { + if (record.byteLength - offset < LENGTH_PREFIX_BYTES) { + throw new NoiseProtocolError("record truncated"); + } + const ciphertextLength = readU16(record, offset); + const start = offset + LENGTH_PREFIX_BYTES; + if ( + ciphertextLength < NOISE_TAG_BYTES + HEADER_BYTES || + ciphertextLength > NOISE_MAX_MESSAGE_BYTES + ) { + throw new NoiseProtocolError("invalid chunk length"); + } + if (record.byteLength - start < ciphertextLength) { + throw new NoiseProtocolError("record truncated"); + } + const plaintext = receiver.decryptWithAd( + EMPTY_AD, + record.subarray(start, start + ciphertextLength), + ); + const flags = plaintext[1]; + if ((flags & ~(FLAG_FIRST | FLAG_FINAL)) !== 0) { + throw new NoiseProtocolError("reserved flag set"); + } + return { + chunk: { + kind: decodeKind(plaintext[0]), + isFirst: (flags & FLAG_FIRST) !== 0, + isFinal: (flags & FLAG_FINAL) !== 0, + totalLength: readU32(plaintext, 2), + data: plaintext.subarray(HEADER_BYTES), + }, + next: start + ciphertextLength, + }; +} + +function startAssembly(chunk: Chunk, recordByteLength: number): RecordAssembly { + if (!chunk.isFirst) throw new NoiseProtocolError("record does not start with FIRST"); + // The claimed frame length fixes the record's exact wire length. Check it + // before allocating so a tiny authenticated header cannot demand gigabytes. + if (protectedRecordByteLength(chunk.totalLength) !== recordByteLength) { + throw new NoiseProtocolError("record length mismatch"); + } + const assembly: RecordAssembly = { + kind: chunk.kind, + totalLength: chunk.totalLength, + expectedChunks: chunkCount(chunk.totalLength), + frame: new Uint8Array(chunk.totalLength), + written: 0, + index: 0, + }; + return appendChunk(assembly, chunk); +} + +function continueAssembly(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + if (chunk.isFirst) throw new NoiseProtocolError("FIRST repeated inside record"); + if (chunk.kind !== assembly.kind) { + throw new NoiseProtocolError("frame kind changed inside record"); + } + if (chunk.totalLength !== assembly.totalLength) { + throw new NoiseProtocolError("frame length changed inside record"); + } + return appendChunk(assembly, chunk); +} + +function appendChunk(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + const isLastExpected = assembly.index === assembly.expectedChunks - 1; + if (chunk.isFinal !== isLastExpected) throw new NoiseProtocolError("FINAL flag mismatch"); + const expectedData = isLastExpected + ? assembly.totalLength - assembly.index * MAX_CHUNK_DATA_BYTES + : MAX_CHUNK_DATA_BYTES; + if (chunk.data.byteLength !== expectedData) throw new NoiseProtocolError("chunk size mismatch"); + assembly.frame.set(chunk.data, assembly.written); + assembly.written += chunk.data.byteLength; + assembly.index += 1; + return assembly; +} + +function decodeKind(byte: number): FrameKind { + if (byte === KIND_TEXT) return "text"; + if (byte === KIND_BINARY) return "binary"; + throw new NoiseProtocolError("invalid frame kind"); +} + +function writeU16(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 8) & 0xff; + target[offset + 1] = value & 0xff; +} + +function readU16(source: Uint8Array, offset: number): number { + return (source[offset] << 8) | source[offset + 1]; +} + +function writeU32(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +function readU32(source: Uint8Array, offset: number): number { + return ( + ((source[offset] << 24) >>> 0) + + (source[offset + 1] << 16) + + (source[offset + 2] << 8) + + source[offset + 3] + ); +} diff --git a/packages/relay/src/secure-channel.test.ts b/packages/relay/src/secure-channel.test.ts new file mode 100644 index 0000000000..8f5034368d --- /dev/null +++ b/packages/relay/src/secure-channel.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it, vi } from "vitest"; +import { + openClientSecureChannel, + openDaemonSecureChannel, + SecureChannelRejectedError, + type SecureChannel, +} from "./secure-channel.js"; +import { + createClientChannel, + createDaemonChannel, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { exportPublicKey, generateKeyPair } from "./crypto.js"; +import { MAX_CHUNK_DATA_BYTES } from "./protected-records.js"; + +interface Frame { + data: string | ArrayBuffer; + isBinary: boolean; +} +type Interceptor = (frame: Frame) => Frame | null; + +/** + * Two transports wired back to back through an optional interceptor per + * direction, standing in for a relay that may record, drop, reorder, or + * rewrite frames. + */ +/** Delivers a frame to a transport a few milliseconds later, like a relay retry. */ +function deliverLater(transport: Transport, frame: TransportMessage, ms = 5): void { + setTimeout(() => transport.onmessage?.(frame), ms); +} + +function createLink() { + const toDaemon: Frame[] = []; + const toClient: Frame[] = []; + let interceptToDaemon: Interceptor = (frame) => frame; + let interceptToClient: Interceptor = (frame) => frame; + + const daemon: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toClient.push(recorded); + const frame = interceptToClient(recorded); + if (frame) setTimeout(() => client.onmessage?.(frame), 0); + }), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const client: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toDaemon.push(recorded); + const frame = interceptToDaemon(recorded); + if (frame) setTimeout(() => daemon.onmessage?.(frame), 0); + }), + // The client may run in a browser: WebSocket.close throws on any code other + // than 1000 or 3000-4999 and leaves the socket open, so mirror that here. + close: vi.fn((code?: number, reason?: string) => { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException( + `The code must be either 1000, or between 3000 and 4999. ${code} is neither.`, + "InvalidAccessError", + ); + } + setTimeout(() => client.onclose?.(code ?? 1005, reason ?? ""), 0); + }), + onmessage: null, + onclose: null, + onerror: null, + }; + return { + daemon, + client, + recordedToDaemon: toDaemon, + recordedToClient: toClient, + setToDaemon(fn: Interceptor) { + interceptToDaemon = fn; + }, + setToClient(fn: Interceptor) { + interceptToClient = fn; + }, + }; +} + +function toFrame(data: string | ArrayBuffer): Frame { + return { data, isBinary: data instanceof ArrayBuffer }; +} + +async function settle(ms = 30): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +interface Pair { + link: ReturnType; + daemonKeyPair: ReturnType; + client: SecureChannel; + daemon: SecureChannel; + daemonReceived: Array; + clientReceived: Array; +} + +async function openProtectedPair( + options: { + link?: ReturnType; + daemonKeyPair?: ReturnType; + clientMinimum?: "legacy" | "protected"; + daemonMinimum?: "legacy" | "protected"; + } = {}, +): Promise { + const link = options.link ?? createLink(); + const daemonKeyPair = options.daemonKeyPair ?? generateKeyPair(); + const daemonReceived: Array = []; + const clientReceived: Array = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: options.daemonMinimum, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: options.clientMinimum, + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + return { link, daemonKeyPair, client, daemon, daemonReceived, clientReceived }; +} + +function bytes(length: number): ArrayBuffer { + return new Uint8Array(length).map((_, i) => (i * 13 + 1) & 0xff).buffer; +} + +describe("secure channel: protected mode", () => { + it("negotiates protected mode between two updated peers and carries both frame kinds", async () => { + const pair = await openProtectedPair(); + expect(pair.client.securityMode()).toBe("protected"); + expect(pair.daemon.securityMode()).toBe("protected"); + + await pair.client.send("hello daemon"); + await pair.client.send(bytes(5)); + await pair.daemon.send("hello client"); + await pair.daemon.send(bytes(7)); + await settle(); + + expect(pair.daemonReceived[0]).toBe("hello daemon"); + expect(new Uint8Array(pair.daemonReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(5))); + expect(pair.clientReceived[0]).toBe("hello client"); + expect(new Uint8Array(pair.clientReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(7))); + }); + + it("keeps text as base64 text frames and binary as binary frames when negotiated", async () => { + const pair = await openProtectedPair(); + pair.link.recordedToDaemon.length = 0; + await pair.client.send("text"); + await pair.client.send(bytes(3)); + await settle(); + expect(pair.link.recordedToDaemon.map((frame) => frame.isBinary)).toEqual([false, true]); + expect(pair.link.recordedToDaemon[0].data).not.toContain("text"); + expect(pair.client.outboundWireByteLength(bytes(3))).toBe(3 + 2 + 6 + 16); + expect(pair.client.outboundWireByteLength("text")).toBe(4 * Math.ceil((4 + 24) / 3)); + }); + + it("carries frames larger than one Noise message", async () => { + const pair = await openProtectedPair(); + const big = bytes(MAX_CHUNK_DATA_BYTES * 2 + 100); + await pair.client.send(big); + await settle(100); + expect(new Uint8Array(pair.daemonReceived[0] as ArrayBuffer)).toEqual(new Uint8Array(big)); + }); + + it("flushes sends queued before the handshake completes, in order", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await client.send("one"); + await client.send("two"); + expect(client.isOpen()).toBe(false); + await daemonPromise; + await settle(); + expect(daemonReceived).toEqual(["one", "two"]); + }); + + it("rejects an exact duplicate record and delivers nothing twice", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("once"); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + + pair.link.daemon.onmessage?.(captured!); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("rejects a reflected record", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("mine"); + await settle(); + pair.link.client.onmessage?.(captured!); + await settle(); + expect(pair.clientReceived).toEqual([]); + expect(pair.link.client.close).toHaveBeenCalledWith(4003, expect.any(String)); + }); + + it("closes the browser socket and reports the close when a record fails at the client", async () => { + const closes: Array<[number, string]> = []; + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onclose: (code, reason) => closes.push([code, reason]) }, + }); + await daemonPromise; + await settle(); + link.client.onmessage?.({ + data: Buffer.from("not a record").toString("base64"), + isBinary: false, + }); + await settle(); + expect(client.isOpen()).toBe(false); + expect(closes).toEqual([[4003, expect.any(String)]]); + }); + + it("rejects out-of-order records and delivers nothing from them", async () => { + const pair = await openProtectedPair(); + const held: Frame[] = []; + pair.link.setToDaemon((frame) => { + held.push(frame); + return null; + }); + await pair.client.send("first"); + await pair.client.send("second"); + await settle(); + expect(held).toHaveLength(2); + pair.link.daemon.onmessage?.(held[1]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + pair.link.daemon.onmessage?.(held[0]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + }); + + it("rejects a tampered record without delivering it", async () => { + const pair = await openProtectedPair(); + pair.link.setToDaemon((frame) => { + const tampered = new Uint8Array((frame.data as ArrayBuffer).slice(0)); + tampered[tampered.byteLength - 1] ^= 0x01; + return { data: tampered.buffer, isBinary: true }; + }); + await pair.client.send(bytes(10)); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("closes on plaintext application traffic", async () => { + const pair = await openProtectedPair(); + pair.link.daemon.onmessage?.({ data: JSON.stringify({ type: "hello" }), isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + expect(pair.daemonReceived).toEqual([]); + }); + + it("answers an identical hello retry with the identical ready and keeps counters", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + ); + const readyFrames = () => + (pair.link.daemon.send as ReturnType).mock.calls + .map(([data]) => data) + .filter((data) => typeof data === "string" && data.includes('"e2ee_ready"')); + expect(hello).toBeDefined(); + expect(readyFrames()).toHaveLength(1); + + pair.link.daemon.onmessage?.(hello!); + await settle(); + expect(readyFrames()).toHaveLength(2); + expect(readyFrames()[0]).toBe(readyFrames()[1]); + expect(pair.link.daemon.close).not.toHaveBeenCalled(); + + await pair.client.send("still protected"); + await pair.daemon.send("both ways"); + await settle(); + expect(pair.daemonReceived).toEqual(["still protected"]); + expect(pair.clientReceived).toEqual(["both ways"]); + }); + + it("closes when a different hello arrives on an open channel", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + )!; + const changed = (hello.data as string).replace('"v":1', '"v":1 '); + expect(changed).not.toBe(hello.data); + pair.link.daemon.onmessage?.({ data: changed, isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + }); + + it("answers an identical hello that arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + // The relay delivers the client's 1s retry while the daemon is still sending ready. + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + deliverLater(link.daemon, frame); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + const readyFrames = link.recordedToClient.filter( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_ready"'), + ); + expect(readyFrames).toHaveLength(2); + expect(readyFrames[0].data).toBe(readyFrames[1].data); + expect(link.daemon.close).not.toHaveBeenCalled(); + expect(client.isOpen()).toBe(true); + expect(daemon.isOpen()).toBe(true); + }); + + it("closes when a different hello arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + const changed = frame.data.replace('"v":1', '"v":1 '); + deliverLater(link.daemon, { data: changed, isBinary: false }); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + expect(link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + expect(daemon.isOpen()).toBe(false); + }); + + it("does not let a recorded hello and records replay into a fresh daemon channel", async () => { + // Session 1: a legitimate client talks to the daemon; the relay records everything. + const first = await openProtectedPair(); + await first.client.send("secret command"); + await first.client.send(bytes(4)); + await settle(); + expect(first.daemonReceived).toHaveLength(2); + const transcript = first.link.recordedToDaemon.slice(); + expect(transcript.length).toBeGreaterThanOrEqual(3); + + // Session 2: the same daemon key pair (restart or reconnect), attacker replays the transcript. + const link = createLink(); + const replayed: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair: first.daemonKeyPair, + events: { onmessage: (data) => replayed.push(data) }, + }); + for (const frame of transcript) link.daemon.onmessage?.(frame); + const daemon = await daemonPromise; + await settle(); + expect(daemon.securityMode()).toBe("protected"); + expect(replayed).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); +}); + +describe("secure channel: negotiation and policy", () => { + it("rejects a present but invalid protected hello instead of falling back", async () => { + for (const mutate of [ + (hello: Record) => ({ ...hello, protected: { v: 2, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: { v: 1, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: "nope" }), + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToDaemon((frame) => { + if (typeof frame.data !== "string") return frame; + return { data: JSON.stringify(mutate(JSON.parse(frame.data))), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await expect(daemonPromise).rejects.toThrow(); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + } + }); + + it("rejects a malformed protected ready instead of waiting for a legacy one", async () => { + for (const protectedValue of [ + null, + {}, + { v: 1, message: "" }, + { v: "1", message: "AAAA" }, + { v: 1, message: 7 }, + [], + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + // A tampered ready first, then the daemon's genuine legacy-shaped ready. + deliverLater(link.client, { + data: JSON.stringify({ type: "e2ee_ready" }), + isBinary: false, + }); + return { data: JSON.stringify({ ...ready, protected: protectedValue }), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(40); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("malformed")); + expect(client.isOpen()).toBe(false); + await expect(client.send("never")).rejects.toThrow("Channel not open"); + } + }); + + it("rejects a tampered protected ready and never retries legacy", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + const message = Buffer.from(ready.protected.message, "base64"); + message[message.byteLength - 1] ^= 0x01; + ready.protected.message = message.toString("base64"); + return { data: JSON.stringify(ready), isBinary: false }; + }); + const errors: Error[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.any(String)); + expect(errors).toHaveLength(1); + await expect(client.send("x")).rejects.toThrow("Channel not open"); + }); + + it("rejects a ready that selects an unoffered parameter", async () => { + // The daemon cannot do this by itself; a forged msg2 would need the keys, so the + // check is exercised by driving the decoder with a crafted selection. + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + ready.capabilities = { binaryCiphertext: true }; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + // Unauthenticated outer capabilities are ignored in protected mode. + expect(client.isOpen()).toBe(true); + expect(client.securityMode()).toBe("protected"); + }); + + it("stripping protected from the ready does not downgrade a protected daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + delete ready.protected; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonReceived: unknown[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + expect(client.securityMode()).toBe("legacy"); + await client.send("legacy record"); + await settle(); + expect(daemonReceived).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("client with a protected minimum refuses a legacy-only daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const errors: Error[] = []; + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: "protected", + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("legacy")); + }); + + it("daemon with a protected minimum refuses a legacy-only client", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: () => "protected", + }); + await createClientChannel(link.client, exportPublicKey(daemonKeyPair.publicKey)); + await expect(daemonPromise).rejects.toBeInstanceOf(SecureChannelRejectedError); + await expect(daemonPromise).rejects.toMatchObject({ + closeCode: 1008, + closeReason: "protected endpoint security required", + }); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + }); +}); + +describe("secure channel: legacy compatibility", () => { + it("new client against an old daemon negotiates legacy and exchanges records", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const clientReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair, { + onmessage: (data) => daemonReceived.push(data), + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + expect(client.securityMode()).toBe("legacy"); + await client.send("from new client"); + await daemon.send("from old daemon"); + await settle(); + expect(daemonReceived).toEqual(["from new client"]); + expect(clientReceived).toEqual(["from old daemon"]); + }); + + it("old client against a new daemon negotiates legacy and keeps legacy retry semantics", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await createClientChannel( + link.client, + exportPublicKey(daemonKeyPair.publicKey), + { + onopen: () => opened.resolve(), + }, + ); + const daemon = await daemonPromise; + await opened.promise; + expect(daemon.securityMode()).toBe("legacy"); + const hello = (link.client.send as ReturnType).mock.calls.find( + ([data]) => typeof data === "string" && data.includes('"e2ee_hello"'), + )?.[0] as string; + link.daemon.onmessage?.({ data: hello, isBinary: false }); + await settle(); + expect(link.daemon.close).not.toHaveBeenCalled(); + await client.send("legacy still works"); + await settle(); + expect(daemonReceived).toEqual(["legacy still works"]); + }); +}); diff --git a/packages/relay/src/secure-channel.ts b/packages/relay/src/secure-channel.ts new file mode 100644 index 0000000000..760dcc49d0 --- /dev/null +++ b/packages/relay/src/secure-channel.ts @@ -0,0 +1,835 @@ +/// +/** + * Endpoint security for relay-carried daemon sessions. + * + * This is the one entrypoint both endpoints use to secure a transport. It owns + * the handshake, record protection, counters, retries, termination, and the + * negotiated security mode. Relay adapters own routing and physical I/O; + * daemon admission owns credentials and authorization. + * + * Two modes exist during migration: + * + * - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the + * daemon's static key, with authenticated record framing. Handshake + * messages travel byte-exact (base64) inside the existing `e2ee_hello` / + * `e2ee_ready` JSON envelopes under `protected`. + * - "legacy": the pre-existing ephemeral-static box construction in + * `encrypted-channel.ts`. It has no replay protection. Chosen only when the + * peer omits `protected` (an older peer) and policy allows it. + * + * A present but invalid `protected` field never falls back to legacy. + * + * See docs/relay-endpoint-security.md for the byte-level mapping. + */ + +import { + EncryptedChannel, + base64EncryptedWireByteLength, + maxBase64EncryptedPlaintextByteLength, + type EncryptedChannelEvents, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { + deriveSharedKey, + exportPublicKey, + generateKeyPair, + importPublicKey, + type KeyPair, +} from "./crypto.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; +import { + createNoiseInitiator, + createNoiseResponder, + NoiseProtocolError, + type CipherState, + type NoiseHandshake, +} from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + protectedRecordByteLength, +} from "./protected-records.js"; + +export type SecurityMode = "legacy" | "protected"; + +export type SecureChannelEvents = EncryptedChannelEvents; + +export interface SecureChannel { + send(data: string | ArrayBuffer): Promise; + close(code?: number, reason?: string): void; + isOpen(): boolean; + securityMode(): SecurityMode; + /** Bytes the transport will carry for `data`, including protection overhead. */ + outboundWireByteLength(data: string | ArrayBuffer): number; + onTransitionToOpen(cb: () => void): void; + onClose(cb: () => void): void; +} + +export interface ClientSecureChannelOptions { + daemonPublicKeyB64: string; + /** Lowest mode this client accepts. "protected" refuses a daemon that only offers legacy. */ + minimumMode?: SecurityMode; + events?: SecureChannelEvents; +} + +export interface DaemonSecureChannelOptions { + daemonKeyPair: KeyPair; + /** + * Lowest mode this daemon admits. "protected" refuses a client that only + * offers legacy. A function is evaluated when the hello arrives so a policy + * change applies to every admission from that moment on. + */ + minimumMode?: SecurityMode | (() => SecurityMode); + events?: SecureChannelEvents; +} + +/** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */ +export class SecureChannelRejectedError extends Error { + constructor( + message: string, + readonly closeCode: number, + readonly closeReason: string, + ) { + super(message); + this.name = "SecureChannelRejectedError"; + } +} + +export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008; +export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required"; +/** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */ +export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002; +/** + * Client-side close when an inbound record fails authentication or framing. + * Browser WebSocket.close accepts only 1000 or 3000-4999, so a client must + * never try to send a 1011. + */ +export const PROTECTED_RECORD_FAILED_CLOSE_CODE = 4003; +/** Daemon-side close for the same failures; the daemon runs on Node's ws. */ +const DAEMON_RECORD_FAILURE_CLOSE_CODE = 1011; +const REHANDSHAKE_REJECTION_CODE = 1008; +const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch"; + +const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1"); +const PROTECTED_ENVELOPE_VERSION = 1; +const PARAMS_VERSION = 0x01; +const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01; +const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT; +const HANDSHAKE_RETRY_MS = 1000; +const MAX_PENDING_SENDS = 200; + +interface ProtectedEnvelope { + v: number; + message: string; +} + +interface HelloEnvelope { + type: "e2ee_hello"; + key: string; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface ReadyEnvelope { + type: "e2ee_ready"; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface NegotiatedParams { + binaryCiphertext: boolean; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the initiator. Resolves once the hello has been sent; + * `events.onopen` fires when the channel is usable. Sends queued before open + * are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}. + */ +export async function openClientSecureChannel( + transport: Transport, + options: ClientSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = options.minimumMode ?? "legacy"; + const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64); + + // Separate ephemeral keys per protocol: the legacy box key is routing and + // compatibility input only; the Noise ephemeral lives inside the handshake. + const legacyKeyPair = generateKeyPair(); + const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey); + const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE }); + const offered: NegotiatedParams = { binaryCiphertext: true }; + const msg1 = noise.writeMessage(encodeParams(offered)); + const hello: HelloEnvelope = { + type: "e2ee_hello", + key: exportPublicKey(legacyKeyPair.publicKey), + capabilities: { binaryCiphertext: true }, + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) }, + }; + const helloText = JSON.stringify(hello); + + const channel = new ClientSecureChannel(transport, events, { + minimumMode, + noise, + offered, + legacySharedKey, + }); + + let retry: ReturnType | null = null; + const emitSendError = (error: unknown) => { + events.onerror?.(error instanceof Error ? error : new Error(String(error))); + }; + const sendHello = () => { + try { + const result = transport.send(helloText); + if (result) void result.catch(emitSendError); + } catch (error) { + // Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers. + emitSendError(error); + } + }; + const clearRetry = () => { + if (retry) { + clearInterval(retry); + retry = null; + } + }; + channel.onTransitionToOpen(clearRetry); + channel.onClose(clearRetry); + + sendHello(); + retry = setInterval(() => { + if (channel.isOpen()) { + clearRetry(); + return; + } + sendHello(); + }, HANDSHAKE_RETRY_MS); + unref(retry); + + return channel; +} + +interface ClientHandshakeState { + minimumMode: SecurityMode; + noise: NoiseHandshake; + offered: NegotiatedParams; + legacySharedKey: Uint8Array; +} + +/** + * Client channel: waits for the daemon's ready, selects the mode, then + * delegates records to the mode's implementation. + */ +class ClientSecureChannel implements SecureChannel { + private state: "handshaking" | "open" | "closed" = "handshaking"; + private closeNotified = false; + private inner: InnerChannel | null = null; + private pendingSends: Array = []; + private readonly onOpenCallbacks: Array<() => void> = []; + private readonly onCloseCallbacks: Array<() => void> = []; + private handshake: ClientHandshakeState | null; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + handshake: ClientHandshakeState, + ) { + this.handshake = handshake; + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleHandshakeMessage(message), + onclose: (code: number, reason: string) => this.handleTransportClose(code, reason), + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private handleHandshakeMessage(message: TransportMessage): void { + if (this.state !== "handshaking" || !this.handshake) return; + let parsed: unknown; + try { + if (message.isBinary) return; + parsed = JSON.parse(decodeTransportText(message.data)); + } catch { + return; // not handshake traffic; ignore while handshaking + } + if (!isRecord(parsed) || parsed.type !== "e2ee_ready") return; + + try { + // A ready the daemon recognizably sent but that fails validation is a + // broken or tampered handshake, never unrelated traffic to wait past. + if (!isReadyEnvelope(parsed)) throw new NoiseProtocolError("malformed ready envelope"); + const ready: ReadyEnvelope = parsed; + if (ready.protected !== undefined) { + this.openProtected(ready.protected); + } else { + this.openLegacy(ready); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message); + } + } + + private openProtected(envelope: ProtectedEnvelope): void { + const handshake = this.handshake!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const selected = decodeParams(handshake.noise.readMessage(msg2)); + if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) { + throw new NoiseProtocolError("daemon selected an unoffered parameter"); + } + const keys = handshake.noise.split(); + this.becomeOpen( + new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, { + retry: null, + failureCloseCode: PROTECTED_RECORD_FAILED_CLOSE_CODE, + }), + ); + } + + private openLegacy(ready: ReadyEnvelope): void { + const handshake = this.handshake!; + if (handshake.minimumMode === "protected") { + throw new Error("daemon offered only legacy endpoint security"); + } + const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, { + binaryCiphertext: ready.capabilities?.binaryCiphertext === true, + }); + legacy.setState("open"); + this.becomeOpen(new LegacyInner(legacy)); + } + + private becomeOpen(inner: InnerChannel): void { + this.handshake = null; + this.inner = inner; + this.state = "open"; + // The mode implementation now owns the transport callbacks; mirror its close. + inner.onClose(() => this.notifyClosed()); + this.events.onopen?.(); + for (const cb of this.onOpenCallbacks) cb(); + const pending = this.pendingSends; + this.pendingSends = []; + void (async () => { + try { + for (const item of pending) await inner.send(item); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_RECORD_FAILED_CLOSE_CODE, err.message); + } + })(); + } + + /** Transport closed while this wrapper still owned its callbacks (handshaking). */ + private handleTransportClose(code: number, reason: string): void { + this.events.onclose?.(code, reason); + this.notifyClosed(); + } + + private notifyClosed(): void { + this.state = "closed"; + this.handshake = null; + if (this.closeNotified) return; + this.closeNotified = true; + for (const cb of this.onCloseCallbacks) cb(); + } + + private fail(code: number, reason: string): void { + if (this.state === "closed") return; + this.state = "closed"; + this.handshake = null; + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state === "handshaking") { + if (this.pendingSends.length >= MAX_PENDING_SENDS) this.pendingSends.shift(); + this.pendingSends.push(data); + return; + } + if (this.state !== "open" || !this.inner) throw new Error("Channel not open"); + await this.inner.send(data); + } + + close(code = 1000, reason = "Normal closure"): void { + if (this.inner) { + this.inner.close(code, reason); + } else { + this.transport.close(code, reason); + } + this.state = "closed"; + this.handshake = null; + } + + isOpen(): boolean { + return this.state === "open" && this.inner?.isOpen() === true; + } + + securityMode(): SecurityMode { + if (!this.inner) throw new Error("security mode is unknown until the channel opens"); + return this.inner.mode; + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + if (this.inner) return this.inner.outboundWireByteLength(data); + // Mode unknown while handshaking: report the larger of the two so callers stay within limits. + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + return Math.max( + base64EncryptedWireByteLength(plaintextBytes), + 4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3), + ); + } + + onTransitionToOpen(cb: () => void): void { + this.onOpenCallbacks.push(cb); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +// --------------------------------------------------------------------------- +// Daemon +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the responder. Resolves once the peer's hello has + * been accepted and the ready sent. Rejects with + * {@link SecureChannelRejectedError} when policy refuses the peer, and with a + * plain error for malformed or failed handshakes; the caller closes the socket. + */ +export async function openDaemonSecureChannel( + transport: Transport, + options: DaemonSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = (): SecurityMode => + typeof options.minimumMode === "function" + ? options.minimumMode() + : (options.minimumMode ?? "legacy"); + return new Promise((resolve, reject) => { + const buffered: TransportMessage[] = []; + + const handleHello = async (message: TransportMessage): Promise => { + try { + if (message.isBinary) throw new Error("Invalid hello message (binary frame)"); + const helloText = decodeTransportText(message.data); + let parsed: unknown; + try { + parsed = JSON.parse(helloText); + } catch { + throw new Error(`Invalid hello message: ${preview(helloText)}`); + } + if (!isHelloEnvelope(parsed)) + throw new Error(`Invalid hello message: ${preview(helloText)}`); + + // Buffer traffic that arrives while the ready is in flight so an early + // record is not mistaken for a second hello. + Object.assign(transport, { onmessage: (next: TransportMessage) => buffered.push(next) }); + + const channel = + parsed.protected !== undefined + ? await acceptProtectedHello( + transport, + events, + options.daemonKeyPair, + parsed, + helloText, + ) + : await acceptLegacyHello( + transport, + events, + options.daemonKeyPair, + parsed, + minimumMode(), + ); + + events.onopen?.(); + // The channel now owns the transport: buffered records are decoded and a + // buffered hello retry gets the same exact-transcript answer as a live one. + for (const next of buffered) transport.onmessage?.(next); + resolve(channel); + } catch (error) { + reject(error); + } + }; + + Object.assign(transport, { + onmessage: handleHello, + onerror: (error: Error) => reject(error), + onclose: (code: number, reason: string) => + reject(new Error(`Connection closed during handshake: ${code} ${reason}`)), + }); + }); +} + +async function acceptProtectedHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + helloText: string, +): Promise { + const envelope = hello.protected!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE }); + const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const offered = decodeParams(noise.readMessage(msg1)); + const selected: NegotiatedParams = { binaryCiphertext: offered.binaryCiphertext }; + const msg2 = noise.writeMessage(encodeParams(selected)); + const ready: ReadyEnvelope = { + type: "e2ee_ready", + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) }, + }; + const readyText = JSON.stringify(ready); + await transport.send(readyText); + const keys = noise.split(); + return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, { + retry: { helloText, readyText }, + failureCloseCode: DAEMON_RECORD_FAILURE_CLOSE_CODE, + }); +} + +async function acceptLegacyHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + minimumMode: SecurityMode, +): Promise { + if (minimumMode === "protected") { + throw new SecureChannelRejectedError( + "client offered only legacy endpoint security", + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + ); + } + const clientPublicKey = importPublicKey(hello.key); + const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); + const binaryCiphertext = hello.capabilities?.binaryCiphertext === true; + await transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}), + } satisfies ReadyEnvelope), + ); + const legacy = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); + legacy.setState("open"); + return new LegacyInner(legacy); +} + +// --------------------------------------------------------------------------- +// Mode implementations +// --------------------------------------------------------------------------- + +interface InnerChannel extends SecureChannel { + readonly mode: SecurityMode; +} + +class LegacyInner implements InnerChannel { + readonly mode = "legacy" as const; + constructor(readonly legacy: EncryptedChannel) {} + send(data: string | ArrayBuffer): Promise { + return this.legacy.send(data); + } + close(code?: number, reason?: string): void { + this.legacy.close(code, reason); + } + isOpen(): boolean { + return this.legacy.isOpen(); + } + securityMode(): SecurityMode { + return this.mode; + } + outboundWireByteLength(data: string | ArrayBuffer): number { + return this.legacy.outboundWireByteLength(data); + } + onTransitionToOpen(cb: () => void): void { + this.legacy.onTransitionToOpen(cb); + } + onClose(cb: () => void): void { + this.legacy.onClose(cb); + } +} + +interface DaemonRetryTranscript { + helloText: string; + readyText: string; +} + +/** + * Protected records over an open transport. Every inbound record is + * authenticated and framing-checked before delivery; any failure closes the + * channel and clears the keys. + */ +class ProtectedChannel implements InnerChannel { + readonly mode = "protected" as const; + private state: "open" | "closed" = "open"; + private readonly onCloseCallbacks: Array<() => void> = []; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + private readonly sender: CipherState, + private readonly receiver: CipherState, + private readonly params: NegotiatedParams, + private readonly role: { + /** Daemon side only: exact transcript used to answer identical retries. */ + retry: DaemonRetryTranscript | null; + failureCloseCode: number; + }, + ) { + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleMessage(message), + onclose: (code: number, reason: string) => { + this.markClosed(); + this.events.onclose?.(code, reason); + }, + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private async handleMessage(message: TransportMessage): Promise { + if (this.state !== "open") return; + try { + const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null; + if (handshake) { + await this.handleHandshakeTraffic(handshake); + return; + } + const record = message.isBinary + ? new Uint8Array(requireArrayBuffer(message.data)) + : new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data))); + const frame = decodeProtectedRecord(record, this.receiver); + const plaintext = + frame.kind === "text" + ? new TextDecoder("utf-8", { fatal: true }).decode(frame.data) + : toArrayBuffer(frame.data); + this.events.onmessage?.(plaintext); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + } + } + + private async handleHandshakeTraffic( + handshake: { kind: "hello"; text: string } | { kind: "ready" }, + ): Promise { + if (handshake.kind === "ready") return; // client ignores duplicate ready after open + if (!this.role.retry) return; // a client never answers hellos + if (handshake.text === this.role.retry.helloText) { + await this.transport.send(this.role.retry.readyText); + return; + } + this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON); + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state !== "open") throw new Error("Channel not open"); + const kind = typeof data === "string" ? "text" : "binary"; + const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + let wire: Uint8Array; + try { + // Synchronous up to here: the nonce is reserved before any await. + wire = encodeProtectedRecord(kind, frame, this.sender); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + throw err; + } + if (this.params.binaryCiphertext && kind === "binary") { + await this.transport.send(toArrayBuffer(wire)); + return; + } + await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire))); + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + const wireBytes = protectedRecordByteLength(plaintextBytes); + if (this.params.binaryCiphertext && typeof data !== "string") return wireBytes; + return 4 * Math.ceil(wireBytes / 3); + } + + close(code = 1000, reason = "Normal closure"): void { + this.markClosed(); + this.transport.close(code, reason); + } + + private fail(code: number, reason: string): void { + if (this.state !== "open") return; + this.markClosed(); + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + private markClosed(): void { + if (this.state === "closed") return; + this.state = "closed"; + this.sender.clear(); + this.receiver.clear(); + for (const cb of this.onCloseCallbacks) cb(); + } + + isOpen(): boolean { + return this.state === "open"; + } + + securityMode(): SecurityMode { + return this.mode; + } + + onTransitionToOpen(cb: () => void): void { + cb(); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +/** + * Largest plaintext that fits in `wireBytes` as a base64 text frame in either + * mode. Callers size payloads before the mode is known, so take the tighter bound. + */ +export function maxSecuredPlaintextByteLength(wireBytes: number): number { + const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes); + const base64Capacity = Math.floor(wireBytes / 4) * 3; + let protectedBound = base64Capacity; + while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) { + protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound); + } + return Math.max(0, Math.min(legacy, protectedBound)); +} + +// --------------------------------------------------------------------------- +// Envelope parsing and parameter encoding +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCapabilities(value: unknown): value is { binaryCiphertext?: boolean } { + return ( + value === undefined || + (isRecord(value) && + (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")) + ); +} + +function isProtectedEnvelope(value: unknown): value is ProtectedEnvelope { + return ( + isRecord(value) && + typeof value.v === "number" && + typeof value.message === "string" && + value.message.length > 0 + ); +} + +function isHelloEnvelope(value: unknown): value is HelloEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_hello" && + typeof value.key === "string" && + value.key.trim().length > 0 && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +function isReadyEnvelope(value: unknown): value is ReadyEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_ready" && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +/** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */ +function parseHandshakeEnvelope( + data: string | ArrayBuffer, +): { kind: "hello"; text: string } | { kind: "ready" } | null { + let text: string; + try { + text = decodeTransportText(data); + } catch { + return null; + } + if (!text.trim().startsWith("{")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Received plaintext frame on encrypted channel"); + } + if (isRecord(parsed) && parsed.type === "e2ee_hello") return { kind: "hello", text }; + if (isRecord(parsed) && parsed.type === "e2ee_ready") return { kind: "ready" }; + // Any other JSON is plaintext app traffic: the peer is not encrypting. + throw new Error("Received plaintext frame on encrypted channel"); +} + +function encodeParams(params: NegotiatedParams): Uint8Array { + return new Uint8Array([ + PARAMS_VERSION, + params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0, + ]); +} + +function decodeParams(payload: Uint8Array): NegotiatedParams { + if (payload.byteLength !== 2) throw new NoiseProtocolError("invalid parameter payload"); + if (payload[0] !== PARAMS_VERSION) throw new NoiseProtocolError("unsupported parameter version"); + if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0) + throw new NoiseProtocolError("reserved parameter flag set"); + return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 }; +} + +function preview(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact); +} + +function decodeTransportText(data: string | ArrayBuffer): string { + return typeof data === "string" ? data : new TextDecoder().decode(data); +} + +function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer { + if (data instanceof ArrayBuffer) return data; + throw new Error("Binary WebSocket frame did not contain bytes"); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength; +} + +function unref(timer: unknown): void { + if (typeof timer === "object" && timer !== null && "unref" in timer) { + (timer as { unref: () => void }).unref(); + } +} diff --git a/packages/server/package.json b/packages/server/package.json index 2f06f218cd..0a7f9edbd4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/highlight": "0.13.0-beta.1", - "@getpaseo/plugin": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/highlight": "0.13.0-beta.2", + "@getpaseo/plugin": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ef5b361962..c01d477004 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12069,3 +12069,17 @@ test.each(["live", "history", "accepted"] as const)( } }, ); + +test("reads the last durable assistant response without loading a stored-only child", async () => { + const store = new RecordingTimelineStore(); + await store.appendCommitted("stored-child", { + type: "assistant_message", + text: "Partial work saved before restart", + }); + const manager = new AgentManager({ clients: {}, durableTimelineStore: store, logger }); + expect(manager.getAgent("stored-child")).toBeNull(); + expect(await manager.getLastAssistantMessage("stored-child")).toBe( + "Partial work saved before restart", + ); + expect(manager.getAgent("stored-child")).toBeNull(); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index de7fe1d986..b39fe782b8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3322,11 +3322,9 @@ export class AgentManager { } async getLastAssistantMessage(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - return null; + if (!this.timelineStore.has(agentId)) { + return (await this.durableTimelineStore?.getLastAssistantMessage(agentId)) ?? null; } - return await this.getLastAssistantMessageFromStores(agentId); } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 6ec66d10e0..00e70756de 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,15 +1,12 @@ -import { createTestAgentMessages } from "../test-utils/agent-messages.js"; import { formatSystemNotificationPrompt, isSystemInjectedEnvelope, parseAgentMessage, - parseAgentMessages, formatAgentMessage, prepareAgentMessage, projectAgentMessages, } from "./agent-messages/index.js"; -import { expect, it, test, vi } from "vitest"; -import pino, { type Logger } from "pino"; +import { expect, test, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,9 +14,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; -import { AgentStorage } from "./agent-storage.js"; -import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; -import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +import { waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentClient, AgentRunResult, @@ -27,555 +22,12 @@ import type { AgentStreamEvent, } from "./agent-sdk-types.js"; -interface CapturedLogger { - logger: Logger; - records: Array>; - nextRecord: Promise; -} - -function createCapturedLogger(): CapturedLogger { - const records: Array> = []; - let resolveNextRecord!: () => void; - const nextRecord = new Promise((resolve) => { - resolveNextRecord = resolve; - }); - const logger = pino( - { level: "error" }, - { - write(line: string) { - records.push(JSON.parse(line) as Record); - resolveNextRecord(); - }, - }, - ); - return { logger, records, nextRecord }; -} - -interface FinishNotificationScenarioOptions { - childLastAssistantMessage?: string | null; - childParentAgentId?: string | null; - requireParentOwnership?: boolean; - parentPromptError?: Error; - logger?: Logger; -} - -interface FinishNotificationScenario { - startWatchingChild(): void; - requestChildPermission(requestId?: string): void; - resolveChildPermission(requestId?: string): void; - resolveChildPermissionFromState(requestId?: string): void; - resolveChildPermissionWhileIdle(requestId?: string): void; - finishChild(): void; - finishChildAndReadParentPrompt(): Promise; - closeChildAndReadParentPrompt(): Promise; - parentPrompts(): string[]; - steerAttemptCount(): number; - wasParentPrompted(): boolean; -} - -function createFinishNotificationScenario( - options?: FinishNotificationScenarioOptions, -): FinishNotificationScenario { - let subscriber: ((event: AgentManagerEvent) => void) | null = null; - let resolveParentPrompt: ((prompt: string) => void) | null = null; - let parentPrompted = false; - let steerAttemptCount = 0; - const parentPrompts: string[] = []; - - const childAgent: ManagedAgent = Object.create(null); - Reflect.set(childAgent, "id", "child-agent"); - Reflect.set(childAgent, "lifecycle", "idle"); - Reflect.set(childAgent, "config", { title: "Child Agent" }); - Reflect.set(childAgent, "pendingPermissions", new Map()); - - const callerAgent: ManagedAgent = Object.create(null); - Reflect.set(callerAgent, "id", "caller-agent"); - Reflect.set(callerAgent, "lifecycle", "idle"); - Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); - Reflect.set(agentManager, "getAgent", (agentId: string) => { - if (agentId === "child-agent") { - return childAgent; - } - if (agentId === "caller-agent") { - return callerAgent; - } - return null; - }); - Reflect.set(agentManager, "subscribe", (callback: (event: AgentManagerEvent) => void) => { - subscriber = callback; - return () => { - subscriber = null; - }; - }); - Reflect.set(agentManager, "getLastAssistantMessage", async () => { - return options?.childLastAssistantMessage ?? null; - }); - Reflect.set(agentManager, "tryRunOutOfBand", () => false); - Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); - Reflect.set(agentManager, "steerOrReplaceActiveTurn", async () => { - steerAttemptCount += 1; - return { status: "inactive" }; - }); - Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { - parentPrompted = true; - parentPrompts.push(prompt); - resolveParentPrompt?.(prompt); - return (async function* noop() {})(); - }); - Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => { - resolveParentPrompt?.(prompt); - throw options?.parentPromptError; - }); - - const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); - Reflect.set(agentStorage, "get", async (agentId: string) => { - if (agentId === "child-agent") { - const parentAgentId = - options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId; - return { - title: "Child Agent", - labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {}, - }; - } - return null; - }); - - return { - startWatchingChild() { - setupFinishNotification({ - agentMessages: createTestAgentMessages({ - agentManager, - agentStorage, - logger: options?.logger ?? createTestLogger(), - }), - agentManager, - agentStorage, - childAgentId: "child-agent", - callerAgentId: "caller-agent", - requireParentOwnership: options?.requireParentOwnership, - logger: options?.logger ?? createTestLogger(), - }); - }, - requestChildPermission(requestId = "permission-1") { - childAgent.lifecycle = "running"; - childAgent.pendingPermissions.set(requestId, { - id: requestId, - provider: "claude", - kind: "tool", - name: "Run command", - description: "Write the QA sentinel", - input: { - file_path: "/tmp/permission-qa.txt", - content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", - }, - }); - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_requested", - provider: "codex", - request: childAgent.pendingPermissions.get(requestId)!, - }, - }); - }, - resolveChildPermission(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_resolved", - provider: "codex", - requestId, - resolution: { behavior: "allow" }, - }, - }); - }, - resolveChildPermissionFromState(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - subscriber?.({ type: "agent_state", agent: childAgent }); - }, - resolveChildPermissionWhileIdle(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - childAgent.lifecycle = "idle"; - subscriber?.({ type: "agent_state", agent: childAgent }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_resolved", - provider: "codex", - requestId, - resolution: { behavior: "allow" }, - }, - }); - }, - finishChild() { - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "idle"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { type: "turn_completed", provider: "codex" }, - }); - }, - async finishChildAndReadParentPrompt() { - const parentPrompt = new Promise((resolve) => { - resolveParentPrompt = resolve; - }); - this.finishChild(); - - return parentPrompt; - }, - async closeChildAndReadParentPrompt() { - const parentPrompt = new Promise((resolve) => { - resolveParentPrompt = resolve; - }); - - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "closed"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - return parentPrompt; - }, - parentPrompts() { - return parentPrompts; - }, - steerAttemptCount() { - return steerAttemptCount; - }, - wasParentPrompted() { - return parentPrompted; - }, - }; -} - test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => { expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true); expect(isSystemInjectedEnvelope("hello world")).toBe(false); }); -test("finish notifications tell the parent the child's last assistant message", async () => { - const scenario = createFinishNotificationScenario({ - childLastAssistantMessage: "Implemented the cleanup and all checks pass.", - }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parseAgentMessage(parentPrompt)).toEqual({ - id: expect.any(String), - source: { - kind: "agent-notification", - agentId: "child-agent", - title: "Child Agent", - event: "finished", - }, - text: "Agent child-agent (Child Agent) finished.\n\n\nImplemented the cleanup and all checks pass.\n", - }); - expect(scenario.steerAttemptCount()).toBe(1); -}); - -test("finish notifications truncate oversized child responses", async () => { - const included = "x".repeat(4000); - const omitted = "TAIL-MARKER".repeat(50); - const scenario = createFinishNotificationScenario({ - childLastAssistantMessage: included + omitted, - }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parentPrompt).toContain(included); - expect(parentPrompt).toContain( - `[truncated ${omitted.length} chars; use get_agent_activity for the full response]`, - ); - expect(parentPrompt).not.toContain("TAIL-MARKER"); -}); - -test("closing a watched child notifies the caller", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.closeChildAndReadParentPrompt(); - - expect(parseAgentMessage(parentPrompt)).toEqual({ - id: expect.any(String), - source: { - kind: "agent-notification", - agentId: "child-agent", - title: "Child Agent", - event: "closed", - }, - text: "Agent child-agent (Child Agent) was closed.", - }); -}); - -test("finish notifications survive permission responses", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - - await vi.waitFor(() => { - expect(scenario.parentPrompts()).toHaveLength(1); - }); - expect(scenario.parentPrompts()[0]).toContain("needs permission."); - const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match( - /\n([\s\S]+?)\n<\/permission-request>/, - )?.[1]; - expect(permissionPayload).toBeDefined(); - expect(JSON.parse(permissionPayload!)).toEqual({ - agentId: "child-agent", - requestId: "permission-1", - request: { - id: "permission-1", - provider: "claude", - kind: "tool", - name: "Run command", - description: "Write the QA sentinel", - input: { - file_path: "/tmp/permission-qa.txt", - content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", - }, - }, - }); - - scenario.resolveChildPermission(); - scenario.finishChild(); - - await vi.waitFor(() => { - expect(scenario.parentPrompts()).toHaveLength(2); - }); - expect(scenario.parentPrompts()[1]).toContain("finished."); -}); - -test("an idle permission resolution waits for the resumed run to finish", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - - scenario.resolveChildPermissionWhileIdle(); - scenario.requestChildPermission("permission-2"); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe( - true, - ); - - scenario.resolveChildPermission("permission-2"); - scenario.finishChild(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); - expect(scenario.parentPrompts()[2]).toContain("finished."); -}); - -test("finish notifications report every concurrently pending permission", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission("permission-1"); - scenario.requestChildPermission("permission-2"); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - expect( - scenario - .parentPrompts() - .flatMap((prompt) => parseAgentMessages(prompt)!) - .map((message) => { - const payload = message.text.match( - /\n([\s\S]+?)\n<\/permission-request>/, - )?.[1]; - return JSON.parse(payload!).requestId; - }), - ).toEqual(["permission-1", "permission-2"]); - - scenario.resolveChildPermission("permission-1"); - scenario.resolveChildPermission("permission-2"); - scenario.finishChild(); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - expect(scenario.parentPrompts()[1]).toContain("finished."); -}); - -test("finish notifications survive repeated permission cycles", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - scenario.resolveChildPermissionFromState(); - - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - scenario.resolveChildPermission(); - scenario.finishChild(); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); - expect( - scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]), - ).toEqual(["needs permission", "needs permission", "finished"]); -}); - -test("detaching a child ends its parent-owned finish notification", async () => { - const scenario = createFinishNotificationScenario({ - childParentAgentId: null, - requireParentOwnership: true, - }); - scenario.startWatchingChild(); - scenario.finishChild(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(scenario.wasParentPrompted()).toBe(false); -}); - -test("follow-up finish notifications do not require a parent relationship", async () => { - const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished."); -}); - -test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => { - const captured = createCapturedLogger(); - const scenario = createFinishNotificationScenario({ - parentPromptError: new Error("parent provider rejected replacement"), - logger: captured.logger, - }); - - scenario.startWatchingChild(); - await scenario.finishChildAndReadParentPrompt(); - await captured.nextRecord; - - expect(captured.records).toEqual([ - expect.objectContaining({ - msg: "Agent message batch dispatch failed", - agentId: "caller-agent", - messageIds: [expect.any(String)], - err: expect.objectContaining({ message: "parent provider rejected replacement" }), - }), - ]); -}); - -it("does not notify archived callers", async () => { - let subscriber: ((event: AgentManagerEvent) => void) | null = null; - - const childAgent: ManagedAgent = Object.create(null); - Reflect.set(childAgent, "id", "child-agent"); - Reflect.set(childAgent, "lifecycle", "idle"); - Reflect.set(childAgent, "config", { title: "Child Agent" }); - Reflect.set(childAgent, "pendingPermissions", new Map()); - - const callerAgent: ManagedAgent = Object.create(null); - Reflect.set(callerAgent, "id", "caller-agent"); - Reflect.set(callerAgent, "lifecycle", "idle"); - Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - - const streamAgentSpy = vi.fn(() => (async function* noop() {})()); - const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})()); - - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); - Reflect.set( - agentManager, - "getAgent", - vi.fn((agentId: string) => { - if (agentId === "child-agent") { - return childAgent; - } - if (agentId === "caller-agent") { - return callerAgent; - } - return null; - }), - ); - Reflect.set( - agentManager, - "subscribe", - vi.fn((callback: (event: AgentManagerEvent) => void) => { - subscriber = callback; - return () => { - subscriber = null; - }; - }), - ); - Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false)); - Reflect.set(agentManager, "streamAgent", streamAgentSpy); - Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy); - - const agentStorageGetSpy = vi.fn(async (agentId: string) => - agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, - ); - const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); - Reflect.set(agentStorage, "get", agentStorageGetSpy); - - setupFinishNotification({ - agentMessages: createTestAgentMessages({ - agentManager, - agentStorage, - logger: createTestLogger(), - }), - agentManager, - agentStorage, - childAgentId: "child-agent", - callerAgentId: "caller-agent", - logger: createTestLogger(), - }); - - expect(subscriber).not.toBeNull(); - - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "idle"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { type: "turn_completed", provider: "codex" }, - }); - - await vi.waitFor(() => { - expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent"); - }); - - expect(streamAgentSpy).not.toHaveBeenCalled(); - expect(replaceAgentRunSpy).not.toHaveBeenCalled(); -}); - -// Deliberately independent literals rather than the production constants these tests -// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests -// green if that constant were shortened back under a provider's startup budget. +// Independent of the production constant so shortening the budget breaks this test. const EXPECTED_RUN_START_BUDGET_MS = 60_000; // The slowest provider startup budget the run-start wait has to sit outside of today // (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS). diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 63c2309956..a8fd2ebd4b 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,24 +1,11 @@ -import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; -import { - prepareAgentMessage, - type AgentPromptSource, - type AgentMessageDelivery, -} from "./agent-messages/index.js"; +import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; -import type { - AgentPermissionRequest, - AgentPromptInput, - AgentRunOptions, -} from "./agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { - getParentAgentIdFromLabels, - getParentAddressFromLabels, -} from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; export type AgentUnarchiveController = Pick; @@ -412,412 +399,3 @@ export async function startCreatedAgentInitialPrompt( } return refreshedSnapshot; } - -export interface SetupFinishNotificationParams { - agentMessages: AgentMessageDelivery; - agentManager: AgentManager; - agentStorage: AgentStorage; - childAgentId: string; - callerAgentId: string; - childServerId?: string; - requireParentOwnership?: boolean; - /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ - dispatchResult?: Promise; - /** A replacement must observe its new start, not the displaced running snapshot. */ - waitForTurnStart?: boolean; - logger: Logger; -} - -type FinishNotificationReason = - | "finished" - | "errored" - | "needs permission" - | "was closed" - | "was canceled"; - -const finishNotificationEvents = { - finished: "finished", - errored: "errored", - "needs permission": "permission-required", - "was closed": "closed", - // Use the existing failure event so older clients still parse agent-message metadata. - "was canceled": "errored", -} as const; - -const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; - -interface FinishNotificationBodyInput { - childAgentId: string; - serverId?: string; - title: string; - reason: FinishNotificationReason; - lastAssistantMessage: string | null; - permissionRequest?: AgentPermissionRequest; -} - -function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { - const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; - const sections = [statusLine]; - if (params.serverId) sections.push(`Server: ${params.serverId}`); - if (params.reason === "needs permission" && params.permissionRequest) { - sections.push( - "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", - `\n${JSON.stringify( - { - agentId: params.childAgentId, - ...(params.serverId ? { serverId: params.serverId } : {}), - requestId: params.permissionRequest.id, - request: params.permissionRequest, - }, - null, - 2, - )}\n`, - ); - } - let lastAssistantMessage = params.lastAssistantMessage?.trim(); - if (lastAssistantMessage) { - if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { - const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; - lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; - } - sections.push(`\n${lastAssistantMessage}\n`); - } - return sections.join("\n\n"); -} - -interface NotifySafelyOptions { - terminal?: boolean; - permissionRequest?: AgentPermissionRequest; -} - -// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces -// every earlier observation for the caller/child pair; a failure removes only itself. -interface ArmedFinishNotification { - stop(): void; - supersedeWhen(accepted: Promise): void; -} - -const armedFinishNotifications = new WeakMap< - AgentManager, - Map> ->(); - -/** Creation waits and accepted follow-ups share notification ownership. */ -export function setupFinishWait(params: { - agentMessages: AgentMessageDelivery; - agentManager: AgentManager; - agentStorage: AgentStorage; - originServerId?: string; - childServerId?: string; - childAgentId: string; - callerAgentId: string; - wait(signal: AbortSignal): Promise; - afterPermission(requestId: string, signal: AbortSignal): Promise; - logger: Logger; -}): () => void { - const controller = new AbortController(); - const byManager = - armedFinishNotifications.get(params.agentManager) ?? - new Map>(); - armedFinishNotifications.set(params.agentManager, byManager); - const key = JSON.stringify([ - params.childServerId ?? null, - params.childAgentId, - params.callerAgentId, - ]); - const registrations = byManager.get(key) ?? new Set(); - byManager.set(key, registrations); - for (const previous of registrations) previous.stop(); - byManager.set(key, registrations); - let superseded = Promise.resolve(false); - const stop = () => { - controller.abort(); - registrations.delete(registration); - if (registrations.size === 0 && byManager.get(key) === registrations) byManager.delete(key); - }; - const registration: ArmedFinishNotification = { - stop, - supersedeWhen(accepted) { - superseded = superseded.then(async (prior) => prior || (await accepted)); - }, - }; - registrations.add(registration); - async function watch(): Promise { - while (!controller.signal.aborted) { - const result = await params.wait(controller.signal); - if (controller.signal.aborted || (await superseded) || result.status === "timeout") return; - const parent = getParentAddressFromLabels( - result.final?.labels, - params.childServerId ?? "self", - ); - if ( - parent?.agentId !== params.callerAgentId || - parent.serverId !== (params.originServerId ?? "self") - ) - return; - if ((await params.agentStorage.get(params.callerAgentId))?.archivedAt) return; - const reason = { idle: "finished", error: "errored", permission: "needs permission" }[ - result.status - ] as FinishNotificationReason; - const permissionRequest = result.final?.pendingPermissions[0]; - params.agentMessages.enqueue({ - recipientId: params.callerAgentId, - prompt: formatFinishNotificationBody({ - childAgentId: params.childAgentId, - serverId: params.childServerId, - title: result.final?.title ?? params.childAgentId, - reason, - lastAssistantMessage: result.lastMessage, - permissionRequest, - }), - source: { - kind: "agent-notification", - agentId: params.childAgentId, - serverId: params.childServerId, - title: result.final?.title ?? params.childAgentId, - event: finishNotificationEvents[reason], - }, - activeTurnBehavior: "steer", - unarchive: false, - }); - if (result.status !== "permission" || !permissionRequest) return; - await params.afterPermission(permissionRequest.id, controller.signal); - } - } - void watch() - .catch((error) => { - if (!controller.signal.aborted) - params.logger.warn( - { err: error, agentId: params.childAgentId }, - "Child finish wait stopped", - ); - }) - .finally(stop); - return stop; -} - -export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { - const { - agentManager, - agentStorage, - childAgentId, - callerAgentId, - requireParentOwnership = false, - waitForTurnStart = false, - logger, - } = params; - let hasSeenRunning = false; - let hasSeenTurn = false; - let stopped = false; - const notifiedPermissionRequestIds = new Set(); - let unsubscribe: (() => void) | null = null; - let notificationQueue = Promise.resolve(); - - const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); - armedFinishNotifications.set(agentManager, armedByManager); - const armedKey = JSON.stringify([params.childServerId ?? null, childAgentId, callerAgentId]); - const registrations = armedByManager.get(armedKey) ?? new Set(); - armedByManager.set(armedKey, registrations); - const previous = [...registrations]; - let superseded = Promise.resolve(false); - let dispatchPending = Boolean(params.dispatchResult); - const registration: ArmedFinishNotification = { - stop, - supersedeWhen(accepted) { - superseded = superseded.then( - async (alreadySuperseded) => alreadySuperseded || (await accepted), - ); - }, - }; - registrations.add(registration); - if (params.dispatchResult) { - const dispatchAccepted = params.dispatchResult.then((result) => result !== false); - for (const observer of previous) observer.supersedeWhen(dispatchAccepted); - void params.dispatchResult.then((accepted) => { - dispatchPending = false; - if (accepted) { - for (const observer of previous) observer.stop(); - if (stopped || accepted === "turn_started") stop(); - } else { - stop(); - } - return undefined; - }); - } else { - for (const observer of previous) { - observer.supersedeWhen(Promise.resolve(true)); - observer.stop(); - } - } - - function stop(): void { - stopped = true; - unsubscribe?.(); - // Pending observations remain visible to overlapping sends until acknowledgement. - if (!dispatchPending) { - registrations.delete(registration); - if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) { - armedByManager.delete(armedKey); - } - } - } - - async function notify( - reason: FinishNotificationReason, - permissionRequest?: AgentPermissionRequest, - ): Promise { - if (params.dispatchResult) { - const result = await params.dispatchResult; - if (result === false || result === "turn_started") return; - } - if (await superseded) return; - const callerRecord = await agentStorage.get(callerAgentId); - if (callerRecord?.archivedAt) { - return; - } - - const record = await agentStorage.get(childAgentId); - if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) { - return; - } - const title = record?.title ?? childAgentId; - const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId); - const body = formatFinishNotificationBody({ - childAgentId, - title, - reason, - lastAssistantMessage, - permissionRequest, - }); - - params.agentMessages.enqueue({ - recipientId: callerAgentId, - prompt: body, - source: { - kind: "agent-notification", - agentId: childAgentId, - title, - event: finishNotificationEvents[reason], - }, - activeTurnBehavior: "steer", - unarchive: false, - }); - } - - function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void { - if (stopped) return; - if (options.terminal ?? true) stop(); - notificationQueue = notificationQueue - .then(() => notify(reason, options.permissionRequest)) - .catch((error) => { - logger.error( - { err: error, childAgentId, callerAgentId, reason }, - "Failed to notify caller agent", - ); - }); - } - - function observeState(agent: ManagedAgent): void { - for (const requestId of notifiedPermissionRequestIds) { - if (!agent.pendingPermissions.has(requestId)) { - notifiedPermissionRequestIds.delete(requestId); - } - } - if (agent.lifecycle === "running") { - if (waitForTurnStart && !hasSeenTurn) return; - hasSeenTurn = true; - if (agent.pendingPermissions.size === 0) { - hasSeenRunning = true; - } - return; - } - // Prior terminal state is not this task's outcome. A failure before turn start - // is reported by the dispatch acknowledgement instead of a finish callback. - if (agent.lifecycle === "error" && hasSeenTurn) { - notifySafely("errored"); - return; - } - // Idle is published before the terminal stream event for both completion - // and cancellation. Let that event identify the outcome instead. - if (agent.lifecycle === "closed") { - notifySafely("was closed"); - return; - } - return; - } - - unsubscribe = agentManager.subscribe( - (event) => { - if (stopped) { - return; - } - - if (event.type === "agent_state") { - observeState(event.agent); - return; - } - - if (event.type === "timeline_replacement") { - return; - } - - if (event.event.type === "turn_started") { - hasSeenTurn = true; - hasSeenRunning = true; - return; - } - - if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") { - // Replacing a turn keeps the child running through the displaced turn's - // cancellation. The task settles only when the child becomes idle. - if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return; - if (event.event.type === "turn_completed" && hasSeenRunning) { - notifySafely("finished"); - } else if (event.event.type === "turn_canceled" && hasSeenTurn) { - notifySafely("was canceled"); - } - return; - } - - if (event.event.type === "permission_requested") { - // A permission pause is an intermediate checkpoint. Forget the run - // observed before it so an idle state during follow-up startup cannot - // masquerade as the final completion. - hasSeenRunning = false; - if (!notifiedPermissionRequestIds.has(event.event.request.id)) { - notifiedPermissionRequestIds.add(event.event.request.id); - notifySafely("needs permission", { - terminal: false, - permissionRequest: event.event.request, - }); - } - return; - } - - if (event.event.type === "permission_resolved") { - notifiedPermissionRequestIds.delete(event.event.requestId); - const childAgent = agentManager.getAgent(childAgentId); - if (childAgent?.pendingPermissions.size === 0) { - hasSeenRunning = childAgent.lifecycle === "running"; - } - } - }, - { agentId: childAgentId, replayState: false }, - ); - - // Check if the child is already running (catches the case where - // the lifecycle flipped before our subscribe call was processed). - // Do NOT treat an immediate "idle" as "finished" — the agent may - // not have started yet (streamAgent sets a pending run before - // transitioning to "running"). - const childSnapshot = agentManager.getAgent(childAgentId); - if (!childSnapshot || childSnapshot.lifecycle === "closed") { - stop(); - return stop; - } - if (childSnapshot.lifecycle === "running" && !waitForTurnStart) { - hasSeenTurn = true; - hasSeenRunning = true; - } - return stop; -} diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index c40b712fad..1ccb78c67a 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -3,6 +3,29 @@ import { describe, expect, test } from "vitest"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; describe("agent timeline content", () => { + test.each([false, true])( + "preserves the exact UTF-16 prefix for oversized shell output (failed=%s)", + (failed) => { + const text = "x".repeat(64 * 1024 - 1) + "🙂"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId: "large-shell", + name: "Bash", + status: failed ? "failed" : "completed", + detail: { type: "shell", command: "echo large", output: text }, + error: failed ? { content: text } : null, + }); + expect(item).toEqual({ + type: "tool_call", + callId: "large-shell", + name: "Bash", + status: failed ? "failed" : "completed", + detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) }, + error: failed ? { content: text.slice(0, 64 * 1024) } : null, + }); + }, + ); + test("marks truncated plain-text tool content within the content budget", () => { const oversizedInput = "x".repeat(64 * 1024 + 1); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index d9bb08c0bc..003239b283 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -11,6 +11,12 @@ export function assertPluginTimelineDataSize(data: JsonValue): void { } } +/** A sliced string can keep the entire provider output alive. Copy only the retained UTF-16 + * units, including an unmatched surrogate at the existing character limit. */ +function retainTextPrefix(text: string, length: number): string { + return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le"); +} + function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { if ( item.type !== "tool_call" || @@ -28,7 +34,7 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { ...item, error: { ...item.error, - content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH), }, }; } @@ -47,7 +53,8 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { ...item, detail: { ...item.detail, - text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, + text: + retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, }, }; } @@ -69,7 +76,7 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim ...item, detail: { ...item.detail, - output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH), }, }; } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 79f674e389..77cdde673b 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,4 +1,5 @@ -import type { AgentPromptSource, AgentMessageDelivery } from "../agent-messages/index.js"; +import type { FinishSubscriptions } from "../finish-subscriptions/index.js"; +import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -15,7 +16,7 @@ import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../a import type { AgentStorage } from "../agent-storage.js"; import type { AgentOwner } from "../agent-owner.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; -import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; +import { startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; @@ -23,7 +24,7 @@ import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp import { resolveCreateAgentIntent } from "./intent.js"; export interface CreateAgentCommandDependencies { - agentMessages?: AgentMessageDelivery; + finishSubscriptions?: FinishSubscriptions; agentManager: AgentManager; agentStorage: AgentStorage; logger: Logger; @@ -175,28 +176,29 @@ export async function createAgentCommand( if (input.kind === "mcp") { input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); } - const stopNotification = + const notification = input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && resolved.prompt !== undefined - ? setupFinishNotification({ - agentMessages: requireAgentMessages(dependencies), - agentManager: dependencies.agentManager, - agentStorage: dependencies.agentStorage, - childAgentId: snapshot.id, - callerAgentId: input.callerAgentId, - // Detached agents have no parent label, but still notify their creator. - requireParentOwnership: !(input.detached ?? false), - logger: dependencies.logger, - }) + ? await requireFinishSubscriptions(dependencies).register( + { + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + requireParentOwnership: !(input.detached ?? false), + }, + true, + ) : undefined; if (resolved.prompt !== undefined) { - const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { - stopNotification?.(); - throw error; - }); + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch( + async (error) => { + await notification?.reject(); + throw error; + }, + ); + notification?.accept(); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; initialPromptError = sendResult.error ?? null; @@ -561,7 +563,9 @@ async function resolveMcpCwd(params: { }; } -function requireAgentMessages(dependencies: CreateAgentCommandDependencies): AgentMessageDelivery { - if (!dependencies.agentMessages) throw new Error("Agent message delivery is not configured"); - return dependencies.agentMessages; +function requireFinishSubscriptions( + dependencies: CreateAgentCommandDependencies, +): FinishSubscriptions { + if (!dependencies.finishSubscriptions) throw new Error("Finish subscriptions are not configured"); + return dependencies.finishSubscriptions; } diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 9df68dfde6..b15053ff04 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -7,8 +7,8 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; export class AgentDirectory { constructor( private readonly deps: { - manager: AgentManager; - storage: AgentStorage; + manager: Pick; + storage: Pick; workspaces: Pick; projectLive(agent: ManagedAgent): Promise; projectStored(record: StoredAgentRecord): AgentSnapshotPayload; @@ -17,6 +17,26 @@ export class AgentDirectory { }, ) {} + /** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */ + async workspaceLastUserMessageTimes(): Promise> { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const times = new Map(); + const record = (workspaceId: string | undefined, at: string | null | undefined) => { + if (!workspaceId || !at) return; + const previous = times.get(workspaceId); + if (!previous || at > previous) times.set(workspaceId, at); + }; + for (const agent of await this.deps.storage.list()) { + if (!agent.internal && !liveIds.has(agent.id)) + record(agent.workspaceId, agent.lastUserMessageAt); + } + for (const agent of live) { + if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); + } + return times; + } + async includes( agent: Pick, includeBackground = false, diff --git a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts new file mode 100644 index 0000000000..872961111b --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts @@ -0,0 +1,414 @@ +import { HostObservationError } from "../host-operations/index.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { AgentMessageDelivery } from "../agent-messages/index.js"; +import { buildStoredAgentPayload } from "../agent-projections.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + createFinishSubscriptions, + createJsonFinishSubscriptionStore, + type FinishObservation, + type FinishSubscriptions, +} from "./index.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +class Observation implements FinishObservation { + waits: Array<{ signal: AbortSignal; result: ReturnType> }> = + []; + permission = deferred(); + opening: Promise = Promise.resolve(); + openedSignals: AbortSignal[] = []; + async open(_record: unknown, signal: AbortSignal) { + this.openedSignals.push(signal); + await this.opening; + return { next: () => this.wait(signal), close: async () => {} }; + } + async wait(signal: AbortSignal) { + signal.throwIfAborted(); + const result = deferred(); + signal.addEventListener("abort", () => result.reject(new Error("Stopped")), { once: true }); + this.waits.push({ signal, result }); + return result.promise; + } + afterPermission() { + return this.permission.promise; + } +} +class Delivery implements AgentMessageDelivery { + prompts: string[] = []; + acknowledgments: Array>> = []; + enqueue(message: Parameters[0]) { + this.prompts.push(String(message.prompt)); + const acknowledgment = deferred<"steered">(); + this.acknowledgments.push(acknowledgment); + return { + status: "queued" as const, + messageId: "notification", + dispatched: acknowledgment.promise, + }; + } + async close() {} + async waitForDispatch() {} +} +const homes: string[] = []; +const services: FinishSubscriptions[] = []; +afterEach(async () => { + for (const service of services.splice(0)) await service.close(); + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); +async function scenario(directory?: string, removalFailures = 0) { + const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-"))); + if (!directory) homes.push(home); + const durableStore = createJsonFinishSubscriptionStore(home); + const store = { + ...durableStore, + async remove(id: string) { + if (removalFailures > 0) { + removalFailures--; + throw new Error("Subscription removal unavailable"); + } + await durableStore.remove(id); + }, + }; + const observation = new Observation(); + const delivery = new Delivery(); + let active = true; + const service = createFinishSubscriptions({ + store, + messages: () => delivery, + open: observation.open.bind(observation), + callerIsActive: async () => active, + serverId: "origin", + logger: createTestLogger(), + retryDelayMs: 1, + }); + services.push(service); + await service.start(); + return { + home, + store, + observation, + delivery, + service, + archive: () => { + active = false; + }, + }; +} +const intent = { callerAgentId: "parent", childAgentId: "child" }; +function outcome(status: "idle" | "running" | "closed" | "error" = "closed"): WaitForFinishResult { + return { + status: status === "error" ? "error" : "idle", + final: buildStoredAgentPayload( + { + id: "child", + provider: "codex", + cwd: "/tmp", + title: "Child", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + labels: { "paseo.parent-agent-id": "parent" }, + lastStatus: status, + }, + ["codex"], + ), + lastMessage: "Partial work saved", + error: null, + }; +} +async function until(check: () => boolean) { + await expect.poll(check).toBe(true); +} + +test("retains intent until parent dispatch acknowledgment, then restart sends nothing", async () => { + const first = await scenario(); + await first.service.register(intent); + await until(() => first.observation.waits.length === 1); + first.observation.waits[0]!.result.resolve(outcome()); + await until(() => first.delivery.prompts.length === 1); + expect(first.delivery.prompts[0]).toContain("was closed"); + expect(first.delivery.prompts[0]).toContain("Partial work saved"); + expect(await first.store.list()).toHaveLength(1); + first.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await first.store.list()).length).toBe(0); + await first.service.close(); + const restarted = await scenario(first.home); + expect(restarted.observation.waits).toHaveLength(0); +}); + +test("restart coalesces predecessor and queued candidate into one settled notification", async () => { + const first = await scenario(); + await first.service.register(intent); + await first.service.register(intent, true); + expect(await first.store.list()).toHaveLength(2); + await first.service.close(); + const restarted = await scenario(first.home); + await until(() => restarted.observation.waits.length === 1); + restarted.observation.waits[0]!.result.resolve(outcome()); + await until(() => restarted.delivery.prompts.length === 1); + restarted.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await restarted.store.list()).length).toBe(0); + expect(restarted.delivery.prompts).toHaveLength(1); +}); + +test("rejected queued follow-up preserves the predecessor and its buffered finish", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const pending = await state.service.register(intent, true); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await Promise.resolve(); + expect(state.delivery.prompts).toHaveLength(0); + await pending.reject(); + await until(() => state.delivery.prompts.length === 1); + expect(await state.store.list()).toHaveLength(1); +}); + +test("accepted replacement ignores old idle and stale acknowledgment cannot delete new intent", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + const next = await state.service.register(intent, true); + next.accept(); + await until(() => state.observation.waits.length === 2); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(1); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 2); +}); + +test("shutdown before dispatch acknowledgment leaves intent for recovery", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome()); + await until(() => state.delivery.prompts.length === 1); + await state.service.close(); + state.delivery.acknowledgments[0]!.resolve("steered"); + const restarted = await scenario(state.home); + await until(() => restarted.observation.waits.length === 1); + restarted.observation.waits[0]!.result.resolve(outcome()); + await until(() => restarted.delivery.prompts.length === 1); +}); + +test("remote recovery before three failed observations still reports child completion", async () => { + const first = await scenario(); + await first.service.register({ ...intent, serverId: "remote" }); + await first.service.close(); + const state = await scenario(first.home); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.reject(new HostObservationError("unreachable")); + await until(() => state.observation.waits.length === 2); + expect(await state.store.list()).toHaveLength(1); + expect(state.delivery.prompts).toHaveLength(0); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("Server: remote"); +}); + +test("archived parent suppresses delivery and removes obsolete intent", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.archive(); + state.observation.waits[0]!.result.resolve(outcome()); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(0); +}); + +test("permission checkpoint retains intent and completion reports after permission clears", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const result = outcome("running"); + result.status = "permission"; + result.final!.pendingPermissions = [ + { + id: "permission", + name: "Write", + input: { path: "file" }, + kind: "tool", + } as (typeof result.final.pendingPermissions)[number], + ]; + state.observation.waits[0]!.result.resolve(result); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("respond_to_permission"); + expect(await state.store.list()).toHaveLength(1); + state.delivery.acknowledgments[0]!.resolve("steered"); + state.observation.permission.resolve(); + await until(() => state.observation.waits.length === 2); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 2); +}); + +test("coalesced unconditional follow-up survives detachment of a parent-owned creation", async () => { + const first = await scenario(); + await first.service.register({ ...intent, requireParentOwnership: true }); + await first.service.register(intent, true); + await first.service.close(); + const restarted = await scenario(first.home); + await until(() => restarted.observation.waits.length === 1); + const detached = outcome(); + detached.final!.labels = {}; + restarted.observation.waits[0]!.result.resolve(detached); + await until(() => restarted.delivery.prompts.length === 1); +}); + +test("detached child suppresses a parent-owned creation subscription", async () => { + const state = await scenario(); + await state.service.register({ ...intent, requireParentOwnership: true }); + await until(() => state.observation.waits.length === 1); + const detached = outcome(); + detached.final!.labels = {}; + state.observation.waits[0]!.result.resolve(detached); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(0); +}); + +test("dispatch failure retains intent and retries the ordinary finish notification", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome()); + await until(() => state.delivery.prompts.length === 1); + state.delivery.acknowledgments[0]!.reject(new Error("Provider unavailable")); + await until(() => state.delivery.prompts.length === 2); + expect(await state.store.list()).toHaveLength(1); + expect(state.observation.waits).toHaveLength(1); + state.delivery.acknowledgments[1]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); +}); + +test("error and missing-child notifications never claim successful completion", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("error")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("errored"); + await state.service.register({ ...intent, childAgentId: "missing-child" }); + await until(() => state.observation.waits.length === 2); + state.observation.waits[1]!.result.resolve({ + status: "error", + final: null, + error: "Agent not found: missing-child", + lastMessage: null, + }); + await until(() => state.delivery.prompts.length === 2); + expect(state.delivery.prompts[1]).toContain("is unavailable"); + expect(state.delivery.prompts[1]).not.toContain("finished"); +}); + +test("finish notification limits a long last response and points to activity for the remainder", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const result = outcome("idle"); + result.lastMessage = "x".repeat(4100); + state.observation.waits[0]!.result.resolve(result); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("x".repeat(4000)); + expect(state.delivery.prompts[0]).not.toContain("x".repeat(4001)); + expect(state.delivery.prompts[0]).toContain("get_agent_activity"); +}); + +test("three failed remote observations consume intent only after unavailable-notification acknowledgment", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + for (let attempt = 0; attempt < 3; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable")); + } + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain( + "Could not observe child agent child on host remote after three failed attempts.", + ); + expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown."); + expect(state.delivery.prompts[0]).not.toContain("finished."); + expect(await state.store.list()).toHaveLength(1); + expect(state.observation.waits).toHaveLength(3); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + await state.service.close(); + const restarted = await scenario(state.home); + expect(restarted.observation.waits).toEqual([]); + expect(restarted.delivery.prompts).toEqual([]); +}); + +test("connecting does not spend the remote failure budget and recovery before three failures still completes", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + for (let attempt = 0; attempt < 4; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("connecting")); + } + for (let attempt = 4; attempt < 6; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable")); + } + await until(() => state.observation.waits.length === 7); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[6]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("finished."); +}); + +test("removed-host notice retries parent dispatch without observing again or deleting unacknowledged intent", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.reject(new HostObservationError("removed")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("Host remote was removed or is unavailable."); + expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown."); + state.delivery.acknowledgments[0]!.reject(new Error("Parent provider unavailable")); + await until(() => state.delivery.prompts.length === 2); + expect(state.delivery.prompts[1]).toBe(state.delivery.prompts[0]); + expect(state.observation.waits).toHaveLength(1); + expect(await state.store.list()).toHaveLength(1); + state.delivery.acknowledgments[1]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); +}); + +test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => { + const state = await scenario(undefined, 1); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(1); + expect(state.delivery.prompts[0]).toContain("finished."); + expect(state.observation.waits).toHaveLength(1); +}); + +test("shutdown during observation setup owns rejection of the already-canceled next result", async () => { + const state = await scenario(); + const opening = deferred(); + state.observation.opening = opening.promise; + await state.service.register(intent); + await until(() => state.observation.openedSignals.length === 1); + const closing = state.service.close(); + await until(() => state.observation.openedSignals[0]!.aborted); + opening.resolve(); + await closing; + expect(await state.store.list()).toHaveLength(1); + expect(state.delivery.prompts).toEqual([]); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/index.ts b/packages/server/src/server/agent/finish-subscriptions/index.ts new file mode 100644 index 0000000000..58c3c8eff1 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/index.ts @@ -0,0 +1,363 @@ +import { setTimeout as pause } from "node:timers/promises"; +import { HostObservationError } from "../host-operations/index.js"; +import { randomUUID } from "node:crypto"; +import type { Logger } from "pino"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { FinishObservationResult } from "./internal/observation-buffer.js"; +import type { AgentMessageDelivery } from "../agent-messages/index.js"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; +import { + formatFinishNotificationBody, + finishNotificationEvents, + type FinishNotificationReason, +} from "./internal/notification.js"; +export { createJsonFinishSubscriptionStore } from "./internal/json-store.js"; + +export interface FinishSubscription { + id: string; + callerAgentId: string; + childAgentId: string; + serverId?: string; + /** Creation subscriptions stop reporting after the child is detached. */ + requireParentOwnership?: boolean; +} +export interface FinishSubscriptionStore { + list(): Promise; + put(subscription: FinishSubscription): Promise; + remove(id: string): Promise; +} +export interface FinishObservationSession { + next(): Promise; + close(): Promise; +} +export interface FinishObservation { + open(subscription: FinishSubscription, signal: AbortSignal): Promise; +} +interface Dependencies extends FinishObservation { + store: FinishSubscriptionStore; + messages(): AgentMessageDelivery; + callerIsActive(agentId: string): Promise; + serverId: string; + logger: Logger; + retryDelayMs?: number; +} +export interface FinishRegistration { + /** The prompt was accepted. Replaces preceding registrations for this pair. */ + accept(): void; + /** Rejected dispatch preserves preceding registrations. */ + reject(): Promise; +} +export interface FinishSubscriptions { + start(): Promise; + register( + input: Omit, + pendingDispatch?: boolean, + ): Promise; + close(): Promise; +} +interface Watch { + records: FinishSubscription[]; + controller: AbortController; + decision: Promise; + decide(accepted: boolean): void; + pending: boolean; + task?: Promise; +} +function key(record: FinishSubscription): string { + return JSON.stringify([record.callerAgentId, record.childAgentId, record.serverId]); +} + +/** Owns durable intent, live observation, supersession, and dispatch acknowledgment. */ +export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptions { + const tasks = new Set>(); + const watches = new Map>(); + let closed = false; + let mutations = Promise.resolve(); + function serialize(action: () => Promise): Promise { + const result = mutations.then(action); + mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + } + function attach(records: FinishSubscription[], pending: boolean): Watch { + let decide!: Watch["decide"]; + const decision = new Promise((resolve) => { + decide = resolve; + }); + const watch: Watch = { records, controller: new AbortController(), pending, decision, decide }; + const group = watches.get(key(records[0]!)) ?? new Set(); + group.add(watch); + watches.set(key(records[0]!), group); + if (!pending) decide(true); + watch.task = run(watch, group).catch((error) => + deps.logger.error({ err: error }, "Finish subscription failed"), + ); + tasks.add(watch.task); + void watch.task.then(() => tasks.delete(watch.task!)); + return watch; + } + function stop(watch: Watch, group: Set): void { + watch.controller.abort(); + watch.decide(false); + group.delete(watch); + if (!group.size && watches.get(key(watch.records[0]!)) === group) + watches.delete(key(watch.records[0]!)); + } + async function remove(watch: Watch): Promise { + // Only IDs captured by this observer are eligible; newer registrations survive. + for (const record of watch.records) await deps.store.remove(record.id); + } + async function delay(signal: AbortSignal, attempt: number): Promise { + try { + await pause( + Math.min((deps.retryDelayMs ?? 1000) * 2 ** Math.min(attempt, 3), 5000), + undefined, + { signal }, + ); + } catch (error) { + if (!signal.aborted) throw error; + } + } + async function abortable(operation: Promise, signal: AbortSignal): Promise { + let abort!: () => void; + const cancellation = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + try { + return await Promise.race([operation, cancellation]); + } finally { + signal.removeEventListener("abort", abort); + } + } + async function decisions(group: Set): Promise { + // New candidates may arrive while an older dispatch decision is pending. + while (true) { + if (closed) return; + const pending = [...group].filter((watch) => watch.pending); + if (!pending.length) return; + await Promise.all(pending.map((watch) => watch.decision)); + } + } + async function suppressed( + record: FinishSubscription, + result?: WaitForFinishResult, + ): Promise { + if (!(await deps.callerIsActive(record.callerAgentId))) return true; + if (!record.requireParentOwnership || !result?.final) return false; + const parent = getParentAddressFromLabels( + result.final.labels, + record.serverId ?? deps.serverId, + ); + return parent?.agentId !== record.callerAgentId || parent.serverId !== deps.serverId; + } + function reasonFor(result: FinishObservationResult): FinishNotificationReason { + if (result.reason === "host unreachable" || result.reason === "host removed") + return result.reason; + if (!result.final) return "is unavailable"; + if (result.final.status === "closed") return "was closed"; + if (result.status === "error") return "errored"; + if (result.status === "permission") return "needs permission"; + return result.reason ?? "finished"; + } + async function deliver( + record: FinishSubscription, + result: FinishObservationResult, + permissions: Set, + signal: AbortSignal, + ): Promise { + const requests = result.final?.pendingPermissions ?? []; + const reason = reasonFor(result); + for (const id of permissions) + if (!requests.some((request) => request.id === id)) permissions.delete(id); + const acknowledgments: Promise[] = []; + for (const request of reason === "needs permission" ? requests : [undefined]) { + if (signal.aborted) return false; + if (request && permissions.has(request.id)) continue; + const title = result.final?.title ?? record.childAgentId; + const receipt = deps.messages().enqueue({ + recipientId: record.callerAgentId, + prompt: formatFinishNotificationBody({ + childAgentId: record.childAgentId, + serverId: record.serverId ?? deps.serverId, + title, + reason, + lastAssistantMessage: result.lastMessage, + permissionRequest: request, + }), + source: { + kind: "agent-notification", + agentId: record.childAgentId, + serverId: record.serverId ?? deps.serverId, + title, + event: finishNotificationEvents[reason], + }, + activeTurnBehavior: "steer", + unarchive: false, + }); + acknowledgments.push( + receipt.dispatched.then(() => { + if (request) permissions.add(request.id); + return undefined; + }), + ); + } + await abortable(Promise.all(acknowledgments), signal); + if (signal.aborted) return false; + if (reason !== "needs permission") return true; + return false; + } + async function run(watch: Watch, group: Set): Promise { + const signal = watch.controller.signal; + if (!(await watch.decision) || signal.aborted) return; + const record = { + ...watch.records[0]!, + requireParentOwnership: watch.records.every( + (intent) => intent.requireParentOwnership === true, + ), + }; + const permissions = new Set(); + let observation: FinishObservationSession | undefined; + let pending: FinishObservationResult | undefined; + let acknowledged = false; + let remoteFailures = 0; + let retries = 0; + let warned = false; + async function observeNext(): Promise { + try { + observation ??= await deps.open(record, signal); + const result = await abortable(observation.next(), signal); + remoteFailures = 0; + return result; + } catch (error) { + await observation?.close(); + observation = undefined; + signal.throwIfAborted(); + if (!(error instanceof HostObservationError) || !record.serverId) throw error; + if (error.reason === "connecting") return undefined; + if (error.reason === "unreachable" && ++remoteFailures < 3) return undefined; + // Host visibility ends this subscription; it says nothing about child completion. + return { + status: "error", + final: null, + lastMessage: null, + error: null, + reason: error.reason === "removed" ? "host removed" : "host unreachable", + }; + } + } + try { + while (!signal.aborted) { + try { + if (await suppressed(record)) { + await remove(watch); + stop(watch, group); + return; + } + if (!pending) pending = await observeNext(); + if (!pending) { + await delay(signal, retries++); + continue; + } + if (signal.aborted) return; + await decisions(group); + if (signal.aborted) return; + if (await suppressed(record, pending)) { + await remove(watch); + stop(watch, group); + return; + } + if (!acknowledged) acknowledged = await deliver(record, pending, permissions, signal); + if (acknowledged) { + // A removal failure retries only the commit, never the already accepted callback. + await remove(watch); + stop(watch, group); + return; + } + pending = undefined; + retries = 0; + warned = false; + } catch (error) { + if (signal.aborted) return; + // Delivery and storage failures retain the captured outcome and durable intent. + // They do not spend the remote-observation budget or change the child's outcome. + if (!warned) { + deps.logger.warn( + { err: error, subscription: record.id }, + "Retrying finish subscription", + ); + warned = true; + } + await delay(signal, retries++); + } + } + } finally { + await observation?.close(); + } + } + + return { + start: () => + serialize(async () => { + const groups = new Map(); + for (const record of await deps.store.list()) { + const records = groups.get(key(record)) ?? []; + records.push(record); + groups.set(key(record), records); + } + for (const records of groups.values()) attach(records, false); + }), + register: (input, pendingDispatch = false) => + serialize(async () => { + if (closed) throw new Error("Finish subscriptions are closed"); + const record: FinishSubscription = { + ...input, + serverId: input.serverId === deps.serverId ? undefined : input.serverId, + id: randomUUID(), + }; + await deps.store.put(record); + const group = watches.get(key(record)) ?? new Set(); + const predecessors = [...group]; + const watch = attach([record], true); + const actualGroup = watches.get(key(record))!; + const registration: FinishRegistration = { + accept() { + if (!watch.pending || closed) return; + watch.pending = false; + for (const prior of predecessors) stop(prior, actualGroup); + watch.decide(true); + void serialize(async () => { + for (const prior of predecessors) await remove(prior); + }).catch((error) => + deps.logger.error({ err: error }, "Failed to remove superseded finish subscriptions"), + ); + }, + reject: () => + serialize(async () => { + stop(watch, actualGroup); + await remove(watch); + }), + }; + if (!pendingDispatch) registration.accept(); + return registration; + }), + async close() { + closed = true; + await mutations; + for (const group of watches.values()) + for (const watch of group) { + watch.controller.abort(); + watch.decide(false); + } + await mutations; + await Promise.all(tasks); + await deps.store.list(); + // Dispatch may still be pending. The delivery owner drains it after observers stop. + }, + }; +} + +export { createFinishObservation } from "./internal/observation.js"; diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts new file mode 100644 index 0000000000..5cf696393c --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { createJsonFinishSubscriptionStore } from "../index.js"; + +const homes: string[] = []; +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); +async function temporaryHome() { + const result = await mkdtemp(join(tmpdir(), "finish-subscriptions-")); + homes.push(result); + return result; +} +const subscription = { + id: "subscription", + callerAgentId: "parent", + childAgentId: "child", + serverId: "remote", +}; + +test("acknowledged concurrent writes survive reopening and removal", async () => { + const directory = await temporaryHome(); + const store = createJsonFinishSubscriptionStore(directory); + await Promise.all([store.put(subscription), store.put({ ...subscription, id: "second" })]); + const restarted = createJsonFinishSubscriptionStore(directory); + expect(await restarted.list()).toEqual([subscription, { ...subscription, id: "second" }]); + await restarted.remove(subscription.id); + expect(await createJsonFinishSubscriptionStore(directory).list()).toEqual([ + { ...subscription, id: "second" }, + ]); +}); + +test("write failure rejects registration and does not publish an unwritten record", async () => { + const directory = await temporaryHome(); + const store = createJsonFinishSubscriptionStore(directory); + expect(await store.list()).toEqual([]); + await mkdir(join(directory, "finish-subscriptions.json")); + await expect(store.put(subscription)).rejects.toThrow(); + expect(await store.list()).toEqual([]); +}); + +test("invalid persisted records fail visibly", async () => { + const directory = await temporaryHome(); + await writeFile( + join(directory, "finish-subscriptions.json"), + JSON.stringify([{ callerAgentId: 42 }]), + ); + await expect(createJsonFinishSubscriptionStore(directory).list()).rejects.toThrow(); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts new file mode 100644 index 0000000000..02b9635521 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../../../atomic-file.js"; +import type { FinishSubscription, FinishSubscriptionStore } from "../index.js"; + +const recordsSchema = z.array( + z.object({ + id: z.string().min(1), + callerAgentId: z.string().min(1), + childAgentId: z.string().min(1), + serverId: z.string().min(1).optional(), + requireParentOwnership: z.boolean().optional(), + }), +); + +export function createJsonFinishSubscriptionStore(paseoHome: string): FinishSubscriptionStore { + const file = join(paseoHome, "finish-subscriptions.json"); + let records: FinishSubscription[] | undefined; + let pending = Promise.resolve(); + async function load(): Promise { + if (records) return records; + try { + records = recordsSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + records = []; + } + return records; + } + function serialized(action: () => Promise): Promise { + const result = pending.then(action); + pending = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function mutate( + update: (current: FinishSubscription[]) => FinishSubscription[], + ): Promise { + const next = update(await load()); + await writeJsonFileAtomic(file, next); + records = next; + } + return { + list: () => serialized(async () => (await load()).map((record) => Object.assign({}, record))), + put: (subscription) => + serialized(() => + mutate((current) => [ + ...current.filter((record) => record.id !== subscription.id), + { ...subscription }, + ]), + ), + remove: (id) => + serialized(() => mutate((current) => current.filter((record) => record.id !== id))), + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts b/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts new file mode 100644 index 0000000000..d1cdc9bad7 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts @@ -0,0 +1,70 @@ +import type { AgentPermissionRequest } from "../../agent-sdk-types.js"; +export type FinishNotificationReason = + | "host unreachable" + | "host removed" + | "finished" + | "errored" + | "needs permission" + | "is unavailable" + | "was closed" + | "was canceled"; + +export const finishNotificationEvents = { + "host unreachable": "errored", + "host removed": "errored", + finished: "finished", + errored: "errored", + "needs permission": "permission-required", + "was closed": "closed", + "is unavailable": "closed", + // Use the existing failure event so older clients still parse agent-message metadata. + "was canceled": "errored", +} as const; + +const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; + +interface FinishNotificationBodyInput { + childAgentId: string; + serverId?: string; + title: string; + reason: FinishNotificationReason; + lastAssistantMessage: string | null; + permissionRequest?: AgentPermissionRequest; +} + +export function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { + if (params.reason === "host unreachable" || params.reason === "host removed") { + const host = + params.reason === "host removed" + ? `Host ${params.serverId} was removed or is unavailable.` + : `Could not observe child agent ${params.childAgentId} on host ${params.serverId} after three failed attempts.`; + return `${host}\n\nStatus of child agent ${params.childAgentId} (${params.title}) is unknown.\n\nServer: ${params.serverId}\n\nThis finish subscription has ended. Reassess the delegated work; reconnecting will not resume notifications.`; + } + const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; + const sections = [statusLine]; + if (params.serverId) sections.push(`Server: ${params.serverId}`); + if (params.reason === "needs permission" && params.permissionRequest) { + sections.push( + "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", + `\n${JSON.stringify( + { + agentId: params.childAgentId, + ...(params.serverId ? { serverId: params.serverId } : {}), + requestId: params.permissionRequest.id, + request: params.permissionRequest, + }, + null, + 2, + )}\n`, + ); + } + let lastAssistantMessage = params.lastAssistantMessage?.trim(); + if (lastAssistantMessage) { + if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { + const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; + lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; + } + sections.push(`\n${lastAssistantMessage}\n`); + } + return sections.join("\n\n"); +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts new file mode 100644 index 0000000000..95e80dd3a9 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts @@ -0,0 +1,83 @@ +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; + +export type FinishObservationResult = WaitForFinishResult & { + reason?: "was canceled" | "host unreachable" | "host removed"; +}; + +/** Coalesces duplicate checkpoints and retains one terminal result while delivery is pending. */ +export function createObservationBuffer(signal: AbortSignal) { + const delivered = new Set(); + const permissions = new Map(); + const resolvedBeforeInitial = new Set(); + let initialPending = true; + let terminal: FinishObservationResult | undefined; + let failure: unknown; + let wake: (() => void) | undefined; + const abort = () => { + failure = signal.reason; + wake?.(); + }; + signal.addEventListener("abort", abort, { once: true }); + function push(result: FinishObservationResult): void { + if (result.status === "timeout") return; + if (result.status === "permission") { + for (const request of result.final?.pendingPermissions ?? []) + if (!delivered.has(request.id)) permissions.set(request.id, result); + } else terminal = result; + wake?.(); + } + return { + pushInitial(result: FinishObservationResult) { + initialPending = false; + if (result.status === "permission" && result.final) { + const pendingPermissions = result.final.pendingPermissions.filter( + (request) => !resolvedBeforeInitial.has(request.id), + ); + if (pendingPermissions.length) + push({ ...result, final: { ...result.final, pendingPermissions } }); + } else if ( + !terminal && + result.final?.status !== "running" && + result.final?.status !== "initializing" + ) + push(result); + resolvedBeforeInitial.clear(); + }, + push, + resolved(id: string) { + if (initialPending) resolvedBeforeInitial.add(id); + delivered.delete(id); + permissions.delete(id); + }, + fail(error: unknown) { + failure = error; + wake?.(); + }, + async next(): Promise { + while (true) { + signal.throwIfAborted(); + const first = permissions.entries().next().value; + if (first) { + for (const request of first[1].final?.pendingPermissions ?? []) { + delivered.add(request.id); + permissions.delete(request.id); + } + return first[1]; + } + if (terminal) { + const result = terminal; + terminal = undefined; + return result; + } + if (failure) throw failure; + await new Promise((resolve) => { + wake = resolve; + }); + wake = undefined; + } + }, + dispose() { + signal.removeEventListener("abort", abort); + }, + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts new file mode 100644 index 0000000000..1d15a890df --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "vitest"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { + HostObservationError, + createHostObservationResolver, + type ToolHosts, + type HostObservation, +} from "../../host-operations/index.js"; +import { buildStoredAgentPayload } from "../../agent-projections.js"; +import { openFinishObservation } from "./observation-stream.js"; + +class Host implements HostObservation { + handler?: Parameters[1]; + released = false; + failure?: (error: unknown) => void; + baseline = Promise.withResolvers(); + async wait() { + return this.baseline.promise; + } + async observe( + _id: string, + handler: Parameters[1], + _signal: AbortSignal, + onError: (error: unknown) => void, + ) { + this.handler = handler; + this.failure = onError; + return async () => { + this.released = true; + }; + } + emit(status: "idle" | "running", event?: AgentStreamEvent) { + this.handler!( + buildStoredAgentPayload( + { + id: "child", + cwd: "/tmp", + provider: "codex", + labels: {}, + lastStatus: status, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ["codex"], + ), + event, + "Last response", + ); + } +} +function permission(id: string): AgentStreamEvent { + return { + type: "permission_requested", + provider: "codex", + request: { id, provider: "codex", name: "Write", kind: "tool", input: { path: id } }, + }; +} +async function scenario() { + const host = new Host(); + const controller = new AbortController(); + const observation = await openFinishObservation( + { use: async (_server, action) => action(host) }, + { id: "watch", callerAgentId: "parent", childAgentId: "child" }, + controller.signal, + ); + return { host, observation, controller }; +} + +test("permission resolution followed by transient idle waits for resumed work to settle", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", permission("first")); + expect((await observation.next()).status).toBe("permission"); + host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "first" }); + let settled = false; + const next = observation.next().then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("running", { type: "turn_started", provider: "codex" }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect(await next).toMatchObject({ status: "idle", lastMessage: "Last response" }); + } finally { + await observation.close(); + } + expect(host.released).toBe(true); +}); + +test("second checkpoint and terminal outcome remain buffered while the first callback is pending", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", permission("first")); + expect((await observation.next()).status).toBe("permission"); + // The recipient has not acknowledged the first callback; no next() is in flight. + host.emit("running", permission("second")); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await observation.next()).final?.pendingPermissions[0]?.id).toBe("second"); + expect((await observation.next()).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test("abort releases continuous observation even with a pending initial wait", async () => { + const { host, observation, controller } = await scenario(); + const next = observation.next(); + controller.abort(); + await expect(next).rejects.toThrow(); + await observation.close(); + expect(host.released).toBe(true); +}); + +test("late initial permission snapshot cannot resurrect an already resolved request", async () => { + const { host, observation } = await scenario(); + try { + const requestEvent = permission("resolved"); + host.emit("running", requestEvent); + await observation.next(); + host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "resolved" }); + const final = buildStoredAgentPayload( + { + id: "child", + cwd: "/tmp", + provider: "codex", + labels: {}, + lastStatus: "running", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ["codex"], + ); + if (requestEvent.type !== "permission_requested") throw new Error("Expected permission"); + final.pendingPermissions = [requestEvent.request]; + host.baseline.resolve({ status: "permission", final, lastMessage: null, error: null }); + await host.baseline.promise; + await Promise.resolve(); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await observation.next()).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test("lookup failure uses its error channel and never fabricates a missing-child result", async () => { + const { host, observation } = await scenario(); + try { + host.failure!(new Error("Route disconnected")); + await expect(observation.next()).rejects.toThrow("Route disconnected"); + } finally { + await observation.close(); + } +}); + +test("a terminal event observed after newer work starts waits for that running work", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", { type: "turn_completed", provider: "codex" }); + let settled = false; + const next = observation.next().then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await next).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test.each([ + { status: "connecting", reason: "connecting" }, + { status: "offline", reason: "unreachable" }, + { status: "disabled", reason: "unreachable" }, + { status: "removed", reason: "removed" }, +] as const)( + "remote observation classifies $status from the authoritative directory, ignoring arbitrary error codes", + async ({ status, reason }) => { + const row = { + registrationId: "registration", + serverId: "remote", + label: null, + source: { id: "owner", key: "remote" }, + metadata: {}, + enabled: status !== "disabled", + status: status === "removed" ? ("offline" as const) : status, + targetDescription: "Remote", + lastOnlineAt: null, + nextRetryAt: null, + error: null, + }; + const hosts: ToolHosts = { + directory: async () => ({ + self: { serverId: "origin", label: "Origin" }, + hosts: status === "removed" ? [] : [row], + }), + async use() { + throw Object.assign(new Error("Untrusted remote error"), { code: "host_unknown" }); + }, + }; + const local = new Host(); + const resolver = createHostObservationResolver({ + serverId: "origin", + local, + hosts: () => hosts, + }); + const error = await resolver.use("remote", async () => {}).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(HostObservationError); + expect(error).toMatchObject({ reason }); + }, +); diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts new file mode 100644 index 0000000000..34c059810f --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts @@ -0,0 +1,121 @@ +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { HostObservation } from "../../host-operations/index.js"; +import type { FinishSubscription, FinishObservationSession } from "../index.js"; +import { createObservationBuffer } from "./observation-buffer.js"; + +interface ObservationScope { + use( + serverId: string | undefined, + action: (host: HostObservation) => Promise, + signal?: AbortSignal, + ): Promise; +} +function isTerminal(snapshot: AgentSnapshotPayload, event?: AgentStreamEvent): boolean { + if (snapshot.status === "closed" || snapshot.status === "error") return true; + return ( + snapshot.status === "idle" && + (event?.type === "turn_completed" || event?.type === "turn_canceled") + ); +} +function observeState( + buffer: ReturnType, + snapshot: AgentSnapshotPayload | null, + event?: AgentStreamEvent, + lastMessage: string | null = null, +): void { + if (!snapshot) { + buffer.push({ status: "idle", final: null, lastMessage: null, error: null }); + return; + } + if (event?.type === "permission_resolved") { + buffer.resolved(event.requestId); + return; + } + if (event?.type === "turn_started") return; + if (event?.type === "permission_requested") { + buffer.push({ + status: "permission", + final: { ...snapshot, pendingPermissions: [event.request] }, + lastMessage, + error: null, + }); + } else if (isTerminal(snapshot, event)) { + buffer.push({ + status: snapshot.status === "error" ? "error" : "idle", + final: snapshot, + lastMessage, + error: snapshot.lastError ?? null, + ...(event?.type === "turn_canceled" ? { reason: "was canceled" as const } : {}), + }); + } +} + +/** One continuous source feeds a bounded buffer while the caller awaits dispatch. */ +export async function openFinishObservation( + resolver: ObservationScope, + record: FinishSubscription, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const controller = new AbortController(); + const lifetime = AbortSignal.any([signal, controller.signal]); + const buffer = createObservationBuffer(lifetime); + let opened!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + opened = resolve; + failed = reject; + }); + const route = resolver + .use( + record.serverId, + async (host) => { + const release = await host.observe( + record.childAgentId, + (snapshot, event, lastMessage) => observeState(buffer, snapshot, event, lastMessage), + lifetime, + (error) => buffer.fail(error), + ); + opened(); + // The ordinary wait supplies the initial settled snapshot and covers queued admission. + // Continuous observation buffers checkpoints and terminal events throughout delivery. + void host + .wait(record.childAgentId, lifetime) + .then((result) => { + buffer.pushInitial(result); + return undefined; + }) + .catch((error) => buffer.fail(error)); + try { + if (!lifetime.aborted) + await new Promise((resolve) => + lifetime.addEventListener("abort", () => resolve(), { once: true }), + ); + } finally { + await release(); + } + }, + lifetime, + ) + .catch((error) => { + failed(error); + buffer.fail(error); + }); + try { + await ready; + } catch (error) { + controller.abort(); + buffer.dispose(); + await route; + throw error; + } + return { + next: () => buffer.next(), + async close() { + controller.abort(); + buffer.dispose(); + await route; + }, + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts new file mode 100644 index 0000000000..887a4ba075 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts @@ -0,0 +1,103 @@ +import type { Logger } from "pino"; +import type { AgentManager } from "../../agent-manager.js"; +import type { AgentStorage } from "../../agent-storage.js"; +import type { AgentMessageDelivery } from "../../agent-messages/index.js"; +import { buildStoredAgentPayload, toAgentPayload } from "../../agent-projections.js"; +import { serializeSnapshotWithMetadata } from "../../mcp-shared.js"; +import { createHostObservationResolver, type ToolHosts } from "../../host-operations/index.js"; +import { openFinishObservation } from "./observation-stream.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { FinishObservation } from "../index.js"; + +export function createFinishObservation(input: { + agentManager: AgentManager; + agentStorage: AgentStorage; + messages(): AgentMessageDelivery; + hosts(): ToolHosts; + serverId: string; + logger: Logger; +}): FinishObservation { + const { agentManager, agentStorage } = input; + const resolver = createHostObservationResolver({ + serverId: input.serverId, + hosts: input.hosts, + local: { + async wait(agentId, signal) { + await input.messages().waitForDispatch(agentId, signal); + signal.throwIfAborted(); + const live = agentManager.getAgent(agentId); + if (!live) { + const stored = await agentStorage.get(agentId); + return { + status: stored?.lastStatus === "error" ? "error" : "idle", + final: stored ? buildStoredAgentPayload(stored, [stored.provider]) : null, + lastMessage: await agentManager.getLastAssistantMessage(agentId), + error: stored?.lastError ?? null, + }; + } + const result = await agentManager.waitForAgentEvent(agentId, { + signal, + waitForActive: true, + }); + const current = agentManager.getAgent(agentId); + const status = result.permission ? "permission" : result.status; + return { + status: status === "permission" || status === "error" ? status : "idle", + final: current + ? await serializeSnapshotWithMetadata(agentStorage, current, input.logger) + : null, + lastMessage: result.lastMessage, + error: current?.lastError ?? null, + }; + }, + async observe(agentId, handler, signal, onError) { + const stored = await agentStorage.get(agentId); + let updates = Promise.resolve(); + const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => { + updates = updates + .then(async () => + handler(snapshot, event, await agentManager.getLastAssistantMessage(agentId)), + ) + .catch(onError); + }; + signal.throwIfAborted(); + const unsubscribe = agentManager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.lifecycle !== "idle") + publish(toAgentPayload(event.agent, { title: stored?.title })); + else if ( + event.type === "agent_stream" && + [ + "permission_requested", + "permission_resolved", + "turn_started", + "turn_completed", + "turn_canceled", + "turn_failed", + ].includes(event.event.type) + ) { + const current = agentManager.getAgent(agentId); + if (current) publish(toAgentPayload(current, { title: stored?.title }), event.event); + } + }, + { agentId, replayState: false }, + ); + const current = agentManager.getAgent(agentId); + if (current) + publish(toAgentPayload(current, { title: stored?.title }), { + type: "turn_started", + provider: current.provider, + }); + else publish(stored ? buildStoredAgentPayload(stored, [stored.provider]) : null); + const stop = () => unsubscribe(); + signal.addEventListener("abort", stop, { once: true }); + return async () => { + signal.removeEventListener("abort", stop); + unsubscribe(); + }; + }, + }, + }); + return { open: (record, signal) => openFinishObservation(resolver, record, signal) }; +} diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 294e869555..2242c3b872 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -1,9 +1,11 @@ +import type { AgentStreamEvent } from "../agent-sdk-types.js"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { DaemonClient, + DaemonConnectionError, type FetchWorkspacesEntry, type WaitForFinishResult, } from "@getpaseo/client/internal/daemon-client"; @@ -17,6 +19,18 @@ import { summarizeAgentActivity } from "../activity-curator.js"; import { expandUserPath } from "../../path-utils.js"; import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; +export class HostOperationRejectedError extends Error { + readonly code: unknown; + constructor(error: unknown) { + super(error instanceof Error ? error.message : String(error), { cause: error }); + this.name = "HostOperationRejectedError"; + this.code = error instanceof Error && "code" in error ? error.code : undefined; + } +} +function isAmbiguousPromptFailure(error: unknown): boolean { + if (error instanceof DaemonConnectionError) return true; + return error instanceof Error && error.message.startsWith("Timeout waiting for message"); +} export type HostOperation = ( input: Record, context: PaseoToolExecutionContext, @@ -29,24 +43,46 @@ export interface HostOperations { getStatus: HostOperation; getActivity: HostOperation; listAgents: HostOperation; +} +/** Connection authority at the host boundary, independent of child lifecycle. */ +export class HostObservationError extends Error { + constructor( + readonly reason: "connecting" | "unreachable" | "removed", + cause?: unknown, + ) { + super(`Host observation ${reason}`, { cause }); + this.name = "HostObservationError"; + } +} +export interface HostObservation { wait(agentId: string, signal: AbortSignal): Promise; observe( agentId: string, - handler: (snapshot: AgentSnapshotPayload | null) => void, + handler: ( + snapshot: AgentSnapshotPayload | null, + event?: AgentStreamEvent, + lastMessage?: string | null, + ) => void, signal: AbortSignal, + onError: (error: unknown) => void, ): Promise<() => Promise>; } export interface ToolHosts { directory(): Promise; - use(serverId: string, action: (client: DaemonClient) => Promise): Promise; + use( + serverId: string, + action: (client: DaemonClient) => Promise, + signal?: AbortSignal, + ): Promise; } /** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { return { directory: () => hosts.list(), - async use(serverId, action) { + async use(serverId, action, signal) { const lifetime = new AbortController(); + let routeFailure: unknown; const transportFactory: DaemonTransportFactory = () => { const opens = new Set<() => void>(); const closes = new Set<(event?: unknown) => void>(); @@ -61,7 +97,7 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { sourceId: "mcp", originSessionId: "mcp", permissions: OWNER_PERMISSIONS, - signal: lifetime.signal, + signal: signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal, frame: (data) => { for (const listener of messages) listener(data, typeof data !== "string"); }, @@ -78,6 +114,7 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { return undefined; }) .catch((error) => { + routeFailure = error; for (const listener of errors) listener(error); for (const listener of closes) listener(error); }); @@ -114,10 +151,17 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { transportFactory, reconnect: { enabled: false }, }); + const stop = () => void client.close(); + signal?.addEventListener("abort", stop, { once: true }); try { - await client.connect(); + signal?.throwIfAborted(); + await client.connect().catch((error) => { + // The action has not run; no application request could have been transmitted. + throw new HostOperationRejectedError(routeFailure ?? error); + }); return await action(client); } finally { + signal?.removeEventListener("abort", stop); lifetime.abort(); await client.close(); } @@ -212,18 +256,29 @@ export function createHostResolver(input: { if (!input.callerAgentId && args.sessionMode !== undefined) { await client.setAgentMode(agentId, args.sessionMode as string); } - const receipt = await client.sendMessage(agentId, args.prompt as string, { - sessionMode: input.callerAgentId - ? (args.sessionMode as string | undefined) - : undefined, - sourceAgentId: input.callerAgentId, - sourceAgentServerId: input.callerAgentId ? input.serverId : undefined, - activeTurnBehavior: - (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? - (input.callerAgentId ? "steer" : "interrupt"), - }); - const agent = (await client.fetchAgent(agentId))?.agent; - if (!agent) throw new Error(`Agent ${agentId} not found`); + const agent = ( + await client.fetchAgent(agentId).catch((error) => { + // Snapshot preflight precedes sendMessage, so failure is definitely unsent. + throw new HostOperationRejectedError(error); + }) + )?.agent; + if (!agent) + throw new HostOperationRejectedError(new Error(`Agent ${agentId} not found`)); + const receipt = await client + .sendMessage(agentId, args.prompt as string, { + sessionMode: input.callerAgentId + ? (args.sessionMode as string | undefined) + : undefined, + sourceAgentId: input.callerAgentId, + sourceAgentServerId: input.callerAgentId ? input.serverId : undefined, + activeTurnBehavior: + (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? + (input.callerAgentId ? "steer" : "interrupt"), + }) + .catch((error) => { + if (isAmbiguousPromptFailure(error)) throw error; + throw new HostOperationRejectedError(error); + }); return result({ success: true, ...receipt, @@ -310,45 +365,6 @@ export function createHostResolver(input: { throw new Error(response.error ?? "Workspace creation failed"); return result(workspaceSummary(response.workspace)); }, - async observe(agentId, handler, signal) { - const observation = client.observeTimeline([agentId], { signal }); - const refresh = async () => handler((await client.fetchAgent(agentId))?.agent ?? null); - const unsubscribe = observation.subscribe({ - snapshot: () => {}, - update: (message) => { - if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; - const event = message.payload.event; - if ( - event.type === "permission_resolved" || - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed" - ) - void refresh().catch(() => handler(null)); - }, - error: () => handler(null), - }); - try { - await observation.ready; - await refresh(); - } catch (error) { - unsubscribe(); - await observation.release(); - throw error; - } - return async () => { - unsubscribe(); - await observation.release(); - }; - }, - wait: (agentId, signal) => { - const close = () => void client.close(); - signal.addEventListener("abort", close, { once: true }); - return client - .waitForFinish(agentId, 0) - .finally(() => signal.removeEventListener("abort", close)); - }, }), ); }, @@ -366,36 +382,129 @@ function workspaceSummary(workspace: FetchWorkspacesEntry) { }; } -/** Wait pauses at permissions; observe only until that request clears before re-arming it. */ -export async function waitForPermissionChange( - host: HostOperations, - agentId: string, - requestId: string, - signal: AbortSignal, -): Promise { - signal.throwIfAborted(); - let changed!: () => void; - let failed!: (error: unknown) => void; - const ready = new Promise((resolve, reject) => { - changed = resolve; - failed = reject; - }); - const abort = () => failed(signal.reason ?? new Error("Finish wait stopped")); - signal.addEventListener("abort", abort, { once: true }); - let release: (() => Promise) | undefined; - try { - release = await host.observe( - agentId, - (snapshot) => { - if (!snapshot) failed(new Error(`Agent ${agentId} is unavailable`)); - else if (!snapshot.pendingPermissions.some((request) => request.id === requestId)) - changed(); - }, - signal, - ); - await ready; - } finally { - signal.removeEventListener("abort", abort); - await release?.(); - } +function remoteObservation(client: DaemonClient): HostObservation { + return { + async observe(agentId, handler, signal, onError) { + const observation = client.observeTimeline([agentId], { signal }); + const refresh = async (event?: AgentStreamEvent) => { + let snapshot = (await client.fetchAgent(agentId))?.agent ?? null; + let lastMessage: string | null = null; + if ( + snapshot && + (snapshot.status === "idle" || + snapshot.status === "closed" || + snapshot.status === "error" || + snapshot.pendingPermissions.length > 0) + ) { + // A new turn may start between the snapshot and the current-response read. + // Bound that wait and refresh state instead of attributing the old event to it. + const current = await client.waitForFinish(agentId, 100); + if (current.status === "timeout") + snapshot = (await client.fetchAgent(agentId))?.agent ?? null; + else { + if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`) + throw new Error(current.error); + snapshot = current.final; + lastMessage = current.lastMessage; + } + } + handler(snapshot, event, lastMessage); + }; + let updates = Promise.resolve(); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; + const event = message.payload.event; + if ( + event.type === "permission_requested" || + event.type === "permission_resolved" || + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_canceled" || + event.type === "turn_failed" + ) + updates = updates.then(() => refresh(event)).catch(onError); + }, + error: onError, + }); + try { + await observation.ready; + await refresh(); + } catch (error) { + unsubscribe(); + await observation.release(); + throw error; + } + return async () => { + unsubscribe(); + await observation.release(); + }; + }, + wait: (agentId, signal) => { + const close = () => void client.close(); + signal.addEventListener("abort", close, { once: true }); + return client + .waitForFinish(agentId, 0) + .then((outcome) => { + if (!outcome.final && outcome.error && outcome.error !== `Agent not found: ${agentId}`) + throw new Error(outcome.error); + return outcome; + }) + .finally(() => signal.removeEventListener("abort", close)); + }, + }; +} + +/** Shares routed status observation with daemon services independently of MCP execution. */ +export function createHostObservationResolver(input: { + serverId: string; + local: HostObservation; + hosts(): ToolHosts; +}) { + return { + async use( + serverId: string | undefined, + action: (host: HostObservation) => Promise, + signal?: AbortSignal, + ): Promise { + if (!serverId || serverId === input.serverId) return action(input.local); + const hosts = input.hosts(); + async function classify(error: unknown): Promise { + // This is the origin's authoritative registration directory, not a destination RPC error. + const row = (await hosts.directory()).hosts.find((host) => host.serverId === serverId); + if (!row) return new HostObservationError("removed", error); + if (row.status === "connecting") return new HostObservationError("connecting", error); + return new HostObservationError("unreachable", error); + } + async function fail(error: unknown): Promise { + throw await classify(error); + } + try { + return await hosts.use( + serverId, + (client) => { + const remote = remoteObservation(client); + return action({ + wait: (agentId, lifetime) => remote.wait(agentId, lifetime).catch(fail), + observe(agentId, handler, lifetime, onError) { + async function report(error: unknown): Promise { + try { + onError(await classify(error)); + } catch (failure) { + onError(failure); + } + } + return remote.observe(agentId, handler, lifetime, report).catch(fail); + }, + }); + }, + signal, + ); + } catch (error) { + if (error instanceof HostObservationError) throw error; + return fail(error); + } + }, + }; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 0599281f15..b8a0d820ec 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,12 +1,20 @@ +import { + createFinishSubscriptions, + createFinishObservation, + createJsonFinishSubscriptionStore, + type FinishSubscriptionStore, + type FinishSubscriptions, + type FinishSubscription, +} from "./finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "./agent-messages/index.js"; import { createTestAgentMessages } from "../test-utils/agent-messages.js"; -import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js"; -import { parseAgentMessage } from "./agent-messages/index.js"; +import { type PromptDispatchDisposition } from "./agent-prompt.js"; +import { parseAgentMessage, parseAgentMessages } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { realpathSync } from "node:fs"; import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; @@ -23,6 +31,7 @@ import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js"; import type { AgentClient, AgentMode, + AgentPermissionRequest, AgentPromptInput, AgentProvider, AgentRunResult, @@ -125,6 +134,12 @@ interface RegisteredMcpToolWithHandler extends RegisteredMcpTool { }>; } +const subscriptionServices: FinishSubscriptions[] = []; +afterEach(async () => { + for (const service of subscriptionServices.splice(0)) await service.close(); +}); +const serverSubscriptionRecords = new WeakMap>(); +const serverSubscriptions = new WeakMap(); const serverDeliveries = new WeakMap(); const toolDeliveries = new WeakMap(); @@ -840,13 +855,43 @@ function createPaseoWorktreeForMcpTest(options: { // Typed in-memory workspace port for catalogue tests without workspace setup. async function createAgentMcpServer( options: Parameters[0], + subscriptionStore?: FinishSubscriptionStore, ) { const messages = createTestAgentMessages(options); + const records = new Map(); + const subscriptions = createFinishSubscriptions({ + store: subscriptionStore ?? { + list: async () => [...records.values()], + put: async (record) => { + records.set(record.id, record); + }, + remove: async (id) => { + records.delete(id); + }, + }, + messages: () => messages, + callerIsActive: async (id) => !(await options.agentStorage.get(id))?.archivedAt, + serverId: options.serverId ?? "self", + logger: options.logger, + ...createFinishObservation({ + ...options, + messages: () => messages, + serverId: options.serverId ?? "self", + hosts: () => { + if (!options.hosts) throw new Error("No hosts"); + return options.hosts(); + }, + }), + }); + subscriptionServices.push(subscriptions); const server = await createAgentMcpServerWithDependencies({ agentMessages: () => messages, + finishSubscriptions: () => subscriptions, workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, ...options, }); + serverSubscriptionRecords.set(server, records); + serverSubscriptions.set(server, subscriptions); serverDeliveries.set(server, messages); return server; } @@ -1375,17 +1420,17 @@ describe("terminal MCP tools", () => { output += message.data; } }); + // Every key travels through the PTY and back; on loaded Windows runners one round + // trip can take seconds, so each wait gets the same budget as the first. + const roundTrip = { timeout: 5000 }; try { - await vi.waitFor( - async () => { - const capture = await terminalManager.captureTerminal(terminal.id); - expect(capture.lines).toContain("READY"); - }, - { timeout: 5000 }, - ); + await vi.waitFor(async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines).toContain("READY"); + }, roundTrip); const tool = registeredTool(server, "send_terminal_keys"); await tool.handler({ terminalId: terminal.id, keys: "application", literal: true }); - await vi.waitFor(() => expect(output).toContain("APPLICATION_READY")); + await vi.waitFor(() => expect(output).toContain("APPLICATION_READY"), roundTrip); for (const [keys, hex] of [ ["Down", "1b4f42"], ["Up", "1b4f41"], @@ -1400,15 +1445,15 @@ describe("terminal MCP tools", () => { await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); expect(capture.lines.join("\n")).toContain(`HEX:${hex}`); - }); + }, roundTrip); } await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true }); - await vi.waitFor(() => expect(output).toContain("NORMAL_READY")); + await vi.waitFor(() => expect(output).toContain("NORMAL_READY"), roundTrip); await tool.handler({ terminalId: terminal.id, keys: "Down" }); await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); expect(capture.lines).toContain("HEX:1b5b42"); - }); + }, roundTrip); } finally { unsubscribe(); await server.close(); @@ -3399,6 +3444,7 @@ class HeldTurnAgentSession implements AgentSession { readonly prompts: string[] = []; readonly steeredPrompts: AgentPromptInput[] = []; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly permissions = new Map(); private activeTurnId: string | null = null; private turnStartGate: Promise | null = null; private nextStartError: Error | null = null; @@ -3527,11 +3573,25 @@ class HeldTurnAgentSession implements AgentSession { async setMode(): Promise {} - getPendingPermissions() { - return []; + requestPermission(id: string): void { + const request: AgentPermissionRequest = { + id, + name: "Write", + kind: "tool", + input: { path: id }, + }; + this.permissions.set(id, request); + this.pushEvent({ type: "permission_requested", provider: this.provider, request }); } - async respondToPermission(): Promise {} + getPendingPermissions() { + return [...this.permissions.values()]; + } + + async respondToPermission(requestId: string): Promise { + this.permissions.delete(requestId); + this.pushEvent({ type: "permission_resolved", provider: this.provider, requestId }); + } describePersistence() { return { provider: this.provider, sessionId: this.id }; @@ -3842,14 +3902,13 @@ describe("send_agent_prompt MCP tool", () => { secondResolve = resolve; }); for (const dispatchResult of [first, second]) { - setupFinishNotification({ - agentMessages: serverDeliveries.get(server)!, - agentManager: manager, - agentStorage: storage, - childAgentId: child.id, - callerAgentId: parent.id, - dispatchResult, - logger, + const registration = await serverSubscriptions + .get(server)! + .register({ childAgentId: child.id, callerAgentId: parent.id }, true); + void dispatchResult.then((result) => { + if (result) registration.accept(); + else void registration.reject(); + return undefined; }); } firstResolve(firstAccepted ? "steered" : false); @@ -3925,8 +3984,7 @@ describe("send_agent_prompt MCP tool", () => { await serverDeliveries .get(server)! .waitForDispatch("child-agent", new AbortController().signal); - expect(spies.agentManager.subscribe).toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); + expect(serverSubscriptionRecords.get(server)!.size).toBe(1); expect(response.structuredContent.guidance).toContain("You will get notified"); }); @@ -6735,3 +6793,125 @@ it("rejects hidden agent-worktree placement and directs callers to workspace cre }), ).rejects.toThrow("create_workspace"); }); + +describe("local durable finish subscription MCP wiring", () => { + const logger = createTestLogger(); + const existingCwd = process.cwd(); + it.each(["concurrent", "repeated"] as const)( + "reports %s permission requests and final completion", + async (sequence) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-local-permissions-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const manager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + let mcp: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcp = await connectInMemoryMcpClient(server); + await sendMcpAndAwaitDispatch(mcp, server, { + agentId: child.id, + prompt: "Permission work", + }); + const prompts = () => + parentClient.sessions[0]!.prompts.flatMap((prompt) => parseAgentMessages(prompt) ?? []) + .map((message) => message.text) + .join("\n"); + const permissionIds = () => + [...prompts().matchAll(/\n([\s\S]+?)\n<\/permission-request>/g)].map( + (match) => JSON.parse(match[1]!).requestId, + ); + const respond = async (requestId: string) => { + const result = await mcp!.callTool({ + name: "respond_to_permission", + arguments: { agentId: child.id, requestId, response: { behavior: "allow" } }, + }); + expect(result.isError).not.toBe(true); + }; + childClient.sessions[0]!.requestPermission("first"); + if (sequence === "concurrent") { + childClient.sessions[0]!.requestPermission("second"); + await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"])); + await respond("first"); + await respond("second"); + } else { + await vi.waitFor(() => expect(permissionIds()).toEqual(["first"])); + await respond("first"); + childClient.sessions[0]!.requestPermission("second"); + await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"])); + await respond("second"); + } + expect(serverSubscriptionRecords.get(server)!.size).toBe(1); + expect(prompts()).not.toContain("finished."); + childClient.sessions[0]!.finishTurn("Permission work complete"); + await vi.waitFor(() => expect(prompts()).toContain("finished.")); + expect(prompts()).toContain("Permission work complete"); + expect(permissionIds()).toEqual(["first", "second"]); + await vi.waitFor(() => expect(serverSubscriptionRecords.get(server)!.size).toBe(0)); + } finally { + await mcp?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it("returns a tool error before sending a prompt when subscription storage cannot commit", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-registration-failure-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const subscriptions = createJsonFinishSubscriptionStore(workdir); + let mcp: Client | undefined; + try { + const parent = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, { + workspaceId: "wks_parent", + }); + const child = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, { + workspaceId: "wks_parent", + }); + expect(await subscriptions.list()).toEqual([]); + await mkdir(join(workdir, "finish-subscriptions.json")); + const server = await createAgentMcpServer( + { + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }, + subscriptions, + ); + mcp = await connectInMemoryMcpClient(server); + const result = await mcp.callTool({ + name: "send_agent_prompt", + arguments: { agentId: child.id, prompt: "Must not dispatch" }, + }); + expect(result.isError).toBe(true); + expect(client.sessions[1]!.prompts).toEqual([]); + expect(await subscriptions.list()).toEqual([]); + } finally { + await mcp?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 50d9c56ab5..a8237d87d0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; +import { withClaudeReplayHistory } from "./transcript-history.js"; /** * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. @@ -91,6 +92,53 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); + test("loads readable history when the temporary directory is unavailable", () => { + const file = transcriptPath(); + const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(entry)); + const unavailable = path.join(tempRoot, "unavailable-tmp"); + for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable); + withClaudeReplayHistory(file, (history) => { + expect([...history.parentEntries]).toEqual([entry]); + }); + }); + + test.each(["parent", "child", "workflow"])( + "preserves indexed %s history when its transcript disappears between passes", + (target) => { + const parent = transcriptPath(); + const sessionDirectory = parent.slice(0, -".jsonl".length); + const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl"); + const workflow = path.join( + sessionDirectory, + "subagents", + "workflows", + "wf_test", + "agent-child.jsonl", + ); + const parentEntry = { + type: "assistant", + message: { content: [{ type: "text", text: "parent" }] }, + }; + const childEntry = { ...parentEntry, isSidechain: true, agentId: "child" }; + mkdirSync(path.dirname(workflow), { recursive: true }); + writeFileSync(parent, JSON.stringify(parentEntry)); + writeFileSync(child, JSON.stringify(childEntry)); + writeFileSync(workflow, JSON.stringify(childEntry)); + withClaudeReplayHistory(parent, (history) => { + rmSync({ parent, child, workflow }[target]); + // Ownership facts and visible payloads are separate passes over the same source. + for (let pass = 0; pass < 2; pass++) { + expect([...history.parentEntries]).toEqual([parentEntry]); + expect(history.subagents).toHaveLength(1); + expect([...history.subagents[0]!.entries]).toEqual([childEntry]); + expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]); + } + }); + }, + ); + // chmod does not remove read access on Windows, or for root. test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "logs a warning with the error when Claude's project folders cannot be listed", diff --git a/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts new file mode 100644 index 0000000000..f0fb26ecc3 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import pino from "pino"; +import { ClaudeAgentClient } from "./agent.js"; + +test("lists a session containing a large image record and retains the final prompt", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-import-image-")); + try { + const project = path.join(configDir, "projects", "image-project"); + await fs.mkdir(project, { recursive: true }); + const first = { + type: "user", + sessionId: "image-session", + cwd: "/image-project", + message: { content: "Describe the image" }, + }; + const image = { + type: "user", + sessionId: "image-session", + cwd: "/image-project", + message: { + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "a".repeat(5 * 1024 * 1024) }, + }, + ], + }, + }; + const last = { type: "user", message: { content: "What changed?" } }; + await fs.writeFile( + path.join(project, "image-session.jsonl"), + [first, image, last].map((record) => JSON.stringify(record)).join("\n"), + ); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + const sessions = await client.listImportableSessions({ limit: 1 }); + expect(sessions).toEqual([ + expect.objectContaining({ + providerHandleId: "image-session", + title: "Describe the image", + firstPromptPreview: "Describe the image", + lastPromptPreview: "What changed?", + }), + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 48ddab5973..8f2ba60907 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -1,6 +1,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { generateReplayCorpus, generateWorkflowReplayCorpus } from "./replay-memory.fixture.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -432,6 +435,267 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(upserts(await replayDescriptors())).toEqual([]); }); + test("replays several legacy children interleaved in the parent transcript", async () => { + const lines: string[] = []; + for (let index = 0; index < 8; index++) { + const agentId = `legacy-${index}`; + const toolUseId = `task-${index}`; + lines.push(parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }])); + lines.push( + JSON.stringify({ + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: toolUseId, content: `agentId: ${agentId}\ndone` }, + ], + }, + }), + ); + lines.push(sidechainEntry({ agentId, stopReason: "end_turn" })); + } + writeParentSession(lines); + const events = await replayDescriptors(); + const histories = events + .map((event) => event.event) + .filter((event) => event.type === "timeline"); + expect(histories.map((event) => event.id).sort()).toEqual( + Array.from({ length: 8 }, (_, index) => `task-${index}`), + ); + expect(histories.map((event) => event.item)).toEqual( + Array.from({ length: 8 }, () => ({ type: "assistant_message", text: "summary of the docs" })), + ); + }); + + test("replays more child events than V8 permits in one spread call", async () => { + const childCount = 16; + const entriesPerChild = 10_000; + const children = Array.from({ length: childCount }, (_, index) => ({ + agentId: `large-child-${index}`, + toolUseId: `large-task-${index}`, + })); + const parentLines = children.flatMap(({ agentId, toolUseId }) => [ + parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]), + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `agentId: ${agentId}\ndone`, + }, + ], + }, + }), + ]); + const subagentDir = writeParentSession(parentLines); + for (const { agentId, toolUseId } of children) { + writeSubagent({ + subagentDir, + agentId, + meta: JSON.stringify({ toolUseId }), + sidechainLines: Array.from({ length: entriesPerChild }, () => sidechainEntry({ agentId })), + }); + } + + const events = await replayDescriptors(); + const timelineCount = events.filter((event) => event.event.type === "timeline").length; + expect(timelineCount).toBe(childCount * entriesPerChild); + expect(upserts(events).filter((event) => event.status === "completed")).toHaveLength( + childCount, + ); + }, 120_000); + + test("limits retained child shell output to the timeline's existing content limit", async () => { + const output = "x".repeat(128 * 1024); + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + content: [ + { + type: "tool_use", + id: "bash-large", + name: "Bash", + input: { command: "echo large" }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + content: [{ type: "tool_result", tool_use_id: "bash-large", content: output }], + }, + }), + ], + }); + const events = await replayDescriptors(); + const completed = events + .map((event) => event.event) + .filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ); + expect(completed).toEqual([ + expect.objectContaining({ + type: "timeline", + id: TOOL_USE_ID, + item: expect.objectContaining({ + detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }), + }), + }), + ]); + }); + + test.each([ + { + label: "a parent transcript without sidechains", + mode: "children", + // Cross V8's whole-string limit only in the opt-in reproduction, keeping routine CI small. + generate: (root: string) => + generateReplayCorpus(root, { + parentMiB: process.env.PASEO_TEST_LARGE_CLAUDE_REPLAY === "1" ? 550 : 2, + childrenMiB: 0, + children: 0, + }), + expected: { parentItems: 1, completed: 0, histories: 0 }, + }, + { + label: "4 MiB parent and 20 sidechains", + mode: "children", + generate: (root: string) => + generateReplayCorpus(root, { parentMiB: 4, childrenMiB: 4, children: 20 }), + expected: { parentItems: 41, completed: 20, histories: 20 }, + }, + { + label: "five 2 MiB workflow sidechains", + mode: "workflow", + generate: (root: string) => generateWorkflowReplayCorpus(root, 2), + expected: { parentItems: 3, completed: 1, histories: 5 }, + }, + ])( + "opens $label with all history under a 256 MB heap", + ({ mode, generate, expected }) => { + const root = mkdtempSync(path.join(os.tmpdir(), "claude-replay-")); + try { + generate(root); + const worker = spawnSync( + process.execPath, + [ + "--max-old-space-size=256", + "--import", + "tsx", + fileURLToPath(new URL("./replay-memory.fixture.ts", import.meta.url)), + root, + mode, + ], + { encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1" }, timeout: 120_000 }, + ); + expect({ status: worker.status, signal: worker.signal, stderr: worker.stderr }).toEqual({ + status: 0, + signal: null, + stderr: "", + }); + const result = JSON.parse(worker.stdout); + expect(result).toMatchObject(expected); + console.log("Claude replay memory:", result); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 150_000, + ); + + test("replays UTF-8 across read chunks, corrupt rows, CRLF and an unterminated final row", async () => { + const text = "é🙂".repeat(30_000); + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync( + path.join(historyDir, "replay-session.jsonl"), + parentEntry([{ type: "text", text }]) + + "\r\nnot json\r\n" + + parentEntry([{ type: "text", text: "final row" }]), + ); + const events = await replayEvents(); + expect(events.filter((event) => event.type === "timeline").map((event) => event.item)).toEqual([ + { type: "assistant_message", text }, + { type: "assistant_message", text: "final row" }, + ]); + }); + + test("converts workflow tools in timestamp order before correlating their results", async () => { + writeWorkflowSession("completed"); + const directory = path.join( + claudeProjectDirSync(cwd, { configDir }), + "replay-session", + "subagents", + "workflows", + WORKFLOW_RUN_ID, + ); + writeSubagent({ + subagentDir: directory, + agentId: "a-result", + sidechainLines: [ + JSON.stringify({ + type: "user", + timestamp: "2026-07-26T06:28:01.000Z", + message: { + content: [{ type: "tool_result", tool_use_id: "workflow-bash", content: "finished" }], + }, + }), + ], + }); + writeSubagent({ + subagentDir: directory, + agentId: "z-call", + sidechainLines: [ + JSON.stringify({ + type: "assistant", + timestamp: "2026-07-26T06:28:00.000Z", + message: { + content: [ + { + type: "tool_use", + id: "workflow-bash", + name: "Bash", + input: { command: "echo finished" }, + }, + ], + }, + }), + ], + }); + const events = await replayDescriptors(); + const calls = events + .map((event) => event.event) + .filter((event) => event.type === "timeline" && event.item.type === "tool_call"); + expect(calls).toEqual([ + expect.objectContaining({ + id: WORKFLOW_TOOL_USE_ID, + item: expect.objectContaining({ callId: "workflow-bash", name: "Bash", status: "running" }), + }), + expect.objectContaining({ + id: WORKFLOW_TOOL_USE_ID, + item: expect.objectContaining({ + callId: "workflow-bash", + name: "Bash", + status: "completed", + detail: expect.objectContaining({ type: "shell", output: "finished" }), + }), + }), + ]); + }); + test("does not accumulate internal workflow agents as replay-only running rows", async () => { const subagentRoot = writeParentSession([ parentEntry([ diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d6b869f1e0..2b2c725269 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1,6 +1,9 @@ +import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { promisify } from "node:util"; +import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { PermissionResult, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; @@ -15,9 +18,11 @@ import { resolveClaudeCodeVersion, toClaudeSdkMcpConfig, } from "./agent.js"; +import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; import type { + ImportableProviderSession, AgentPromptInput, AgentSession, AgentTimelineItem, @@ -1740,6 +1745,404 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { }); describe("ClaudeAgentClient.listImportableSessions", () => { + async function descriptorFixture(count: number) { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-errors-")); + const projectDir = path.join(configDir, "projects", "errors"); + await fs.mkdir(projectDir, { recursive: true }); + const files: string[] = []; + const descriptors: ImportableProviderSession[] = []; + const logs: unknown[] = []; + for (let index = 0; index < count; index += 1) { + const file = path.join(projectDir, `${index}.jsonl`); + const timestamp = new Date(Date.UTC(2026, 0, 1) + (count - index) * 1000); + await fs.writeFile( + file, + JSON.stringify({ + type: "user", + sessionId: `session-${index}`, + cwd: "/errors", + message: { content: `Prompt ${index}` }, + }), + ); + await fs.utimes(file, timestamp, timestamp); + files.push(file); + descriptors.push({ + providerHandleId: `session-${index}`, + cwd: "/errors", + title: `Prompt ${index}`, + firstPromptPreview: `Prompt ${index}`, + lastPromptPreview: `Prompt ${index}`, + lastActivityAt: timestamp, + }); + } + const options = { + logger: pino({ level: "warn" }, { write: (line: string) => logs.push(JSON.parse(line)) }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }; + return { + files, + descriptors, + logs, + options, + cleanup: () => fs.rm(configDir, { recursive: true, force: true }), + }; + } + + const cannotEnforceFilePermissions = process.platform === "win32" || process.getuid?.() === 0; + const discoveryPermissionCases = [ + { label: "project enumeration", mode: 0, deniedSuffix: "", existingSuffix: "" }, + { + label: "candidate stat", + mode: 0o400, + deniedSuffix: "hidden.jsonl", + existingSuffix: "0.jsonl", + }, + ]; + test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)( + "logs denied $label while discovering a healthy project", + async ({ mode, deniedSuffix }) => { + const fixture = await descriptorFixture(1); + const projectsRoot = path.dirname(path.dirname(fixture.files[0]!)); + const deniedProject = path.join(projectsRoot, "denied-project"); + await fs.mkdir(deniedProject); + try { + await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}'); + await fs.chmod(deniedProject, mode); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual( + fixture.descriptors, + ); + expect(fixture.logs).toEqual([ + expect.objectContaining({ + path: path.join(deniedProject, deniedSuffix), + err: expect.objectContaining({ code: "EACCES" }), + msg: "Skipping Claude session discovery path with denied access", + }), + ]); + } finally { + await fs.chmod(deniedProject, 0o700); + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)( + "surfaces a provider error when $label denies discovery in every project", + async ({ mode, deniedSuffix, existingSuffix }) => { + const fixture = await descriptorFixture(1); + const existingProject = path.dirname(fixture.files[0]!); + const deniedProject = path.join(path.dirname(existingProject), "denied-project"); + await fs.mkdir(deniedProject); + try { + await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}'); + await fs.chmod(existingProject, mode); + await fs.chmod(deniedProject, mode); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({ + name: "AggregateError", + message: expect.stringContaining("no transcripts could be discovered"), + errors: expect.arrayContaining([ + expect.objectContaining({ + code: "EACCES", + path: path.join(existingProject, existingSuffix), + }), + expect.objectContaining({ + code: "EACCES", + path: path.join(deniedProject, deniedSuffix), + }), + ]), + }); + expect(fixture.logs).toHaveLength(2); + expect(fixture.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: path.join(existingProject, existingSuffix), + err: expect.objectContaining({ code: "EACCES" }), + }), + expect.objectContaining({ + path: path.join(deniedProject, deniedSuffix), + err: expect.objectContaining({ code: "EACCES" }), + }), + ]), + ); + } finally { + await fs.chmod(existingProject, 0o700); + await fs.chmod(deniedProject, 0o700); + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions)( + "isolates an actual access-denied transcript while returning a healthy session", + async () => { + const fixture = await descriptorFixture(2); + try { + await fs.chmod(fixture.files[0]!, 0); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([ + fixture.descriptors[1], + ]); + expect(fixture.logs).toEqual([ + expect.objectContaining({ + path: fixture.files[0], + err: expect.objectContaining({ code: "EACCES" }), + msg: "Skipping Claude transcript with unreadable import metadata", + }), + ]); + } finally { + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions)( + "surfaces a provider error when every actual candidate is access denied", + async () => { + const fixture = await descriptorFixture(2); + try { + await Promise.all(fixture.files.map((file) => fs.chmod(file, 0))); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({ + name: "AggregateError", + errors: fixture.files.map((file) => + expect.objectContaining({ code: "EACCES", path: file }), + ), + message: expect.stringContaining( + "all 2 candidate transcripts have unreadable import metadata", + ), + }); + expect(fixture.logs).toHaveLength(2); + } finally { + await fixture.cleanup(); + } + }, + ); + + test("returns an empty listing for readable transcripts without import metadata", async () => { + const fixture = await descriptorFixture(2); + try { + await Promise.all(fixture.files.map((file) => fs.writeFile(file, '{"type":"assistant"}'))); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([]); + expect(fixture.logs).toEqual([]); + } finally { + await fixture.cleanup(); + } + }); + + test("refreshes descriptors after append, rewrite, replacement, and deletion", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-cache-")); + try { + const projectDir = path.join(configDir, "projects", "changing-transcript"); + await fs.mkdir(projectDir, { recursive: true }); + const sessionFile = path.join(projectDir, "changing.jsonl"); + const initial = `${JSON.stringify({ + type: "user", + sessionId: "changing-session", + cwd: "/changing-project", + message: { content: "First prompt" }, + })}\n`; + function title(customTitle: string): string { + return `${JSON.stringify({ type: "custom-title", customTitle })}\n`; + } + await fs.writeFile(sessionFile, initial + title("First title")); + const timestamp = new Date("2026-06-01T12:00:00.000Z"); + await fs.utimes(sessionFile, timestamp, timestamp); + + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + const first = await client.listImportableSessions({ limit: 1 }); + expect(first.map((session) => session.title)).toEqual(["First title"]); + // Callers must not be able to mutate a cached descriptor, including its Date. + first[0]!.title = "Changed by caller"; + first[0]!.lastActivityAt.setTime(0); + const repeated = await Promise.all([ + client.listImportableSessions({ limit: 1 }), + client.listImportableSessions({ limit: 1 }), + ]); + expect(repeated.map((sessions) => [sessions[0]!.title, sessions[0]!.lastActivityAt])).toEqual( + [ + ["First title", timestamp], + ["First title", timestamp], + ], + ); + + repeated[0]![0]!.title = "Changed by overlapping caller"; + repeated[0]![0]!.lastActivityAt.setTime(0); + expect([repeated[1]![0]!.title, repeated[1]![0]!.lastActivityAt]).toEqual([ + "First title", + timestamp, + ]); + + await fs.appendFile(sessionFile, title("After append")); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["After append"]); + + // Same-length rewrites with a restored mtime still invalidate the descriptor. + await fs.writeFile(sessionFile, initial + title("Other title")); + await fs.utimes(sessionFile, timestamp, timestamp); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["Other title"]); + + const replacement = path.join(projectDir, "replacement.tmp"); + await fs.writeFile(replacement, initial + title("Final title")); + await fs.utimes(replacement, timestamp, timestamp); + await fs.rename(replacement, sessionFile); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["Final title"]); + + await fs.unlink(sessionFile); + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }); + + test.each([ + { providers: 1, requestsPerProvider: 4 }, + { providers: 5, requestsPerProvider: 3 }, + ])( + "discovers large histories with $providers providers and $requestsPerProvider requests each", + async ({ providers, requestsPerProvider }) => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-heap-")); + try { + const projectDir = path.join(configDir, "projects", "large-transcript"); + await fs.mkdir(projectDir, { recursive: true }); + const sessionFile = path.join(projectDir, "large.jsonl"); + const transcript = await fs.open(sessionFile, "w"); + try { + await transcript.writeFile( + `${JSON.stringify({ + type: "user", + sessionId: "large-session", + cwd: "/large-project", + message: { content: "First prompt" }, + })}\r\n`, + ); + const block = `${JSON.stringify({ type: "assistant", text: "x".repeat(1024) })}\n`.repeat( + 1024, + ); + for (let index = 0; index < 128; index += 1) { + await transcript.writeFile(block); + } + // A malformed record must not hide the final unterminated record. + await transcript.writeFile( + `incomplete json\n${JSON.stringify({ + type: "user", + message: { content: "Last prompt 🐢" }, + })}`, + ); + } finally { + await transcript.close(); + } + + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "--max-old-space-size=96", + "--import", + import.meta.resolve("tsx"), + "--input-type=module", + "--eval", + ` + import { deepStrictEqual } from "node:assert"; + import { ClaudeAgentClient } from ${JSON.stringify(new URL("./agent.ts", import.meta.url).href)}; + import pino from ${JSON.stringify(import.meta.resolve("pino"))}; + import { mkdir, symlink } from "node:fs/promises"; + import path from "node:path"; + const clients = []; + for (let index = 0; index < ${providers}; index++) { + const providerConfigDir = path.join(${JSON.stringify(configDir)}, "alias-" + index); + await mkdir(providerConfigDir); + await symlink(path.join(${JSON.stringify(configDir)}, "projects"), path.join(providerConfigDir, "projects"), "junction"); + clients.push(new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, + })); + } + const listings = await Promise.all(clients.flatMap(client => + Array.from({ length: ${requestsPerProvider} }, () => client.listImportableSessions({ limit: 1 })) + )); + const sessions = listings[0]; + for (const listing of listings) deepStrictEqual(listing, sessions); + console.log(JSON.stringify(sessions)); + `, + ], + { timeout: 20_000 }, + ); + + expect(JSON.parse(stdout)).toEqual([ + { + providerHandleId: "large-session", + cwd: "/large-project", + title: "First prompt", + firstPromptPreview: "First prompt", + lastPromptPreview: "Last prompt 🐢", + lastActivityAt: (await fs.stat(sessionFile)).mtime.toISOString(), + }, + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }, + ); + + test("keeps metadata and UTF-8 across read chunk boundaries", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-limit-")); + try { + const projectDir = path.join(configDir, "projects", "boundary"); + await fs.mkdir(projectDir, { recursive: true }); + const prefix = '{"padding":"'; + // Split the four-byte turtle across a 64 KiB input chunk boundary. + const padding = "x".repeat(64 * 1024 - Buffer.byteLength(prefix) - 1); + const suffix = + '","type":"user","sessionId":"boundary-session","cwd":"/boundary","message":{"content":"Hello 🐢"}}'; + const remaining = 4 * 1024 * 1024 - Buffer.byteLength(prefix + padding + "🐢" + suffix); + await fs.writeFile( + path.join(projectDir, "boundary.jsonl"), + prefix + padding + "🐢" + "x".repeat(remaining) + suffix, + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => ({ + providerHandleId: session.providerHandleId, + cwd: session.cwd, + title: session.title, + firstPromptPreview: session.firstPromptPreview, + lastPromptPreview: session.lastPromptPreview, + })), + ).toEqual([ + { + providerHandleId: "boundary-session", + cwd: "/boundary", + title: "Hello 🐢", + firstPromptPreview: "Hello 🐢", + lastPromptPreview: "Hello 🐢", + }, + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }); + + test("rejects a cancelled listing before doing filesystem work", async () => { + const controller = new AbortController(); + controller.abort(new Error("Picker closed")); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + await expect(client.listImportableSessions({ signal: controller.signal })).rejects.toThrow( + "Picker closed", + ); + }); + test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => { const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-")); const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; @@ -3695,3 +4098,184 @@ describe("Claude question permission notifications", () => { expect(request.description).toBeUndefined(); }); }); + +describe("Claude import descriptor scheduling", () => { + function controlledReads() { + const started: string[] = []; + const reads = new Map< + string, + { resolve: (value: string) => void; reject: (reason: unknown) => void } + >(); + const signals = new Map(); + const sharedScheduler = createImportDescriptorScheduler( + (candidate: { cacheKey: string }, signal) => { + started.push(candidate.cacheKey); + signals.set(candidate.cacheKey, signal); + return new Promise((resolve, reject) => { + reads.set(candidate.cacheKey, { resolve, reject }); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }, + ); + function scheduler(candidates: string[], signal: AbortSignal): Promise { + return sharedScheduler( + candidates.map((cacheKey) => ({ cacheKey })), + signal, + ); + } + return { scheduler, started, reads, signals }; + } + + test("shares a cold read across overlapping listings and leaves capacity for unrelated work", async () => { + const { scheduler, started, reads } = controlledReads(); + const signal = new AbortController().signal; + const overlapping = Array.from({ length: 4 }, () => scheduler(["shared"], signal)); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + const unrelated = scheduler(["unrelated"], signal); + await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated"])); + reads.get("unrelated")!.resolve("unrelated descriptor"); + await expect(unrelated).resolves.toEqual(["unrelated descriptor"]); + reads.get("shared")!.resolve("shared descriptor"); + await expect(Promise.all(overlapping)).resolves.toEqual( + Array.from({ length: 4 }, () => ["shared descriptor"]), + ); + // A settled entry must not become an accidental second cache. + const later = scheduler(["shared"], signal); + await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated", "shared"])); + reads.get("shared")!.resolve("updated descriptor"); + await expect(later).resolves.toEqual(["updated descriptor"]); + }); + + test("cancelling the initiating listing keeps its other subscriber alive", async () => { + const { scheduler, started, reads } = controlledReads(); + const initiator = new AbortController(); + const first = scheduler(["shared"], initiator.signal); + const firstRejected = expect(first).rejects.toThrow("First picker closed"); + const second = scheduler(["shared"], new AbortController().signal); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + initiator.abort(new Error("First picker closed")); + await firstRejected; + reads.get("shared")!.resolve("shared descriptor"); + await expect(second).resolves.toEqual(["shared descriptor"]); + expect(started).toEqual(["shared"]); + }); + + test("the final subscriber cancellation aborts the read and allows a fresh subscription", async () => { + const { scheduler, started, signals } = controlledReads(); + const firstController = new AbortController(); + const secondController = new AbortController(); + const first = scheduler(["shared"], firstController.signal); + const second = scheduler(["shared"], secondController.signal); + const firstRejected = expect(first).rejects.toThrow("First picker closed"); + const secondRejected = expect(second).rejects.toThrow("Final picker closed"); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + firstController.abort(new Error("First picker closed")); + await firstRejected; + expect(signals.get("shared")!.aborted).toEqual(false); + secondController.abort(new Error("Final picker closed")); + await secondRejected; + expect(signals.get("shared")!.aborted).toEqual(true); + const retryController = new AbortController(); + const retry = scheduler(["shared"], retryController.signal); + const retryRejected = expect(retry).rejects.toThrow("Retry closed"); + await vi.waitFor(() => expect(started).toEqual(["shared", "shared"])); + retryController.abort(new Error("Retry closed")); + await retryRejected; + expect(signals.get("shared")!.aborted).toEqual(true); + }); + + test("runs four reads per listing and gives a later small listing a bounded turn", async () => { + const { scheduler, started, reads, signals } = controlledReads(); + const controller = new AbortController(); + const large = scheduler( + Array.from({ length: 500 }, (_, index) => `large-${index}`), + controller.signal, + ); + const largeRejected = expect(large).rejects.toThrow("Fairness check complete"); + await vi.waitFor(() => expect(started).toEqual(["large-0", "large-1", "large-2", "large-3"])); + const small = scheduler(["small"], new AbortController().signal); + reads.get("large-0")!.resolve("first descriptor"); + await vi.waitFor(() => + expect(started).toEqual(["large-0", "large-1", "large-2", "large-3", "small"]), + ); + reads.get("small")!.resolve("small descriptor"); + await expect(small).resolves.toEqual(["small descriptor"]); + controller.abort(new Error("Fairness check complete")); + await largeRejected; + expect(signals.get("large-1")!.aborted).toEqual(true); + expect(signals.get("large-2")!.aborted).toEqual(true); + expect(signals.get("large-3")!.aborted).toEqual(true); + expect(started.length).toBeLessThanOrEqual(6); + }); + + test("refills four workers while retaining candidate order after out-of-order completions", async () => { + const { scheduler, started, reads } = controlledReads(); + const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"])); + reads.get("3")!.resolve("descriptor-3"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4"])); + reads.get("2")!.resolve("descriptor-2"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4", "5"])); + reads.get("5")!.resolve("descriptor-5"); + reads.get("4")!.resolve("descriptor-4"); + reads.get("1")!.resolve("descriptor-1"); + reads.get("0")!.resolve("descriptor-0"); + await expect(listing).resolves.toEqual([ + "descriptor-0", + "descriptor-1", + "descriptor-2", + "descriptor-3", + "descriptor-4", + "descriptor-5", + ]); + }); + + test("an unexpected read failure cancels sibling workers without starting remaining candidates", async () => { + const { scheduler, started, reads, signals } = controlledReads(); + const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal); + const rejected = expect(listing).rejects.toThrow("Unexpected read failure"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"])); + reads.get("0")!.reject(new TypeError("Unexpected read failure")); + await rejected; + expect(signals.get("1")!.aborted).toEqual(true); + expect(signals.get("2")!.aborted).toEqual(true); + expect(signals.get("3")!.aborted).toEqual(true); + expect(started).toEqual(["0", "1", "2", "3"]); + }); + + test("bounds global reads and skips cancelled queued candidates", async () => { + const { scheduler, started, reads } = controlledReads(); + const controllers = Array.from({ length: 5 }, () => new AbortController()); + const listings = controllers.map((controller, index) => + scheduler([`${index}-first`], controller.signal), + ); + // Attach handlers before aborting any promises. + function errorMessage(error: Error): string { + return error.message; + } + const settled = listings.map((listing) => listing.catch(errorMessage)); + await vi.waitFor(() => expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"])); + controllers[4]!.abort(new Error("Queued picker closed")); + await expect(settled[4]).resolves.toEqual("Queued picker closed"); + controllers[0]!.abort(new Error("Active picker closed")); + await expect(settled[0]).resolves.toEqual("Active picker closed"); + reads.get("1-first")!.reject(new Error("Stop 1")); + reads.get("2-first")!.reject(new Error("Stop 2")); + reads.get("3-first")!.reject(new Error("Stop 3")); + await expect(Promise.all(settled)).resolves.toEqual([ + "Active picker closed", + "Stop 1", + "Stop 2", + "Stop 3", + "Queued picker closed", + ]); + expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]); + // A cancelled queue entry must not consume a later listing's read slot. + const later = scheduler(["later"], new AbortController().signal); + await vi.waitFor(() => + expect(started).toEqual(["0-first", "1-first", "2-first", "3-first", "later"]), + ); + reads.get("later")!.resolve("later descriptor"); + await expect(later).resolves.toEqual(["later descriptor"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index e39d4a1045..59512d0d44 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4,6 +4,8 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import path from "node:path"; +import { LRUCache } from "lru-cache"; +import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { type AgentDefinition, type CanUseTool, @@ -43,6 +45,7 @@ import { resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; +import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; @@ -50,17 +53,10 @@ import { ClaudeTaskProtocolSource, type ClaudeHookObservationInput, } from "./subagents/live-source.js"; -import { - observeReplaySubagents, - parseClaudeSubagentMeta, - type ClaudeReplayParentFacts, - type ClaudeSubagentMeta, -} from "./subagents/replay-source.js"; +import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js"; import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; -import { - observeReplayWorkflows, - parseClaudeWorkflowRun, -} from "./subagents/workflow-replay-source.js"; +import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; +import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js"; import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { @@ -467,6 +463,10 @@ interface ClaudeAgentClientOptions { rewindSdk?: ClaudeRewindSdk; } +type ClaudeImportDescriptorResult = + | { type: "parsed"; descriptor: ImportableProviderSession | null } + | { type: "unreadable"; error: Error }; + interface ClaudeAgentSessionOptions { defaults?: { agents?: Record }; runtimeSettings?: ProviderRuntimeSettings; @@ -1571,6 +1571,15 @@ export class ClaudeAgentClient implements AgentClient { private readonly resolveBinary: () => Promise; private readonly resolveVersion: (signal?: AbortSignal) => Promise; private readonly rewindSdk: ClaudeRewindSdk; + private readonly scheduleImportDescriptors = createImportDescriptorScheduler( + (candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal), + ); + private readonly importDescriptorCache = new LRUCache({ + max: 500, + maxSize: 8 * 1024 * 1024, + sizeCalculation: (descriptor, key) => + 128 + 2 * (JSON.stringify(descriptor).length + key.length), + }); constructor(options: ClaudeAgentClientOptions) { this.discoverModels = options.discoverModels ?? discoverClaudeModels; @@ -1714,26 +1723,73 @@ export class ClaudeAgentClient implements AgentClient { } async listImportableSessions( - options?: ListImportableSessionsOptions, + options?: ListImportableSessionsOptions & { signal?: AbortSignal }, ): Promise { + // Leave time to deliver the provider error before the client RPC expires at 60 seconds. + const deadline = AbortSignal.timeout(45_000); + const signal = options?.signal ? AbortSignal.any([options.signal, deadline]) : deadline; + signal.throwIfAborted(); const configDir = claudeConfigDir(this.buildProviderEnv()); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) : path.join(configDir, "projects"); - if (!(await pathExists(sessionsRoot))) { - return []; - } + const rootExists = await pathExists(sessionsRoot); + signal.throwIfAborted(); + if (!rootExists) return []; const limit = options?.limit ?? 20; const scanLimit = Math.min(options?.scanLimit ?? limit * 3, 500); const candidates = await collectRecentClaudeSessions(sessionsRoot, scanLimit, { rootIsProjectDir: Boolean(options?.cwd), + logger: this.logger, }); - const parsed = await Promise.all( - candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)), - ); + const results = await this.scheduleImportDescriptors(candidates, signal); + const failures = results.filter((result) => result.type === "unreadable"); + if (failures.length > 0 && failures.length === results.length) { + const errors = failures.map((result) => result.error); + throw new AggregateError( + errors, + `Cannot list Claude sessions: all ${failures.length} candidate transcripts have unreadable import metadata. ${errors[0]!.message}`, + ); + } + const parsed = results.filter((result) => result.type === "parsed"); return parsed + .map((result) => result.descriptor) .filter((session): session is ImportableProviderSession => session !== null) - .slice(0, limit); + .slice(0, limit) + .map((session) => structuredClone(session)); + } + + private async readImportDescriptor( + candidate: ClaudeSessionCandidate, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const cached = this.importDescriptorCache.get(candidate.cacheKey); + if (cached) return { type: "parsed", descriptor: structuredClone(cached) }; + + let descriptor: ImportableProviderSession | null; + try { + descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime, signal); + } catch (error) { + signal.throwIfAborted(); + if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error; + this.logger.warn( + { err: error, path: candidate.path }, + "Skipping Claude transcript with unreadable import metadata", + ); + return { type: "unreadable", error }; + } + if (descriptor) { + const current = await fsPromises.stat(candidate.path).catch((error: unknown) => { + if (!isExpectedClaudeImportFileError(error)) throw error; + return null; + }); + signal.throwIfAborted(); + if (current && claudeSessionCacheKey(candidate.path, current) === candidate.cacheKey) { + this.importDescriptorCache.set(candidate.cacheKey, structuredClone(descriptor)); + } + } + return { type: "parsed", descriptor: descriptor ? structuredClone(descriptor) : null }; } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { @@ -5258,12 +5314,10 @@ class ClaudeAgentSession implements AgentSession { ); return; } - const content = fs.readFileSync(historyPath, "utf8"); - const replay = this.ingestPersistedSidechains( - content, - readClaudeSidechainHistory(historyPath), - ); - this.ingestPersistedHistory(content, replay); + withClaudeReplayHistory(historyPath, (history) => { + const replay = this.ingestPersistedSidechains(history); + this.ingestPersistedHistory(history.parentEntries, replay); + }); } catch (error) { this.logger.warn( { err: error, sessionId, historyPath }, @@ -5272,14 +5326,13 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedHistory(content: string, replay: ClaudeReplayOwnership): void { - if (!content) { - return; - } - + private ingestPersistedHistory( + entries: Iterable, + replay: ClaudeReplayOwnership, + ): void { const timeline: PersistedTimelineEntry[] = []; - for (const line of content.split(/\r?\n/)) { - this.ingestPersistedHistoryLine(line, timeline, replay); + for (const entry of entries) { + this.ingestPersistedHistoryEntry(entry, timeline, replay); } if (timeline.length > 0) { @@ -5288,46 +5341,26 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains( - parentContent: string, - sidechains: ClaudeSidechainHistory, - ): ClaudeReplayOwnership { - const parentEntries = parseClaudeHistoryRecords(parentContent).filter( - (entry) => entry.isSidechain !== true, - ); - const sidechainEntries = [parentContent, ...sidechains.contents] - .flatMap(parseClaudeHistoryRecords) - .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); - const parentFacts = readClaudeReplayParentFacts(parentEntries); + private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership { + const parentFacts = readClaudeReplayParentFacts(history.parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. const subagentReplay = observeReplaySubagents({ - subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ - agentId, - meta: sidechains.metaByAgentId.get(agentId) ?? null, - entries, - parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), + subagents: history.subagents.map((subagent) => ({ + ...subagent, + parentFacts: readClaudeReplayParentFacts(subagent.entries), })), parent: parentFacts, - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + convertEntry: (entry) => this.convertHistoryEntry(entry), }); const observations = [ ...subagentReplay.observations, ...observeReplayWorkflows({ - workflows: sidechains.workflowContents - .map(parseClaudeWorkflowRun) - .filter((workflow) => workflow !== null), - parentEntries, - entriesByRunId: new Map( - [...sidechains.workflowSidechainContentsByRunId].map(([runId, contents]) => [ - runId, - contents - .flatMap(parseClaudeHistoryRecords) - .filter((entry) => entry.type !== "user" || isToolResultUserEntry(entry)), - ]), - ), - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + workflows: history.workflows, + parentEntries: history.parentEntries, + entriesByRunId: history.workflowEntriesByRunId, + convertEntry: (entry) => this.convertHistoryEntry(entry), }), ]; const restoredProviderSubagentIds = new Set( @@ -5342,41 +5375,22 @@ class ClaudeAgentSession implements AgentSession { }; if (observations.length === 0) return replay; - this.persistedProviderSubagentEvents.push( - ...foldSubagentObservations(observations).map( - (event): Extract => ({ - type: "provider_subagent", - provider: "claude", - event, - }), - ), - ); + for (const event of foldSubagentObservations(observations)) { + this.persistedProviderSubagentEvents.push({ + type: "provider_subagent", + provider: "claude", + event, + }); + } this.historyPending = true; return replay; } - private ingestPersistedHistoryLine( - line: string, + private ingestPersistedHistoryEntry( + entry: ClaudeHistoryEntry, timeline: PersistedTimelineEntry[], replay: ClaudeReplayOwnership, ): void { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - - let entry: Record; - try { - const parsed: unknown = JSON.parse(trimmed); - const record = toObjectRecord(parsed); - if (!record) { - return; - } - entry = record; - } catch { - return; - } - if (entry.isSidechain) { return; } @@ -5425,12 +5439,12 @@ class ClaudeAgentSession implements AgentSession { } if (items.length > 0) { - timeline.push( - ...items.map((item) => ({ + for (const item of items) { + timeline.push({ item, timestamp: historyTimestamp ?? undefined, - })), - ); + }); + } } } @@ -5445,7 +5459,9 @@ class ClaudeAgentSession implements AgentSession { } private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { - return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)); + return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map( + limitAgentTimelineItemContent, + ); } // Maps Claude content blocks into AgentTimelineItems. @@ -6165,21 +6181,6 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null { return null; } -function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { - const entries: ClaudeHistoryEntry[] = []; - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = toObjectRecord(JSON.parse(trimmed)); - if (entry) entries.push(entry); - } catch { - // Ignore individual corrupt history rows, matching the parent history replay behavior. - } - } - return entries; -} - /** The parent's card for a subagent, labeled with its type and task. */ function buildClaudeSubagentCardDetail( facts: ClaudeSubagentCardFacts, @@ -6212,187 +6213,49 @@ function labelReplayedSubagentCard( * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before * Claude Code wrote the meta sidecar. */ -function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): ClaudeReplayParentFacts { +function readClaudeReplayParentFacts( + entries: Iterable, +): ClaudeReplayParentFacts { const toolCalls = new Map(); - for (const [id, call] of readClaudeHistoricalSubagentToolCalls(parentEntries)) { - toolCalls.set(id, { - ...((call.name ?? call.subagentType) ? { title: call.name ?? call.subagentType } : {}), - ...(call.description ? { description: call.description } : {}), - }); - } - - // Read outcomes straight off the tool_result blocks rather than reusing the agentId scrape, - // so a subagent linked through its meta sidecar still gets a status when the scrape missed it. - const outcomesByToolCallId = new Map(); - for (const entry of parentEntries) { - const content = toObjectRecord(entry.message)?.content; - if (!Array.isArray(content)) continue; - for (const value of content) { - const block = toObjectRecord(value); - if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - if (!toolCalls.has(block.tool_use_id)) continue; - outcomesByToolCallId.set(block.tool_use_id, { failed: block.is_error === true }); - } - } - - return { - toolCalls, - linksByAgentId: readClaudeHistoricalSubagentToolResults(parentEntries), - outcomesByToolCallId, - }; -} - -interface ClaudeSidechainHistory { - contents: string[]; - workflowContents: string[]; - workflowSidechainContentsByRunId: Map; - /** agentId -> sidecar metadata, when Claude Code wrote one next to the transcript. */ - metaByAgentId: Map; -} - -const CLAUDE_SUBAGENT_META_FILE = /^agent-(.+)\.meta\.json$/; - -function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory { - const sessionDirectory = path.join( - path.dirname(historyPath), - path.basename(historyPath, ".jsonl"), - ); - const sidechainDirectory = path.join(sessionDirectory, "subagents"); - const history: ClaudeSidechainHistory = { - contents: [], - workflowContents: [], - workflowSidechainContentsByRunId: new Map(), - metaByAgentId: new Map(), - }; - const workflowDirectory = path.join(sessionDirectory, "workflows"); - if (fs.existsSync(workflowDirectory)) { - for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - try { - history.workflowContents.push( - fs.readFileSync(path.join(workflowDirectory, entry.name), "utf8"), - ); - } catch { - // A partial or unreadable run summary must not fail the rest of history ingestion. - } - } - } - if (!fs.existsSync(sidechainDirectory)) return history; - - const directories = [sidechainDirectory]; - while (directories.length > 0) { - const directory = directories.pop(); - if (!directory) continue; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - directories.push(entryPath); - continue; - } - if (!entry.isFile()) continue; - if (entry.name.endsWith(".jsonl")) { - recordClaudeSidechainContents(history, sidechainDirectory, entryPath); - continue; - } - // The sidecar carries the Task tool_use id, which is the same id the live stream keys on. - // Reading it is what lets replay and live agree instead of each deriving its own link. - const metaMatch = CLAUDE_SUBAGENT_META_FILE.exec(entry.name); - if (!metaMatch?.[1]) continue; - try { - const meta = parseClaudeSubagentMeta(fs.readFileSync(entryPath, "utf8")); - if (meta) history.metaByAgentId.set(metaMatch[1], meta); - } catch { - // Undocumented internals: a missing or unreadable sidecar must never fail ingestion. - } - } - } - return history; -} - -function recordClaudeSidechainContents( - history: ClaudeSidechainHistory, - sidechainDirectory: string, - entryPath: string, -): void { - const contents = fs.readFileSync(entryPath, "utf8"); - const relativeParts = path.relative(sidechainDirectory, entryPath).split(path.sep); - const workflowRunId = - relativeParts[0] === "workflows" && relativeParts.length >= 3 ? relativeParts[1] : undefined; - if (!workflowRunId) { - history.contents.push(contents); - return; - } - - const workflowContents = history.workflowSidechainContentsByRunId.get(workflowRunId) ?? []; - workflowContents.push(contents); - history.workflowSidechainContentsByRunId.set(workflowRunId, workflowContents); -} - -interface ClaudeHistoricalSubagentToolCall { - name?: string; - subagentType?: string; - description?: string; -} - -function readClaudeHistoricalSubagentToolCalls( - entries: ClaudeHistoryEntry[], -): Map { - const toolCalls = new Map(); + const linksByAgentId = new Map(); + const outcomes = new Map(); for (const entry of entries) { const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { const block = toObjectRecord(value); if ( - block?.type !== "tool_use" || - (block.name !== "Task" && block.name !== "Agent") || - typeof block.id !== "string" + block?.type === "tool_use" && + (block.name === "Task" || block.name === "Agent") && + typeof block.id === "string" ) { - continue; + const input = toObjectRecord(block.input); + const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + toolCalls.set(block.id, { + ...(title ? { title } : {}), + ...(description ? { description } : {}), + }); } - const input = toObjectRecord(block.input); - const name = readNonEmptyString(input?.name); - const subagentType = readNonEmptyString(input?.subagent_type); - const description = readNonEmptyString(input?.description); - toolCalls.set(block.id, { - ...(name ? { name } : {}), - ...(subagentType ? { subagentType } : {}), - ...(description ? { description } : {}), - }); - } - } - return toolCalls; -} - -function readClaudeHistoricalSubagentToolResults( - entries: ClaudeHistoryEntry[], -): Map { - const results = new Map(); - for (const entry of entries) { - const content = toObjectRecord(entry.message)?.content; - if (!Array.isArray(content)) continue; - for (const value of content) { - const block = toObjectRecord(value); if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content)); - if (!match?.[1]) continue; - results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true }); + outcomes.set(block.tool_use_id, { failed: block.is_error === true }); + const agentId = readLegacyClaudeSubagentId(block.content); + if (agentId) + linksByAgentId.set(agentId, { + toolCallId: block.tool_use_id, + failed: block.is_error === true, + }); } } - return results; + return { + toolCalls, + linksByAgentId, + outcomesByToolCallId: new Map([...outcomes].filter(([id]) => toolCalls.has(id))), + }; } -function groupClaudeSidechainEntries( - entries: ClaudeHistoryEntry[], -): Map { - const entriesByAgentId = new Map(); - for (const entry of entries) { - if (typeof entry.agentId !== "string") continue; - const grouped = entriesByAgentId.get(entry.agentId) ?? []; - grouped.push(entry); - entriesByAgentId.set(entry.agentId, grouped); - } - return entriesByAgentId; +function readLegacyClaudeSubagentId(content: unknown): string | undefined { + return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1]; } interface ClaudeHistoryEntry { @@ -6591,29 +6454,69 @@ function createAsyncMessageInput(): AsyncMessageInput { interface ClaudeSessionCandidate { path: string; mtime: Date; + cacheKey: string; +} + +function claudeSessionCacheKey(filePath: string, stats: fs.Stats): string { + return `${filePath}\0${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`; +} + +function isMissingClaudeImportFile(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +function isExpectedClaudeImportFileError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + return ( + error.code === "ENOENT" || + error.code === "ENOTDIR" || + error.code === "EACCES" || + error.code === "EPERM" + ); } async function pathExists(target: string): Promise { try { await fsPromises.access(target); return true; - } catch { + } catch (error) { + if (!isMissingClaudeImportFile(error)) throw error; return false; } } +interface ClaudeSessionDiscoveryOptions { + rootIsProjectDir: boolean; + logger: Logger; +} + async function collectRecentClaudeSessions( root: string, limit: number, - options?: { rootIsProjectDir?: boolean }, + options: ClaudeSessionDiscoveryOptions, ): Promise { + const permissionErrors: Error[] = []; + function recordDiscoveryFailure(error: unknown, targetPath: string): void { + if (isMissingClaudeImportFile(error)) return; + if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error; + permissionErrors.push(error); + options.logger.warn( + { err: error, path: targetPath }, + "Skipping Claude session discovery path with denied access", + ); + } let rootEntries: string[]; try { rootEntries = await fsPromises.readdir(root); - } catch { + } catch (error) { + if (!isMissingClaudeImportFile(error)) throw error; return []; } - const fileEntries = options?.rootIsProjectDir + const fileEntries = options.rootIsProjectDir ? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file)) : ( await Promise.all( @@ -6626,7 +6529,8 @@ async function collectRecentClaudeSessions( return files .filter((file) => file.endsWith(".jsonl")) .map((file) => path.join(projectPath, file)); - } catch { + } catch (error) { + recordDiscoveryFailure(error, projectPath); return [] as string[]; } }), @@ -6636,8 +6540,14 @@ async function collectRecentClaudeSessions( fileEntries.map(async (fullPath) => { try { const fileStats = await fsPromises.stat(fullPath); - return { path: fullPath, mtime: fileStats.mtime }; - } catch { + if (!fileStats.isFile()) return null; + return { + path: fullPath, + mtime: fileStats.mtime, + cacheKey: claudeSessionCacheKey(fullPath, fileStats), + }; + } catch (error) { + recordDiscoveryFailure(error, fullPath); return null; } }), @@ -6645,6 +6555,12 @@ async function collectRecentClaudeSessions( const candidates: ClaudeSessionCandidate[] = statResults.filter( (entry): entry is ClaudeSessionCandidate => entry !== null, ); + if (candidates.length === 0 && permissionErrors.length > 0) { + throw new AggregateError( + permissionErrors, + `Cannot discover Claude sessions: access was denied to ${permissionErrors.length} paths and no transcripts could be discovered. ${permissionErrors[0]!.message}`, + ); + } return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit); } @@ -6716,14 +6632,8 @@ function applyClaudeSessionEntryToAccumulator( async function parseClaudeSessionDescriptor( filePath: string, mtime: Date, + signal: AbortSignal, ): Promise { - let content: string; - try { - content = await fsPromises.readFile(filePath, "utf8"); - } catch { - return null; - } - const acc: ClaudeSessionDescriptorAccumulator = { sessionId: null, cwd: null, @@ -6734,16 +6644,38 @@ async function parseClaudeSessionDescriptor( lastPromptPreview: null, }; - for (const line of content.split(/\r?\n/)) { - if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) continue; + // Keep only the current record, including image records larger than a read chunk. + const record: Buffer[] = []; + const stream = fs.createReadStream(filePath, { highWaterMark: 64 * 1024, signal }); + function applyRecord(): void { + const line = Buffer.concat(record).toString("utf8"); + if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) return; let entry: unknown; try { entry = JSON.parse(line); } catch { - continue; + return; } applyClaudeSessionEntryToAccumulator(entry, acc); } + try { + for await (const chunk of stream) { + signal.throwIfAborted(); + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf(10, start); + const end = newline === -1 ? chunk.length : newline; + record.push(chunk.subarray(start, end)); + if (newline === -1) break; + applyRecord(); + record.length = 0; + start = newline + 1; + } + } + if (record.length > 0) applyRecord(); + } finally { + stream.destroy(); + } const { sessionId, cwd } = acc; diff --git a/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts b/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts new file mode 100644 index 0000000000..41766e2d9b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts @@ -0,0 +1,92 @@ +import pLimit from "p-limit"; + +interface ImportDescriptorCandidate { + cacheKey: string; +} + +interface PendingDescriptor { + controller: AbortController; + subscribers: number; + settled: boolean; + promise: Promise; +} + +export function createImportDescriptorScheduler< + Candidate extends ImportDescriptorCandidate, + Descriptor, +>( + read: (candidate: Candidate, signal: AbortSignal) => Promise, +): (candidates: Candidate[], signal: AbortSignal) => Promise { + const limit = pLimit(4); + const reads = new Map>(); + + function acquire(candidate: Candidate): PendingDescriptor { + let pending = reads.get(candidate.cacheKey); + if (!pending) { + const controller = new AbortController(); + const entry: PendingDescriptor = { + controller, + subscribers: 0, + settled: false, + promise: limit(() => { + controller.signal.throwIfAborted(); + return read(candidate, controller.signal); + }).finally(() => { + entry.settled = true; + if (reads.get(candidate.cacheKey) === entry) reads.delete(candidate.cacheKey); + }), + }; + reads.set(candidate.cacheKey, entry); + pending = entry; + } + pending.subscribers += 1; + return pending; + } + + function release(candidate: Candidate, pending: PendingDescriptor): void { + pending.subscribers -= 1; + // The initiating listing has no special ownership of a shared read. + if (pending.subscribers === 0 && !pending.settled) { + if (reads.get(candidate.cacheKey) === pending) reads.delete(candidate.cacheKey); + pending.controller.abort(); + } + } + + return async (candidates, signal) => { + signal.throwIfAborted(); + const controller = new AbortController(); + signal = AbortSignal.any([signal, controller.signal]); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + const results: Descriptor[] = []; + let nextIndex = 0; + async function worker(): Promise { + while (nextIndex < candidates.length) { + signal.throwIfAborted(); + const index = nextIndex++; + const candidate = candidates[index]!; + // Each worker queues only its next read. Later listings enter the + // shared FIFO after at most four outstanding reads from this listing. + const pending = acquire(candidate); + try { + results[index] = await Promise.race([pending.promise, aborted]); + } finally { + release(candidate, pending); + } + } + } + try { + const workers = Array.from({ length: Math.min(4, candidates.length) }, () => worker()); + await Promise.all(workers); + return results; + } catch (error) { + controller.abort(error); + throw error; + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } + }; +} diff --git a/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts b/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts new file mode 100644 index 0000000000..cab987e473 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts @@ -0,0 +1,202 @@ +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +/** Full-scale reproduction is opt-in: run this file with ROOT children --generate. */ +export function generateReplayCorpus( + root: string, + size = { parentMiB: 395, childrenMiB: 362, children: 269 }, +): { cwd: string; configDir: string } { + const cwd = path.join(root, "repo"); + const configDir = path.join(root, "claude"); + mkdirSync(cwd, { recursive: true }); + const project = claudeProjectDirSync(cwd, { configDir }); + const subagents = path.join(project, "large-session", "subagents"); + mkdirSync(subagents, { recursive: true }); + const parent = path.join(project, "large-session.jsonl"); + const row = (value: unknown) => JSON.stringify(value) + "\n"; + // Large transcripts include records and context summaries that never become timeline items. + // Generate those incrementally: the fixture generator itself must not hold the corpus. + const padding = row({ type: "progress", data: "x".repeat(64 * 1024) }); + writeFileSync(parent, ""); + for (let i = 0; i < Math.ceil((size.parentMiB * 1024 * 1024) / padding.length); i++) { + appendFileSync(parent, padding); + } + appendFileSync(parent, row({ type: "user", message: { content: "Open the large session" } })); + for (let i = 0; i < size.children; i++) { + const agentId = `child-${i}`; + const id = `task-${i}`; + appendFileSync( + parent, + row({ + type: "assistant", + message: { + content: [{ type: "tool_use", id, name: "Task", input: { description: agentId } }], + }, + }), + ); + appendFileSync( + parent, + row({ + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: id, content: "done" }] }, + }), + ); + const file = path.join(subagents, `agent-${agentId}.jsonl`); + writeFileSync(file, ""); + const summary = row({ + type: "user", + isCompactSummary: true, + isSidechain: true, + agentId, + message: { content: "x".repeat(64 * 1024) }, + }); + for ( + let j = 0; + j < Math.ceil((size.childrenMiB * 1024 * 1024) / size.children / summary.length); + j++ + ) + appendFileSync(file, summary); + appendFileSync( + file, + row({ + type: "assistant", + isSidechain: true, + agentId, + timestamp: "2026-07-26T06:28:00.000Z", + message: { + content: [{ type: "text", text: `History of ${agentId}` }], + stop_reason: "end_turn", + }, + }), + ); + writeFileSync( + path.join(subagents, `agent-${agentId}.meta.json`), + JSON.stringify({ toolUseId: id }), + ); + } + return { cwd, configDir }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = process.argv[2]; + if (!root) throw new Error("Usage: replay-memory.fixture.ts ROOT children|workflow [--generate]"); + if (process.argv.includes("--generate")) { + if (process.argv[3] === "workflow") generateWorkflowReplayCorpus(root); + else generateReplayCorpus(root); + } + process.env.CLAUDE_CONFIG_DIR = path.join(root, "claude"); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + queryFactory: () => { + throw new Error("Replay must not query Claude"); + }, + resolveVersion: async () => "2.1.220", + }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "large-session" }, + { cwd: path.join(root, "repo") }, + ); + let parentItems = 0; + const completed = new Set(); + const histories = new Set(); + for await (const event of session.streamHistory()) { + if (event.type === "timeline") parentItems++; + if ( + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed" + ) + completed.add(event.event.id); + if ( + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" + ) + histories.add(event.event.item.text); + } + await session.close(); + console.log( + JSON.stringify({ + parentItems, + completed: completed.size, + histories: histories.size, + maxRSSKiB: process.resourceUsage().maxRSS, + }), + ); +} + +export function generateWorkflowReplayCorpus( + root: string, + childMiB = 320, +): { cwd: string; configDir: string } { + const cwd = path.join(root, "repo"); + const configDir = path.join(root, "claude"); + mkdirSync(cwd, { recursive: true }); + const project = claudeProjectDirSync(cwd, { configDir }); + const runId = "wf_large"; + const sessionDirectory = path.join(project, "large-session"); + const childrenDirectory = path.join(sessionDirectory, "subagents", "workflows", runId); + mkdirSync(childrenDirectory, { recursive: true }); + mkdirSync(path.join(sessionDirectory, "workflows"), { recursive: true }); + writeFileSync( + path.join(project, "large-session.jsonl"), + [ + { type: "user", message: { content: "Open the large session" } }, + { + type: "assistant", + message: { + content: [{ type: "tool_use", id: "workflow-task", name: "Workflow", input: {} }], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "workflow-task", + content: `Workflow launched in background.\nRun ID: ${runId}`, + }, + ], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + writeFileSync( + path.join(sessionDirectory, "workflows", `${runId}.json`), + JSON.stringify({ runId, status: "completed", summary: "Large workflow" }), + ); + for (let i = 0; i < 5; i++) { + const file = path.join(childrenDirectory, `agent-child-${i}.jsonl`); + writeFileSync(file, ""); + const prompt = + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: `child-${i}`, + message: { content: "x".repeat(64 * 1024) }, + }) + "\n"; + for (let j = 0; j < Math.ceil((childMiB * 1024 * 1024) / prompt.length); j++) + appendFileSync(file, prompt); + appendFileSync( + file, + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: `child-${i}`, + timestamp: `2026-07-26T06:28:0${4 - i}.000Z`, + message: { + content: [{ type: "text", text: `History of child-${i}` }], + stop_reason: "end_turn", + }, + }) + "\n", + ); + } + return { cwd, configDir }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 372cbc01b2..23ba35c0fe 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -65,17 +65,12 @@ export interface ClaudeReplayEntry { * Effort is replay-only: the SDK's live assistant message does not carry it, and it can be * silently downgraded for the selected model, so any live value would be a guess. */ -function readRuntime(entries: readonly ClaudeReplayEntry[]): { model?: string; effort?: string } { - const runtime: { model?: string; effort?: string } = {}; - for (const entry of entries) { - if (entry.type !== "assistant") continue; - const effort = typeof entry.effort === "string" ? entry.effort.trim() : ""; - if (effort) runtime.effort = effort; - const rawModel = (entry.message as { model?: unknown } | undefined)?.model; - const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined); - if (model) runtime.model = model; - } - return runtime; +function readRuntime(entry: ClaudeReplayEntry): { model?: string; effort?: string } { + if (entry.type !== "assistant") return {}; + const effort = typeof entry.effort === "string" ? entry.effort.trim() : ""; + const rawModel = entry.message?.model; + const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined); + return { ...(effort ? { effort } : {}), ...(model ? { model } : {}) }; } /** @@ -126,22 +121,16 @@ function readTotalTokens(raw: unknown): number | undefined { * * The last assistant entry's summed usage matches Claude Code's live `total_tokens` definition. */ -function readUsage(entries: readonly ClaudeReplayEntry[]): ClaudeSubagentUsage | null { - let totalTokens: number | undefined; - - for (const entry of entries) { - if (entry.type !== "assistant") continue; - const observed = readTotalTokens(entry.message?.usage); - if (observed !== undefined) totalTokens = observed; - } - +function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null { + if (entry.type !== "assistant") return null; + const totalTokens = readTotalTokens(entry.message?.usage); return totalTokens === undefined ? null : { totalTokens }; } export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; - entries: ClaudeReplayEntry[]; + entries: Iterable; /** Task declarations made by this subagent, used to attach its direct children. */ parentFacts?: ClaudeReplayParentFacts; } @@ -211,18 +200,6 @@ function resolveParentLink( return null; } -/** A final `end_turn` is the child's own completion signal when the parent result is absent. */ -function readChildTerminalStatus( - entries: readonly ClaudeReplayEntry[], -): ProviderSubagentStatus | null { - for (let index = entries.length - 1; index >= 0; index--) { - const entry = entries[index]; - if (entry?.type !== "assistant") continue; - return entry.message?.stop_reason === "end_turn" ? "completed" : null; - } - return null; -} - /** * The parent's tool input is the same identity source the live path reads; meta.json contributes * provider details only after that declaration has admitted the child. @@ -231,11 +208,11 @@ function declareSubagent( subagent: ClaudeReplaySubagentInput, link: ParentLink, toolCall: { title?: string; description?: string } | undefined, - parentSubagentId?: string, + parentSubagentId: string | undefined, + firstTimestamp: string | null, ): SubagentObservation { const title = toolCall?.title ?? subagent.meta?.agentType; const description = toolCall?.description ?? subagent.meta?.description; - const firstTimestamp = normalizeProviderReplayTimestamp(subagent.entries[0]?.timestamp); return { kind: "declared", id: link.id, @@ -248,12 +225,12 @@ function declareSubagent( } function observeSubtitle( - subagent: ClaudeReplaySubagentInput, link: ParentLink, title: string | undefined, + runtime: { model?: string; effort?: string }, + usage: ClaudeSubagentUsage | null, + timestamp: string | null, ): SubagentObservation | null { - const runtime = readRuntime(subagent.entries); - const usage = readUsage(subagent.entries); const hasDetails = runtime.model !== undefined || runtime.effort !== undefined || @@ -265,7 +242,6 @@ function observeSubtitle( ...(usage ? { usage } : {}), }); if (!subtitle) return null; - const timestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); return { kind: "subtitle", id: link.id, @@ -278,35 +254,50 @@ function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + toolOwners: Map, parentSubagentId?: string, ): SubagentObservation[] { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); + const timeline: SubagentObservation[] = []; + const runtime: { model?: string; effort?: string } = {}; + let usage: ClaudeSubagentUsage | null = null; + let firstTimestamp: string | null = null; + let lastTimestamp: string | null = null; + let seenEntry = false; + let childTerminalStatus: ProviderSubagentStatus | null = null; - const observations: SubagentObservation[] = [ - declareSubagent(subagent, link, toolCall, parentSubagentId), - ]; - const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); - if (subtitle) observations.push(subtitle); - + // Consume each source once for presentation and timeline; keep no raw transcript records. for (const entry of subagent.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); + if (!seenEntry) firstTimestamp = timestamp; + seenEntry = true; + lastTimestamp = timestamp; + Object.assign(runtime, readRuntime(entry)); + usage = readUsage(entry) ?? usage; + if (entry.type === "assistant") { + childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; + } + recordReplayToolOwners(toolOwners, entry, link.id); for (const item of convertEntry(entry)) { - observations.push({ - kind: "timeline", - id: link.id, - item, - ...(timestamp ? { timestamp } : {}), - }); + timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); } } - - // Prefer the parent's result because it carries failure. If that result is missing, the child's - // final end_turn still proves completion. Any other shape remains running. - const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries); + const observations = [ + declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp), + ]; + const subtitle = observeSubtitle( + link, + toolCall?.title ?? subagent.meta?.agentType, + runtime, + usage, + lastTimestamp, + ); + if (subtitle) observations.push(subtitle); + for (const observation of timeline) observations.push(observation); + const terminalStatus = link.status ?? childTerminalStatus; if (terminalStatus) { - const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); observations.push({ kind: "status", id: link.id, @@ -314,22 +305,18 @@ function observeSubagent( ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), }); } - return observations; } function recordReplayToolOwners( owners: Map, - entries: readonly ClaudeReplayEntry[], + entry: ClaudeReplayEntry, subagentId: string, ): void { - for (const entry of entries) { - if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue; - for (const block of entry.message.content) { - if (block?.type === "tool_use" && typeof block.id === "string") { - owners.set(block.id, subagentId); - } - } + if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) return; + for (const block of entry.message.content) { + if (block?.type === "tool_use" && typeof block.id === "string") + owners.set(block.id, subagentId); } } @@ -359,8 +346,15 @@ export function observeReplaySubagents(input: { const { ownerId, parent, link } = resolved; // Only proven descendants may own notifications; ambient sidecars cannot claim them. - recordReplayToolOwners(toolOwners, subagent.entries, link.id); - observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); + for (const observation of observeSubagent( + subagent, + parent, + input.convertEntry, + toolOwners, + ownerId, + )) { + observations.push(observation); + } if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); unresolved.splice(index, 1); madeProgress = true; diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index ede48a5f48..1d7ff2b1a8 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -47,8 +47,9 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu */ export function observeReplayWorkflows(input: { workflows: readonly ClaudeWorkflowRun[]; - parentEntries: readonly ClaudeWorkflowParentEntry[]; - entriesByRunId?: ReadonlyMap; + parentEntries: Iterable; + /** Repeatable entries in chronological order, supplied by the transcript reader. */ + entriesByRunId?: ReadonlyMap>; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; }): SubagentObservation[] { const toolCallIdByRunId = readWorkflowLinks(input.parentEntries); @@ -78,15 +79,15 @@ export function observeReplayWorkflows(input: { }); } - observations.push( - ...observeWorkflowTimeline({ - id, - result: workflow.result, - entries: input.entriesByRunId?.get(workflow.runId) ?? [], - convertEntry: input.convertEntry, - finishedAt, - }), - ); + for (const observation of observeWorkflowTimeline({ + id, + result: workflow.result, + entries: input.entriesByRunId?.get(workflow.runId) ?? [], + convertEntry: input.convertEntry, + finishedAt, + })) { + observations.push(observation); + } const subtitle = buildClaudeSubagentSubtitle({ title: "Workflow", @@ -117,15 +118,14 @@ export function observeReplayWorkflows(input: { function observeWorkflowTimeline(input: { id: string; result: unknown; - entries: readonly ClaudeReplayEntry[]; + entries: Iterable; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; finishedAt?: string; }): SubagentObservation[] { const observations: SubagentObservation[] = []; const replayedAssistantText = new Set(); - const entries = [...input.entries].sort(compareReplayTimestamps); - for (const entry of entries) { + for (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); const items = input.convertEntry?.(entry) ?? []; for (const item of items) { @@ -150,16 +150,7 @@ function observeWorkflowTimeline(input: { return observations; } -function compareReplayTimestamps(a: ClaudeReplayEntry, b: ClaudeReplayEntry): number { - const aTimestamp = normalizeProviderReplayTimestamp(a.timestamp); - const bTimestamp = normalizeProviderReplayTimestamp(b.timestamp); - if (!aTimestamp && !bTimestamp) return 0; - if (!aTimestamp) return 1; - if (!bTimestamp) return -1; - return Date.parse(aTimestamp) - Date.parse(bTimestamp); -} - -function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map { +function readWorkflowLinks(entries: Iterable): Map { const workflowToolCallIds = new Set(); const toolCallIdByRunId = new Map(); diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts new file mode 100644 index 0000000000..cba606b0e4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -0,0 +1,256 @@ +import fs from "node:fs"; +import path from "node:path"; +import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; +import { + parseClaudeSubagentMeta, + type ClaudeReplayEntry, + type ClaudeSubagentMeta, +} from "./subagents/replay-source.js"; +import { + parseClaudeWorkflowRun, + type ClaudeWorkflowRun, +} from "./subagents/workflow-replay-source.js"; + +interface TranscriptEntry extends ClaudeReplayEntry { + isSidechain?: unknown; +} + +interface RecordLocation { + file: string; + offset: number; + length: number; + timestamp: string | null; +} + +/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */ +function* locatedRecords( + file: string, + fd: number, +): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { + const stat = fs.fstatSync(fd); + // Opening a directory succeeds on some platforms; it must never look like empty history. + if (stat.isDirectory()) { + throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { + code: "EISDIR", + syscall: "read", + path: file, + }); + } + let remaining = stat.size; + let offset = 0; + let pieces: Buffer[] = []; + let length = 0; + while (remaining > 0) { + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (count === 0) break; + remaining -= count; + let start = 0; + let end: number; + while ((end = buffer.indexOf(10, start)) !== -1 && end < count) { + pieces.push(buffer.subarray(start, end)); + length += end - start; + const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8")); + const location = { + file, + offset, + length, + timestamp: normalizeProviderReplayTimestamp(entry?.timestamp), + }; + offset += length + 1; + pieces = []; + length = 0; + if (entry) yield { entry, location }; + start = end + 1; + } + if (start < count) { + pieces.push(buffer.subarray(start, count)); + length += count - start; + } + } + const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8")); + if (entry) + yield { + entry, + location: { + file, + offset, + length, + timestamp: normalizeProviderReplayTimestamp(entry.timestamp), + }, + }; +} + +/** Indexed sources parse only their own records using descriptors owned by this replay. */ +function indexedRecords( + locations: readonly RecordLocation[], + descriptors: ReadonlyMap, +): Iterable { + return { + *[Symbol.iterator]() { + for (const location of locations) { + const fd = descriptors.get(location.file)!; + const buffer = Buffer.allocUnsafe(location.length); + let read = 0; + while (read < buffer.length) { + const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + if (count === 0) + throw new Error(`Claude transcript was truncated during replay: ${location.file}`); + read += count; + } + const entry = parseRecord(buffer.toString("utf8")); + if (entry) yield entry; + } + }, + }; +} + +function isToolResult(entry: TranscriptEntry): boolean { + return ( + Array.isArray(entry.message?.content) && + entry.message.content.some((block) => block?.type === "tool_result") + ); +} + +function parseRecord(line: string): TranscriptEntry | null { + try { + const value: unknown = JSON.parse(line); + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as TranscriptEntry) + : null; + } catch { + return null; + } +} + +function readOptionalFile(file: string): string | null { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded + * by the replay ownership resolver; workflow children retain their separate run ownership. */ +export interface ClaudeReplayHistory { + parentEntries: Iterable; + subagents: { + agentId: string; + meta: ClaudeSubagentMeta | null; + entries: Iterable; + }[]; + workflows: ClaudeWorkflowRun[]; + workflowEntriesByRunId: Map>; +} + +/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history. + * Sources must be consumed before the callback returns. No transcript copies are needed. */ +export function withClaudeReplayHistory( + historyPath: string, + consume: (history: ClaudeReplayHistory) => T, +): T { + const descriptors = new Map(); + try { + return consume(indexHistory(historyPath, descriptors)); + } finally { + for (const fd of descriptors.values()) fs.closeSync(fd); + } +} + +function indexHistory(historyPath: string, descriptors: Map): ClaudeReplayHistory { + const sessionDirectory = path.join( + path.dirname(historyPath), + path.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const sidechainFiles = [historyPath]; + const workflowFiles = new Map(); + const metaByAgentId = new Map(); + const directories = [sidechainDirectory]; + while (directories.length) { + const directory = directories.pop()!; + if (!fs.existsSync(directory)) continue; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(file); + continue; + } + if (!entry.isFile()) continue; + if (entry.name.endsWith(".jsonl")) { + const parts = path.relative(sidechainDirectory, file).split(path.sep); + if (parts[0] === "workflows" && parts.length >= 3) { + const runId = parts[1]!; + const files = workflowFiles.get(runId) ?? []; + files.push(file); + workflowFiles.set(runId, files); + } else sidechainFiles.push(file); + continue; + } + const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1]; + if (!agentId) continue; + const contents = readOptionalFile(file); + const meta = contents === null ? null : parseClaudeSubagentMeta(contents); + if (meta) metaByAgentId.set(agentId, meta); + } + } + const parentLocations: RecordLocation[] = []; + const locationsByAgentId = new Map(); + function scan( + file: string, + visit: (entry: TranscriptEntry, location: RecordLocation) => void, + ): void { + const fd = fs.openSync(file, "r"); + descriptors.set(file, fd); + for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); + } + for (const file of sidechainFiles) { + scan(file, (entry, location) => { + if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location); + if (entry.isSidechain !== true || typeof entry.agentId !== "string") return; + const locations = locationsByAgentId.get(entry.agentId) ?? []; + locations.push(location); + locationsByAgentId.set(entry.agentId, locations); + }); + } + const workflowEntriesByRunId = new Map>(); + for (const [runId, files] of workflowFiles) { + const locations: RecordLocation[] = []; + for (const file of files) + scan(file, (entry, location) => { + if (entry.type !== "user" || isToolResult(entry)) locations.push(location); + }); + locations.sort((a, b) => { + if (!a.timestamp && !b.timestamp) return 0; + if (!a.timestamp) return 1; + if (!b.timestamp) return -1; + return Date.parse(a.timestamp) - Date.parse(b.timestamp); + }); + workflowEntriesByRunId.set(runId, indexedRecords(locations, descriptors)); + } + return { + parentEntries: indexedRecords(parentLocations, descriptors), + subagents: [...locationsByAgentId].map(([agentId, locations]) => ({ + agentId, + meta: metaByAgentId.get(agentId) ?? null, + entries: indexedRecords(locations, descriptors), + })), + workflows: readWorkflows(sessionDirectory), + workflowEntriesByRunId, + }; +} + +function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] { + const workflows: ClaudeWorkflowRun[] = []; + const workflowDirectory = path.join(sessionDirectory, "workflows"); + if (fs.existsSync(workflowDirectory)) { + for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const contents = readOptionalFile(path.join(workflowDirectory, entry.name)); + const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); + if (workflow) workflows.push(workflow); + } + } + return workflows; +} diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index c9c0f07161..da10deb869 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -8,6 +8,7 @@ import { AgentManager } from "../agent-manager.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; import { MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + MOCK_LOAD_TEST_HANDLED_COMMAND, MockLoadTestAgentClient, } from "./mock-load-test-agent.js"; @@ -49,6 +50,18 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("lists the command it handles without starting a turn", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ provider: "mock", cwd: process.cwd() }); + + const commands = (await session.listCommands?.()) ?? []; + + expect(commands.map((command) => `/${command.name} ${command.argumentHint}`)).toEqual([ + MOCK_LOAD_TEST_HANDLED_COMMAND, + ]); + expect(session.tryHandleOutOfBand?.(MOCK_LOAD_TEST_HANDLED_COMMAND)).not.toBeNull(); + }); + test("rejects the configured number of prompts before starting a retry", async () => { const client = new MockLoadTestAgentClient(); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index ce9714c5f2..b7b301540a 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -18,6 +18,7 @@ import type { AgentRuntimeInfo, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, FetchCatalogOptions, @@ -35,7 +36,13 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream"; -export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command"; +const MOCK_LOAD_TEST_COMMAND: AgentSlashCommand = { + name: "mock", + description: "Reply without starting a turn", + argumentHint: "handled-command", + kind: "command", +}; +export const MOCK_LOAD_TEST_HANDLED_COMMAND = `/${MOCK_LOAD_TEST_COMMAND.name} ${MOCK_LOAD_TEST_COMMAND.argumentHint}`; const MOCK_LOAD_TEST_MODE_ID = "load-test"; const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; const MOCK_LOAD_TEST_INTERVAL_MS = 40; @@ -954,6 +961,10 @@ export class MockLoadTestAgentSession implements AgentSession { return { turnId }; } + async listCommands(): Promise { + return [MOCK_LOAD_TEST_COMMAND]; + } + tryHandleOutOfBand( prompt: AgentPromptInput, ): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise } | null { diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index f9a776d168..1396d1f784 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -47,8 +47,8 @@ test("bridge preserves tool failure messages and classifies invalid requests as logger, ), projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger), - workspaceGitService: { - peekSnapshot: () => { + workspaceRuntimeStore: { + get: () => { throw new Error("Unexpected git lookup"); }, }, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 74c4361c9e..6b84f17161 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,7 +1,8 @@ +import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { createHostResolver, - waitForPermissionChange, + HostOperationRejectedError, type HostOperation, type HostOperations, type ToolHosts, @@ -77,8 +78,6 @@ import { import { type PromptDispatchDisposition, sendPromptToAgent, - setupFinishNotification, - setupFinishWait, waitForAgentRunStartWithTimeout, } from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; @@ -118,6 +117,7 @@ import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { agentMessages?: () => AgentMessageDelivery; + finishSubscriptions?: () => FinishSubscriptions; serverId?: string; hosts?: () => ToolHosts; agentManager: AgentManager; @@ -175,10 +175,6 @@ export interface PaseoToolHostDependencies { logger: Logger; } -function settledStatus(status: string): "error" | "idle" { - return status === "error" ? "error" : "idle"; -} - interface ProviderSummary { id: AgentProvider; label: string; @@ -663,6 +659,49 @@ function createToolCatalog( if (!options.agentMessages) throw new Error("Agent message delivery is not configured"); return options.agentMessages(); } + function finishSubscriptions(): FinishSubscriptions { + if (!options.finishSubscriptions) throw new Error("Finish subscriptions are not configured"); + return options.finishSubscriptions(); + } + async function prepareRemoteNotification( + name: string, + destination: string | undefined, + input: Record, + ): Promise { + if ( + name !== "send_agent_prompt" || + destination === options.serverId || + !callerAgentId || + input.notifyOnFinish === false + ) + return undefined; + return finishSubscriptions().register( + { childAgentId: input.agentId as string, callerAgentId, serverId: destination }, + true, + ); + } + async function registerRemoteCreation( + name: string, + destination: string | undefined, + input: Record, + response: PaseoToolResult, + ): Promise { + if ( + name !== "create_agent" || + destination === options.serverId || + !callerAgentId || + input.notifyOnFinish === false || + response.isError + ) + return; + const childAgentId = (response.structuredContent as { agentId: string }).agentId; + await finishSubscriptions().register({ + childAgentId, + callerAgentId, + serverId: destination, + requireParentOwnership: true, + }); + } const localHostOperations = new Map(); const tools = new Map(); const hostTools = new Map([ @@ -691,36 +730,6 @@ function createToolCatalog( getStatus: (args, context) => localHostOperations.get("get_agent_status")!(args, context), getActivity: (args, context) => localHostOperations.get("get_agent_activity")!(args, context), listAgents: (args, context) => localHostOperations.get("list_agents")!(args, context), - async observe(agentId, handler, signal) { - const unsubscribe = agentManager.subscribe( - (event) => { - if (event.type === "agent_state") handler(toAgentPayload(event.agent)); - }, - { agentId, replayState: true }, - ); - const stop = () => unsubscribe(); - signal.addEventListener("abort", stop, { once: true }); - return async () => { - signal.removeEventListener("abort", stop); - unsubscribe(); - }; - }, - async wait(agentId, signal) { - const settled = await agentManager.waitForAgentEvent(agentId, { - signal, - waitForActive: true, - }); - const snapshot = agentManager.getAgent(agentId); - const final = snapshot - ? await serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger) - : null; - return { - status: settled.permission ? "permission" : settledStatus(settled.status), - final, - lastMessage: settled.lastMessage, - error: final?.lastError ?? null, - }; - }, }, }); const registerTool = ( @@ -752,34 +761,14 @@ function createToolCatalog( }; handler = async ({ serverId, ...input }, context) => { const destination = serverId ?? options.serverId; + const remoteNotification = await prepareRemoteNotification(name, destination, input); try { const response = await hostResolver.use(serverId, (host) => host[operation](input, context), ); - let childAgentId: string | undefined; - if (name === "create_agent") { - childAgentId = (response.structuredContent as { agentId: string }).agentId; - } else if (name === "send_agent_prompt" && destination !== options.serverId) { - childAgentId = input.agentId as string; - } - if (childAgentId && callerAgentId && input.notifyOnFinish !== false) { - setupFinishWait({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - originServerId: options.serverId, - childServerId: destination, - childAgentId, - callerAgentId, - logger: childLogger, - wait: (signal) => - hostResolver.use(destination, (host) => host.wait(childAgentId, signal)), - afterPermission: (requestId, signal) => - hostResolver.use(destination, (host) => - waitForPermissionChange(host, childAgentId, requestId, signal), - ), - }); - } + if (response.isError) await remoteNotification?.reject(); + else remoteNotification?.accept(); + await registerRemoteCreation(name, destination, input, response); return { ...response, structuredContent: { @@ -788,6 +777,11 @@ function createToolCatalog( }, }; } catch (error) { + // A lost response does not establish whether the remote daemon admitted the prompt. + // Reconcile the child rather than silently dropping notification intent. + if (error instanceof PaseoToolInputError || error instanceof HostOperationRejectedError) + await remoteNotification?.reject(); + else remoteNotification?.accept(); if (!options.serverId) throw error; const failure = { serverId: destination, @@ -1621,6 +1615,7 @@ function createToolCatalog( { agentManager, agentStorage, + finishSubscriptions: options.finishSubscriptions?.(), logger: childLogger, paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, @@ -1649,7 +1644,7 @@ function createToolCatalog( }, mode: parsedArgs.settings?.modeId, promptFailure: "throw", - notifyOnFinish: false, + notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -1966,68 +1961,56 @@ function createToolCatalog( notifyOnFinish = Boolean(callerAgentId), activeTurnBehavior = callerAgentId ? "steer" : "interrupt", }) => { - let stopNotification: (() => void) | undefined; - let acceptDispatch!: (result: PromptDispatchDisposition | false) => void; - const dispatchResult = new Promise((resolve) => { - acceptDispatch = resolve; - }); - const notifying = Boolean(callerAgentId && notifyOnFinish); - function prepareDispatch(): void { - if (!callerAgentId || !notifyOnFinish) return; - stopNotification = setupFinishNotification({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - childAgentId: agentId, - childServerId: options.serverId, - callerAgentId, - waitForTurnStart: false, - dispatchResult, - logger: childLogger, - }); + if (callerAgentId && !agentManager.getAgent(agentId) && !(await agentStorage.get(agentId))) { + throw new PaseoToolInputError(`Agent ${agentId} not found`); } - function confirmDispatch(disposition: PromptDispatchDisposition): void { - // A new turn must ignore outcomes from the displaced turn. Its iterator - // has not started yet, so rearm here; steering keeps the early observer. - if (disposition !== "turn_started") { - acceptDispatch(disposition); - return; - } - acceptDispatch(disposition); - if (!callerAgentId || !notifyOnFinish) return; - stopNotification?.(); - stopNotification = setupFinishNotification({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - childAgentId: agentId, - childServerId: options.serverId, - callerAgentId, - waitForTurnStart: true, - logger: childLogger, - }); + const notifying = Boolean(callerAgentId && notifyOnFinish); + const notification = + notifying && callerAgentId + ? await (() => { + if (!options.finishSubscriptions) + throw new Error("Finish subscriptions are not configured"); + return options + .finishSubscriptions() + .register({ childAgentId: agentId, callerAgentId }, true); + })() + : undefined; + function confirmDispatch(_disposition: PromptDispatchDisposition): void { + notification?.accept(); + } + function rejectNotification(): void { + void notification + ?.reject() + .catch((error) => + childLogger.error( + { err: error, agentId }, + "Failed to remove rejected finish subscription", + ), + ); } if (callerAgentId) { - if (!agentManager.getAgent(agentId) && !(await agentStorage.get(agentId))) { - throw new PaseoToolInputError(`Agent ${agentId} not found`); - } - const receipt = agentMessages().enqueue({ - recipientId: agentId, - prompt, - source: { - kind: "agent-message", - agentId: callerAgentId, - title: resolveCallerAgent()?.config.title ?? undefined, - }, - sessionMode, - activeTurnBehavior, - beforeDispatch: prepareDispatch, - onDispatch: confirmDispatch, - }); + const receipt = (() => { + try { + return agentMessages().enqueue({ + recipientId: agentId, + prompt, + source: { + kind: "agent-message", + agentId: callerAgentId, + title: resolveCallerAgent()?.config.title ?? undefined, + }, + sessionMode, + activeTurnBehavior, + onDispatch: confirmDispatch, + }); + } catch (error) { + rejectNotification(); + throw error; + } + })(); void receipt.dispatched.catch(() => { - acceptDispatch(false); - stopNotification?.(); + rejectNotification(); }); return { content: [], @@ -2054,8 +2037,7 @@ function createToolCatalog( onDispatch: confirmDispatch, logger: childLogger, }).catch((error) => { - acceptDispatch(false); - stopNotification?.(); + rejectNotification(); if (error instanceof AgentNotFoundError) { throw new PaseoToolInputError(error.message); } @@ -2065,7 +2047,7 @@ function createToolCatalog( // Return once the provider has accepted the turn, so the status reports it running. if (disposition === "turn_started") { await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { - stopNotification?.(); + rejectNotification(); throw error; }); } diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 0adf442fb6..6e99bd3860 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -134,7 +134,6 @@ describe("hello admission", () => { credential: { kind: "localCredential", token: "stale" }, passwordHash: undefined, localCredential: "current", - transport: "direct", }), ).toMatchObject({ admission: { principalId: "owner" } }); }); @@ -146,7 +145,6 @@ describe("hello admission", () => { const input = { passwordHash: CORRECT_PASSWORD_HASH, localCredential: second, - transport: "direct" as const, }; expect( await resolveSessionAdmission({ @@ -172,19 +170,9 @@ describe("hello admission", () => { credential: { kind: "password", password: "wrong" }, }), ).toEqual({ rejection: "incorrect_password" }); - expect( - await resolveSessionAdmission({ - ...input, - transport: "relay", - credential: { kind: "password", password: "wrong" }, - }), - ).toEqual({ rejection: "incorrect_password" }); expect(await resolveSessionAdmission({ ...input, credential: undefined })).toEqual({ rejection: "password_required", }); - expect( - await resolveSessionAdmission({ ...input, transport: "relay", credential: undefined }), - ).toMatchObject({ admission: { principalId: "owner" } }); } finally { await deleteLocalCredential(home); await rm(home, { recursive: true, force: true }); diff --git a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts index 218ae4aee3..be952b8efa 100644 --- a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Logger } from "pino"; +import pino, { type Logger } from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -10,8 +10,14 @@ import { type AutoArchiveOnMergeDependencies, type AutoArchiveOnMergeOptions, } from "./index.js"; -import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; -import { createWorktree } from "../../utils/worktree.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "../workspace-git-service.js"; +import { createGitHubService } from "../../services/github-service.js"; +import { archiveIfSafe } from "./archive-if-safe.js"; +import { killTerminalsForWorkspace } from "../workspace-archive-service.js"; +import { isPaseoOwnedWorktreeCwd, createWorktree } from "../../utils/worktree.js"; type PullRequestState = "open" | "closed" | "merged"; @@ -22,6 +28,7 @@ interface PullRequestObservation { } const cleanupPaths: string[] = []; +const cleanupServices: WorkspaceGitServiceImpl[] = []; function run(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: "pipe" }); @@ -32,7 +39,7 @@ async function settleObservation(): Promise { await new Promise((resolve) => setImmediate(resolve)); } -async function createWorkspaceJourney() { +async function createWorkspaceFiles() { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "auto-archive-behavior-"))); cleanupPaths.push(tempDir); const repoDir = path.join(tempDir, "repo"); @@ -51,6 +58,11 @@ async function createWorkspaceJourney() { paseoHome, }); + return { repoDir, paseoHome, worktree }; +} + +async function createWorkspaceJourney() { + const { repoDir, worktree } = await createWorkspaceFiles(); const workspaceId = "workspace-under-test"; let active = true; let currentPullRequest: PullRequestObservation | null = null; @@ -149,6 +161,95 @@ async function createWorkspaceJourney() { }; } +async function createUnobservedWorkspace(archiveBarrier?: Promise) { + const { repoDir, paseoHome, worktree } = await createWorkspaceFiles(); + const cwd = worktree.worktreePath; + run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]); + run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]); + run(cwd, ["branch", "--set-upstream-to=origin/workspace"]); + const logger = pino({ level: "silent" }); + const github = createGitHubService({ + resolveGhPath: async () => "/test/gh", + runner: async () => ({ stdout: "", stderr: "" }), + }); + let observation: PullRequestObservation | null = null; + const reads: Array = []; + const archives: string[] = []; + let active = true; + const service = new WorkspaceGitServiceImpl({ + logger, + paseoHome, + deps: { + forgeOverrides: { github }, + getPullRequestStatus: async (_cwd, _forge, options) => { + reads.push(options?.reason); + return { + status: observation && { + ...observation, + title: observation.headRefName, + baseRefName: "main", + isMerged: observation.state === "merged", + }, + authState: "authenticated", + featuresEnabled: true, + githubFeaturesEnabled: true, + }; + }, + }, + }); + cleanupServices.push(service); + const options = { + logger, + paseoHome, + daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) }, + workspaceGitService: service, + getAutoArchivedChangeRequestUrl: async () => null, + listActiveWorkspaces: async () => + active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [], + } as unknown as AutoArchiveOnMergeOptions; + setupAutoArchiveOnMerge(options, { + resolvePath: path.resolve, + archiveIfSafe: async (input) => + archiveIfSafe({ + ...input, + deps: { + isPaseoOwnedWorktreeCwd, + killTerminalsForWorkspace, + archiveByScope: async (_options, request) => { + if (request.scope.kind !== "workspace") throw new Error("Expected workspace archive"); + archives.push(request.scope.workspaceId); + await archiveBarrier; + active = false; + return { + archivedAgentIds: [], + archivedWorkspaceIds: [request.scope.workspaceId], + removedDirectory: false, + }; + }, + }, + }), + }); + return { + archives, + reads, + metrics: () => service.getMetrics(), + isActive: () => active, + makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"), + commitUnpushed: () => { + writeFileSync(path.join(cwd, "README.md"), "unpushed work\n"); + run(cwd, ["add", "README.md"]); + run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "unpushed"]); + }, + observe: async (state: PullRequestState) => { + observation = pullRequest("workspace", state); + const snapshot = await service.getSnapshot(cwd, { force: true, reason: "workspace-journey" }); + expect(snapshot.forge.pullRequest?.state).toBe(state); + await settleObservation(); + return snapshot; + }, + }; +} + function pullRequest( headRefName: string, state: PullRequestState, @@ -161,13 +262,77 @@ function pullRequest( }; } -afterEach(() => { +afterEach(async () => { + for (const service of cleanupServices.splice(0)) await service.dispose(); for (const target of cleanupPaths.splice(0)) { rmSync(target, { recursive: true, force: true }); } }); describe("workspace auto-archive behavior", () => { + test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => { + let releaseArchive!: () => void; + const archiveBarrier = new Promise((resolve) => { + releaseArchive = resolve; + }); + const workspace = await createUnobservedWorkspace(archiveBarrier); + try { + await workspace.observe("open"); + expect(workspace.archives).toEqual([]); + + const merged = await workspace.observe("merged"); + expect(merged.git).toMatchObject({ + isDirty: false, + aheadOfOrigin: 0, + isPaseoOwnedWorktree: true, + }); + await expect.poll(() => workspace.archives).toEqual(["workspace-under-test"]); + expect(workspace.isActive()).toBe(true); + await workspace.observe("merged"); + + expect(workspace.metrics().workspaceListenerCount).toBe(0); + expect(workspace.archives).toEqual(["workspace-under-test"]); + // Confirmation reads the completed snapshot cache; it does not shell out again. + expect(workspace.reads).toEqual([ + "workspace-journey", + "workspace-journey", + "workspace-journey", + ]); + } finally { + releaseArchive(); + } + await expect.poll(() => workspace.isActive()).toBe(false); + }); + + test("keeps an unobserved workspace active when its first snapshot is already merged", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("merged"); + + expect(workspace.metrics().workspaceListenerCount).toBe(0); + expect(workspace.archives).toEqual([]); + expect(workspace.reads).toEqual(["workspace-journey"]); + }); + + test("keeps dirty work when an unobserved workspace PR merges", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("open"); + workspace.makeDirty(); + const merged = await workspace.observe("merged"); + + expect(merged.git.isDirty).toBe(true); + expect(workspace.archives).toEqual([]); + }); + + test("keeps unpushed commits when an unobserved workspace PR merges", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("open"); + workspace.commitUnpushed(); + const merged = await workspace.observe("merged"); + + expect(merged.git).toMatchObject({ isDirty: false, aheadOfOrigin: 1 }); + expect(workspace.archives).toEqual([]); + }); + test("archives when the checked-out PR changes from open to merged", async () => { const workspace = await createWorkspaceJourney(); workspace.checkout("feature"); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 22bf93b625..03decb5f67 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,9 @@ +import { + createFinishSubscriptions, + createFinishObservation, + createJsonFinishSubscriptionStore, +} from "./agent/finish-subscriptions/index.js"; +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; @@ -172,6 +178,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; +import { admitSecuredRelaySocket } from "./websocket/secure-relay-ingress.js"; import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; @@ -423,6 +430,7 @@ export interface PaseoDaemonConfig { agentStoragePath: string; relayEnabled?: boolean; relayEnabledMutable?: boolean; + relayRequireProtectedSecurity?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; relayUseTls?: boolean; @@ -538,11 +546,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } +function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { + return { + enabled: config.relayEnabled ?? true, + requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, + }; +} + function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { - relay: { enabled: config.relayEnabled ?? true }, + relay: createInitialRelayConfig(config), mcp: { enabled: config.mcpEnabled ?? true, injectIntoAgents: config.mcpInjectIntoAgents ?? true, @@ -905,7 +920,17 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); - workspaceRegistry.subscribeToMutations((mutation) => { + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceGitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + workspaceRegistry.subscribeToMutations(async (mutation) => { + if (mutation.kind === "remove" && mutation.workspace) { + const cwd = path.resolve(mutation.workspace.cwd); + const workspaces = await workspaceRegistry.list(); + const hasActiveWorkspace = workspaces.some( + (workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd, + ); + if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); + } if (mutation.kind === "archive" && mutation.workspace) { pluginRuntime.emit("workspace.archived", { workspace: describeHookWorkspace(mutation.workspace), @@ -1016,6 +1041,7 @@ export async function createPaseoDaemon( logger, paseoHome: config.paseoHome, workspaceGitService, + workspaceRuntimeStore, }); const archiveWorkspaceRecordExternal = async ( workspaceId: string, @@ -1391,9 +1417,38 @@ export async function createPaseoDaemon( ); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); + const finishSubscriptions = createFinishSubscriptions({ + store: createJsonFinishSubscriptionStore(config.paseoHome), + serverId, + logger, + messages: () => { + if (!wsServer) throw new Error("Daemon is not accepting agent messages"); + return wsServer.agentMessages; + }, + callerIsActive: async (agentId) => { + const record = await agentStorage.get(agentId); + return record ? !record.archivedAt : Boolean(agentManager.getAgent(agentId)); + }, + ...createFinishObservation({ + agentManager, + agentStorage, + serverId, + logger, + messages: () => { + if (!wsServer) throw new Error("Daemon is not accepting agent messages"); + return wsServer.agentMessages; + }, + hosts: () => { + if (!wsServer) throw new Error("Daemon is not accepting host operations"); + return wsServer.toolHosts(); + }, + }), + }); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ + finishSubscriptions: () => finishSubscriptions, agentMessages: () => { if (!wsServer) throw new Error("Daemon is not accepting agent messages"); return wsServer.agentMessages; @@ -1434,7 +1489,7 @@ export async function createPaseoDaemon( terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, @@ -1710,6 +1765,7 @@ export async function createPaseoDaemon( startPaused: true, }, workspaceAutoName, + workspaceRuntimeStore, daemonAuth, speechService, terminalManager, @@ -1745,14 +1801,17 @@ export async function createPaseoDaemon( return appBaseUrl; }, desktopManaged: config.desktopManaged === true, - getRelayConfig: () => - relayRuntime?.getConfig() ?? { + getRelayConfig: () => ({ + ...(relayRuntime?.getConfig() ?? { enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled, endpoint: relayEndpoint, publicEndpoint: relayPublicEndpoint, useTls: relayUseTls, publicUseTls: relayPublicUseTls, - }, + }), + requireProtectedSecurity: + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }), }, serviceProxyPublicBaseUrl, browserToolsBroker, @@ -1765,7 +1824,9 @@ export async function createPaseoDaemon( pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); providerSnapshotManager.settlePluginProviders(); + await finishSubscriptions.start(); wsServer.beginAcceptingConnections(); + const relaySecurityLogger = logger.child({ module: "relay-security" }); relayRuntime = createRelayRuntime({ config: { enabled: relayEnabled, @@ -1775,12 +1836,25 @@ export async function createPaseoDaemon( publicUseTls: relayPublicUseTls, }, logger, - attachSocket: async (ws, metadata) => { - if (!wsServer) throw new Error("WebSocket server is not ready"); - await wsServer.attachExternalSocket(ws, metadata); - }, + attachSocket: (ws, metadata) => + admitSecuredRelaySocket( + ws, + { + daemonKeyPair: daemonKeyPair.keyPair, + logger: relaySecurityLogger, + requireProtectedMode: () => + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }, + async (secured) => { + if (!wsServer) throw new Error("WebSocket server is not ready"); + await wsServer.attachExternalSocket(secured.socket, { + ...metadata, + transport: "relay", + securityMode: secured.securityMode, + }); + }, + ), serverId, - daemonKeyPair: daemonKeyPair.keyPair, }); daemonConfigStore.onFieldChange("relay.enabled", (value) => { relayRuntime?.setEnabled(value === true); @@ -1835,6 +1909,7 @@ export async function createPaseoDaemon( scriptHealthMonitor.stop(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); + await finishSubscriptions.close(); await wsServer?.agentMessages.close(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1d3f2e598d..d55171831b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const { toCheckoutErrorMock } = vi.hoisted(() => ({ @@ -67,7 +68,7 @@ function createPendingManager() { const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), getSnapshot: async () => createWorkspaceSnapshot(), - peekSnapshot: () => null, + registerWorkspace: () => ({ unsubscribe: () => {} }), requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); @@ -90,6 +91,7 @@ function createPendingManager() { }; const logger = { child: () => logger, warn: () => {} }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService, @@ -125,7 +127,7 @@ describe("CheckoutDiffManager", () => { const workspaceGitService = { subscribe: vi.fn(), - peekSnapshot: vi.fn(), + registerWorkspace: vi.fn( (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { onWorkspaceSnapshot = listener; @@ -147,6 +149,7 @@ describe("CheckoutDiffManager", () => { }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 6dc437297d..917813d587 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; @@ -8,11 +9,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, - | "getCheckoutDiff" - | "getSnapshot" - | "peekSnapshot" - | "registerWorkspace" - | "requestWorkingTreeWatch" + "getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch" >; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -63,14 +60,17 @@ export interface CheckoutDiffSubscription { export class CheckoutDiffManager { private readonly workspaceGitService: CheckoutDiffWorkspace; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly targets = new Map(); constructor(options: { logger: pino.Logger; paseoHome: string; workspaceGitService: CheckoutDiffWorkspace; + workspaceRuntimeStore: WorkspaceRuntimeStore; }) { this.workspaceGitService = options.workspaceGitService; + this.workspaceRuntimeStore = options.workspaceRuntimeStore; } async read( @@ -366,7 +366,7 @@ export class CheckoutDiffManager { private async openTarget(target: CheckoutDiffWatchTarget): Promise { if (target.compare.mode !== "uncommitted") { const snapshot = - this.workspaceGitService.peekSnapshot(target.cwd) ?? + this.workspaceRuntimeStore.get(target.cwd) ?? (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); target.diffCwd = snapshot.git.repoRoot ?? target.cwd; if (this.targets.get(target.key) !== target || target.listeners.size === 0) { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7b2d06560e..cc4eee4fec 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -275,6 +275,7 @@ interface ResolveRelayInput { interface ResolvedRelay { enabled: boolean; enabledMutable: boolean; + requireProtectedSecurity: boolean; endpoint: string; publicEndpoint: string; useTls: boolean; @@ -297,6 +298,21 @@ function resolveTlsFromEnv( return persistedValue ?? fallback; } +function resolveRelayEndpoints(input: ResolveRelayInput): { + endpoint: string; + publicEndpoint: string; +} { + const endpoint = + input.env.PASEO_RELAY_ENDPOINT ?? + input.persisted.daemon?.relay?.endpoint ?? + DEFAULT_RELAY_ENDPOINT; + const publicEndpoint = + input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? + input.persisted.daemon?.relay?.publicEndpoint ?? + endpoint; + return { endpoint, publicEndpoint }; +} + function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED); // COMPAT(relayOptInDefault): daemons whose startup config omitted this field @@ -306,14 +322,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? input.enabledFallback; - const endpoint = - input.env.PASEO_RELAY_ENDPOINT ?? - input.persisted.daemon?.relay?.endpoint ?? - DEFAULT_RELAY_ENDPOINT; - const publicEndpoint = - input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? - input.persisted.daemon?.relay?.publicEndpoint ?? - endpoint; + const { endpoint, publicEndpoint } = resolveRelayEndpoints(input); const useTls = input.cliRelayUseTls ?? resolveTlsFromEnv( @@ -329,6 +338,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { return { enabled, enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined, + requireProtectedSecurity: input.persisted.daemon?.relay?.requireProtectedSecurity ?? false, endpoint, publicEndpoint, useTls, @@ -635,6 +645,7 @@ export function resolveConfigFromPersisted( agentClients: {}, relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index e24e431cd0..8a2d4f9c39 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1715,3 +1715,320 @@ test( expect(requests[1]).not.toHaveProperty("sessionMode"); }, ); + +test( + "durable remote finish intent survives origin restart and unavailable-host recovery", + { timeout: 60000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), "durable-remote-origin-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => { + finish.resolve(); + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const clients = () => + createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }); + const a = await daemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + mcpEnabled: true, + }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const remoteId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Remote parent", + }); + const mcp = new Client({ name: "durable-remote-parent", version: "1" }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + cleanup.push(() => mcp.close()); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId: remoteId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Remote child", + initialPrompt: "Keep working while origin restarts", + settings: { modeId: "full-access" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + const records = async (home: string) => + JSON.parse(await readFile(join(home, "finish-subscriptions.json"), "utf8")); + expect(await records(a.paseoHome)).toHaveLength(1); + // An unavailable saved route must retain intent, including through a restart. + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await origin.paseo.dispose(); + await origin.client.close(); + await mcp.close(); + await a.close(); + const restarted = await daemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + mcpEnabled: true, + }); + const recovered = await api(restarted); + expect(await records(restarted.paseoHome)).toHaveLength(1); + expect(parentPrompts).toEqual([]); + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + await recovered.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(recovered.paseo); + expect(parentPrompts).toEqual([]); + expect(await records(restarted.paseoHome)).toHaveLength(1); + finish.resolve(); + await expect.poll(() => parentPrompts, { timeout: 15000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain(`Agent ${childId}`); + expect(parentPrompts[0]).toContain(`Server: ${remoteId}`); + expect(parentPrompts[0]).toContain('event="finished"'); + await expect.poll(() => records(restarted.paseoHome), { timeout: 5000 }).toEqual([]); + }, +); + +test( + "remote finish observation survives a permission checkpoint until resumed completion", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => { + finish.resolve(); + }); + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Permission parent", + }); + const mcp = new Client({ name: "permission-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId: directory.hosts[0]!.serverId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Permission child", + initialPrompt: "Run rm -f permission.txt", + settings: { modeId: "default" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain('event="permission-required"'); + expect(parentPrompts[0]).toContain("respond_to_permission"); + const child = (await destination.client.fetchAgent(childId))!.agent; + expect(child.pendingPermissions).toHaveLength(1); + expect( + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toHaveLength(1); + await destination.client.respondToPermission(childId, child.pendingPermissions[0]!.id, { + behavior: "allow", + }); + expect(parentPrompts).toHaveLength(1); + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + finish.resolve(); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain('event="finished"'); + await expect + .poll(async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ) + .toEqual([]); + }, +); + +test( + "disabled remote send rejects unsent finish intent before reconnecting to an idle child", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ agentClients: createTestAgentClients() }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const serverId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Parent", + }); + const child = await remoteWorkspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Idle child", + }); + const mcp = new Client({ name: "offline-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + const response = await mcp.callTool({ + name: "send_agent_prompt", + arguments: { serverId, agentId: child.id, prompt: "Never sent" }, + }); + expect(response.isError).toBe(true); + const records = () => + readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8").then(JSON.parse); + expect(await records()).toEqual([]); + await origin.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(origin.paseo); + expect((await destination.client.fetchAgent(child.id))?.agent.status).toBe("idle"); + expect(await records()).toEqual([]); + expect(parentPrompts).toEqual([]); + }, +); + +test.each(["removed", "disabled"] as const)( + "%s remote destination reports unknown child status and consumes its finish subscription", + { timeout: 30000 }, + async (availability) => { + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => finish.resolve()); + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const serverId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Parent", + }); + const mcp = new Client({ name: "removed-host-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Child", + initialPrompt: "Held work", + settings: { modeId: "full-access" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + if (availability === "removed") await origin.paseo.hosts.remove(saved.registrationId); + else await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await expect.poll(() => parentPrompts, { timeout: 15000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain(`Status of child agent ${childId}`); + expect(parentPrompts[0]).toContain(`Server: ${serverId}`); + expect(parentPrompts[0]).toContain( + availability === "removed" + ? `Host ${serverId} was removed or is unavailable.` + : `Could not observe child agent ${childId} on host ${serverId} after three failed attempts.`, + ); + expect(parentPrompts[0]).toContain("is unknown."); + expect(parentPrompts[0]).not.toContain("finished."); + await expect + .poll(async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ) + .toEqual([]); + if (availability === "removed") + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + else await origin.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(origin.paseo); + finish.resolve(); + const completed = await destination.client.waitForFinish(childId, 5000); + expect(completed.final?.status).toBe("idle"); + expect(parentPrompts).toHaveLength(1); + expect( + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toEqual([]); + }, +); diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index d435049f06..df2e952a06 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -31,12 +31,14 @@ export async function createConnectionOfferV2(args: { serverId: string; daemonPublicKeyB64: string; relay: { endpoint: string; useTls?: boolean }; + minimumSecurityMode?: "protected"; }): Promise { return ConnectionOfferV2Schema.parse({ v: 2, serverId: args.serverId, daemonPublicKeyB64: args.daemonPublicKeyB64, relay: args.relay, + ...(args.minimumSecurityMode ? { minimumSecurityMode: args.minimumSecurityMode } : {}), }); } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 3629a0fd18..c68cda02d2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -120,6 +120,35 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); + test("patch persists the relay protected security requirement independently of relay enablement", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore( + paseoHome, + { + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + // A launch override owns relay.enabled; the security requirement is still editable. + { relayEnabledMutable: false }, + ); + const changes: unknown[] = []; + store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); + + store.patch({ relay: { requireProtectedSecurity: true } }); + + expect(changes).toEqual([true]); + expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); + expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); + }); + test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 3e285bded0..489f68ed5d 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -17,7 +17,7 @@ type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDae type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride; interface SupportedMutableConfigPatch { - relay?: { enabled?: boolean }; + relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; @@ -170,6 +170,7 @@ function isEqualValue(a: unknown, b: unknown): boolean { const RELOADABLE_PATHS = [ "daemon.relay.enabled", + "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", "daemon.browserTools.enabled", @@ -193,6 +194,7 @@ const RELOADABLE_PATHS = [ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.enabled", "relay.enabled"], + ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], ["daemon.browserTools.enabled", "browserTools.enabled"], @@ -249,9 +251,21 @@ function compactOwnedPaths(paths: readonly string[], owners: readonly string[]): return Array.from(compacted).sort(); } +function pickRelayPatch( + relay: MutableDaemonConfigPatch["relay"], +): Pick { + const picked: NonNullable = { + ...(relay?.enabled !== undefined ? { enabled: relay.enabled } : {}), + ...(relay?.requireProtectedSecurity !== undefined + ? { requireProtectedSecurity: relay.requireProtectedSecurity } + : {}), + }; + return Object.keys(picked).length > 0 ? { relay: picked } : {}; +} + function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { - ...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}), + ...pickRelayPatch(patch.relay), ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -646,6 +660,9 @@ function mergeMutableDaemonPatch( if (persistRelayEnabled && patch.relay?.enabled !== undefined) { next.relay = { ...next.relay, enabled: patch.relay.enabled }; } + if (patch.relay?.requireProtectedSecurity !== undefined) { + next.relay = { ...next.relay, requireProtectedSecurity: patch.relay.requireProtectedSecurity }; + } if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } diff --git a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts index 6486f8e04a..efc99c9b59 100644 --- a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts @@ -1,3 +1,5 @@ +import { AgentStorage } from "../agent/agent-storage.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -64,3 +66,157 @@ describe("daemon restart resume", () => { } }, 30_000); }); + +// These run in separate OS processes so SIGKILL cannot invoke daemon shutdown hooks. +import { fork, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { readFile, mkdtemp, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +async function restartFixture(root: string) { + const child = fork( + fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)), + [root], + { + execArgv: ["--conditions=source", "--import", "tsx"], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + let output = ""; + child.stdout?.on("data", (data) => { + output += data; + }); + child.stderr?.on("data", (data) => { + output += data; + }); + const ready = await Promise.race([ + once(child, "message").then( + ([message]) => message as { port: number; paseoHome: string; cwd: string }, + ), + once(child, "exit").then(([code]) => { + throw new Error(`Fixture exited ${code}: ${output}`); + }), + ]); + return { child, ...ready }; +} +async function stopFixture(child: ChildProcess, abrupt: boolean) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + if (abrupt) child.kill("SIGKILL"); + else child.send("stop"); + await exited; +} +async function readPrompts(root: string): Promise { + try { + return (await readFile(path.join(root, "parent-prompts.jsonl"), "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +test.each([false, true])( + "durable finish subscriptions recover a running child after restart (abrupt=%s)", + async (abrupt) => { + const root = await mkdtemp(path.join(tmpdir(), "durable-finish-process-")); + const processes: ChildProcess[] = []; + let client: DaemonClient | undefined; + let mcp: Client | undefined; + try { + const first = await restartFixture(root); + processes.push(first.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const parent = await client.createAgent({ + provider: "claude", + cwd: first.cwd, + title: "Durable parent", + modeId: "full-access", + }); + mcp = new Client({ name: "durable-parent", version: "1" }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${first.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const result = await mcp.callTool({ + name: "create_agent", + arguments: { + provider: "claude/claude-test", + title: "Held child", + initialPrompt: "HOLD_CHILD", + }, + }); + expect(result.isError).not.toBe(true); + const childId = (result.structuredContent as { agentId: string }).agentId; + expect((await client.fetchAgent(childId))?.agent.status).toBe("running"); + // Kill only after the running snapshot commits, so this exercises bootstrap repair + // rather than recovery of the earlier idle snapshot while its write is pending. + await expect + .poll( + async () => + ( + await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get( + childId, + ) + )?.lastStatus, + ) + .toBe("running"); + expect( + JSON.parse(await readFile(path.join(first.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toHaveLength(1); + await mcp.close(); + mcp = undefined; + await client.close(); + client = undefined; + await stopFixture(first.child, abrupt); + const second = await restartFixture(root); + processes.push(second.child); + await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1); + const prompts = await readPrompts(root); + expect(prompts[0]).toContain(`Agent ${childId}`); + expect(prompts[0]).toContain("was closed"); + await expect + .poll( + async () => + JSON.parse( + await readFile(path.join(second.paseoHome, "finish-subscriptions.json"), "utf8"), + ), + { timeout: 5000 }, + ) + .toEqual([]); + client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + expect((await client.fetchAgent(childId))?.agent.status).toBe("closed"); + await client.close(); + client = undefined; + await stopFixture(second.child, false); + const third = await restartFixture(root); + processes.push(third.child); + expect(await readPrompts(root)).toHaveLength(1); + expect( + JSON.parse(await readFile(path.join(third.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toEqual([]); + } catch (error) { + console.error( + await readFile(path.join(root, "fixture.log"), "utf8").catch(() => "No fixture log"), + ); + throw error; + } finally { + await mcp?.close(); + await client?.close(); + for (const child of processes.toReversed()) await stopFixture(child, true); + await rm(root, { recursive: true, force: true }); + } + }, + 60000, +); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index facf6dffd7..32d3a1f36c 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -7,7 +7,10 @@ import { encodeTerminalStreamFrame, TerminalStreamOpcode, } from "@getpaseo/protocol/binary-frames/index"; -import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client"; +import { + getDaemonAuthFailureReason, + type DaemonClient as HostClient, +} from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; import { withTimeout } from "../../utils/promise-timeout.js"; @@ -26,7 +29,12 @@ import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; +import { + createClientChannel, + openClientSecureChannel, + type SecureChannel, + type Transport, +} from "@getpaseo/relay/e2ee"; import { deriveSharedKey, decrypt, @@ -59,6 +67,7 @@ async function getPairingOfferUrl(args: { relayEnabled?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; }): Promise { const pairing = await generateLocalPairingOffer({ @@ -66,6 +75,7 @@ async function getPairingOfferUrl(args: { relayEnabled: args.relayEnabled, relayEndpoint: args.relayEndpoint, relayPublicEndpoint: args.relayPublicEndpoint, + relayRequireProtectedSecurity: args.relayRequireProtectedSecurity, appBaseUrl: args.appBaseUrl, includeQr: false, }); @@ -90,6 +100,113 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +/** + * An updated relay client: the shared secure channel over a raw relay socket. + * Resolves once the daemon answers the app hello with server_info. + */ +async function openUpdatedRelayClient(args: { + relayPort: number; + serverId: string; + daemonPublicKeyB64: string; + clientId: string; + minimumMode?: "protected"; +}): Promise<{ + channel: SecureChannel; + serverInfo: { features?: Record }; + messages: unknown[]; + waitFor: (predicate: (message: unknown) => boolean) => Promise; + close: () => void; +}> { + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${args.relayPort}`, + useTls: false, + serverId: args.serverId, + role: "client", + }), + ); + const transport: Transport = { + send: (data) => ws.send(data), + close: (code?: number, reason?: string) => ws.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + ws.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + transport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, + }); + }); + ws.on("close", (code, reason) => transport.onclose?.(code, reason.toString())); + ws.on("error", (error) => transport.onerror?.(error)); + await once(ws, "open"); + + const messages: unknown[] = []; + const waiters: Array<{ + predicate: (message: unknown) => boolean; + resolve: (m: unknown) => void; + }> = []; + const deliver = (message: unknown) => { + messages.push(message); + for (const waiter of waiters.slice()) { + if (!waiter.predicate(message)) continue; + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + }; + const waitFor = (predicate: (message: unknown) => boolean) => + withTimeout( + new Promise((resolve) => { + const existing = messages.find(predicate); + if (existing !== undefined) resolve(existing); + else waiters.push({ predicate, resolve }); + }), + 20_000, + "relay client message", + ); + const isServerInfo = (message: unknown) => { + const parsed = WSOutboundMessageSchema.safeParse(message); + return ( + parsed.success && + parsed.data.type === "session" && + parsed.data.message.type === "status" && + parsed.data.message.payload?.status === "server_info" + ); + }; + + const channel = await openClientSecureChannel(transport, { + daemonPublicKeyB64: args.daemonPublicKeyB64, + ...(args.minimumMode ? { minimumMode: args.minimumMode } : {}), + events: { + onmessage: (data) => { + if (typeof data === "string") deliver(JSON.parse(data)); + }, + }, + }); + await channel.send( + JSON.stringify({ + type: "hello", + clientId: args.clientId, + clientType: "cli", + protocolVersion: 1, + }), + ); + const serverInfoMessage = (await waitFor(isServerInfo)) as { + message: { payload: { features?: Record } }; + }; + return { + channel, + serverInfo: serverInfoMessage.message.payload, + messages, + waitFor, + close: () => ws.close(), + }; +} + function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -204,9 +321,18 @@ async function waitForCapturedLog( let relayPort: number; let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; + // PASEO_RELAY_E2E_PORT points at an already-running relay (for example the + // Elixir relay) so the same daemon/client exchanges run against both. + const externalRelayPort = Number(process.env.PASEO_RELAY_E2E_PORT ?? ""); + const usesExternalRelay = Number.isInteger(externalRelayPort) && externalRelayPort > 0; const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => { relayStdoutLines = []; + if (usesExternalRelay) { + relayPort = externalRelayPort; + await waitForRelayWebSocketReady(relayPort, 60000); + return; + } relayPort = options.port ?? (await getAvailablePort()); const relayDir = path.resolve(process.cwd(), "../relay"); const relayArgs = [ @@ -273,144 +399,199 @@ async function waitForCapturedLog( await withTimeout(exited, 10000, "Local relay did not stop"); }; - test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => { - let a: Awaited> | undefined; - let b: Awaited> | undefined; - let origin: ReturnType | undefined; - let api: PaseoApi | undefined; - let remote: PaseoApi | undefined; - let sibling: PaseoApi | undefined; - let terminalRoute: Awaited> | undefined; - let wire: Awaited> | undefined; + // Needs to stop and restart the relay, which only the spawned wrangler relay allows. + test.skipIf(usesExternalRelay)( + "connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", + async () => { + let a: Awaited> | undefined; + let b: Awaited> | undefined; + let origin: ReturnType | undefined; + let api: PaseoApi | undefined; + let remote: PaseoApi | undefined; + let sibling: PaseoApi | undefined; + let terminalRoute: Awaited> | undefined; + let wire: Awaited> | undefined; + try { + await startRelay({ useLocalRelay: true }); + const port = relayPort; + b = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${port}`, + relayUseTls: false, + relayPublicUseTls: false, + auth: { password: hashSync("relay-password", 4) }, + }); + a = await createTestPaseoDaemon(); + origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); + await origin.connect(); + api = createPaseoApi(origin); + const offerUrl = await getPairingOfferUrl({ + paseoHome: b.paseoHome, + relayEnabled: true, + relayEndpoint: b.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + wire = await relayWire(port); + const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + connection.searchParams.set("password", "wrong-password"); + const saved = await api.hosts.add({ connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) + .toBe("authentication_failed"); + expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); + connection.searchParams.set("password", "relay-password"); + await api.hosts.update(saved.registrationId, { connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) + .toBe("online"); + const admittedConnections = wire.connections(); + expect(wire.active()).toBe(1); + remote = await api.hosts.client(serverId); + sibling = await api.hosts.client(serverId); + expect(wire.connections()).toBe(admittedConnections); + expect(wire.active()).toBe(1); + expect((await remote.workspaces.list()).entries).toEqual([]); + const observed = await remote.workspaces.list({ subscribe: {} }); + let snapshots = 0; + observed.subscription.subscribe({ + snapshot: () => { + snapshots++; + }, + update() {}, + }); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + title: "Relayed workspace", + }); + const terminal = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + }); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + expect(wire.connections()).toBe(admittedConnections); + terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); + terminalRoute.close(); + terminalRoute = undefined; + const before = snapshots; + await stopRelay(); + await expect + .poll( + async () => { + try { + await remote!.workspaces.list(); + return "online"; + } catch (error) { + return (error as { code: string }).code; + } + }, + { timeout: 15000 }, + ) + .toBe("DAEMON_CONNECTION_LOST"); + await startRelay({ useLocalRelay: true, port }); + await expect + .poll( + async () => { + try { + return (await remote!.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 30000 }, + ) + .toBe(1); + await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); + expect(wire.active()).toBe(1); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); + terminalRoute.close(); + terminalRoute = undefined; + await remote.dispose(); + remote = undefined; + expect((await sibling.workspaces.list()).entries).toHaveLength(1); + await sibling.terminals.ref(terminal.id).kill(); + await api.dispose(); + await expect(sibling.workspaces.list()).rejects.toThrow(); + const direct = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + password: "relay-password", + reconnect: { enabled: false }, + }); + try { + await direct.connect(); + const directApi = createPaseoApi(direct); + expect((await directApi.workspaces.list()).entries).toHaveLength(1); + await directApi.dispose(); + } finally { + await direct.close(); + } + } finally { + terminalRoute?.close(); + await remote?.dispose(); + await sibling?.dispose(); + await api?.dispose(); + await origin?.close(); + await a?.close(); + await b?.close(); + await wire?.close(); + await stopRelay(); + } + }, + 120000, + ); + + test("password-protected daemon refuses a relay client without the password", async () => { + let daemon: Awaited> | undefined; + const clients: HostClient[] = []; try { await startRelay({ useLocalRelay: true }); - const port = relayPort; - b = await createTestPaseoDaemon({ + daemon = await createTestPaseoDaemon({ relayEnabled: true, - relayEndpoint: `127.0.0.1:${port}`, + relayEndpoint: `127.0.0.1:${relayPort}`, relayUseTls: false, relayPublicUseTls: false, auth: { password: hashSync("relay-password", 4) }, }); - a = await createTestPaseoDaemon(); - origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); - await origin.connect(); - api = createPaseoApi(origin); const offerUrl = await getPairingOfferUrl({ - paseoHome: b.paseoHome, + paseoHome: daemon.paseoHome, relayEnabled: true, - relayEndpoint: b.config.relayEndpoint, + relayEndpoint: daemon.config.relayEndpoint, }); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); - wire = await relayWire(port); - const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); - connection.searchParams.set("key", daemonPublicKeyB64); - connection.searchParams.set("password", "wrong-password"); - const saved = await api.hosts.add({ connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) - .toBe("authentication_failed"); - expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); - connection.searchParams.set("password", "relay-password"); - await api.hosts.update(saved.registrationId, { connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) - .toBe("online"); - const admittedConnections = wire.connections(); - expect(wire.active()).toBe(1); - remote = await api.hosts.client(serverId); - sibling = await api.hosts.client(serverId); - expect(wire.connections()).toBe(admittedConnections); - expect(wire.active()).toBe(1); - expect((await remote.workspaces.list()).entries).toEqual([]); - const observed = await remote.workspaces.list({ subscribe: {} }); - let snapshots = 0; - observed.subscription.subscribe({ - snapshot: () => { - snapshots++; - }, - update() {}, - }); - const workspace = await remote.workspaces.create({ - source: { kind: "directory", path: b.staticDir }, - title: "Relayed workspace", - }); - const terminal = await workspace.terminals.create({ - command: process.execPath, - args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], - }); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - expect(wire.connections()).toBe(admittedConnections); - terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); - terminalRoute.close(); - terminalRoute = undefined; - const before = snapshots; - await stopRelay(); - await expect - .poll( - async () => { - try { - await remote!.workspaces.list(); - return "online"; - } catch (error) { - return (error as { code: string }).code; - } - }, - { timeout: 15000 }, - ) - .toBe("DAEMON_CONNECTION_LOST"); - await startRelay({ useLocalRelay: true, port }); - await expect - .poll( - async () => { - try { - return (await remote!.workspaces.list()).entries.length; - } catch { - return -1; - } - }, - { timeout: 30000 }, - ) - .toBe(1); - await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); - expect(wire.active()).toBe(1); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); - terminalRoute.close(); - terminalRoute = undefined; - await remote.dispose(); - remote = undefined; - expect((await sibling.workspaces.list()).entries).toHaveLength(1); - await sibling.terminals.ref(terminal.id).kill(); - await api.dispose(); - await expect(sibling.workspaces.list()).rejects.toThrow(); - const direct = createHostClient(`127.0.0.1:${b.port}`, { - clientId: crypto.randomUUID(), - password: "relay-password", - reconnect: { enabled: false }, - }); - try { - await direct.connect(); - const directApi = createPaseoApi(direct); - expect((await directApi.workspaces.list()).entries).toHaveLength(1); - await directApi.dispose(); - } finally { - await direct.close(); - } + const connectOverRelay = (password?: string): Promise => { + const connection = new URL(`relay://127.0.0.1:${relayPort}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + if (password) connection.searchParams.set("password", password); + const client = createHostClient(connection.toString(), { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + clients.push(client); + return client.connect(); + }; + + // The pairing offer alone must not grant access to a password-protected daemon. + const rejection = await connectOverRelay().then( + () => null, + (error: unknown) => error, + ); + expect(getDaemonAuthFailureReason(rejection)).toBe("password_required"); + await expect(connectOverRelay("wrong-password")).rejects.toThrow("Incorrect password"); + + await connectOverRelay("relay-password"); + expect((await createPaseoApi(clients.at(-1)!).workspaces.list()).entries).toEqual([]); } finally { - terminalRoute?.close(); - await remote?.dispose(); - await sibling?.dispose(); - await api?.dispose(); - await origin?.close(); - await a?.close(); - await b?.close(); - await wire?.close(); - await stopRelay(); + try { + await Promise.all(clients.map((client) => client.close())); + await daemon?.close(); + } finally { + await stopRelay(); + } } - }, 120000); + }, 90000); test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { await startRelay({ useLocalRelay: true }); @@ -620,11 +801,11 @@ async function waitForCapturedLog( } }, 90000); - test("daemon closes a relay client that sends an unsupported handshake key", async () => { + test("updated client negotiates protected endpoint security through the relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; const { logger, lines } = createCapturingLogger(); - await startRelay({ useLocalRelay: true }); + await startRelay(); const daemon = await createTestPaseoDaemon({ listen: "127.0.0.1", @@ -641,8 +822,76 @@ async function waitForCapturedLog( relayPublicEndpoint: daemon.config.relayPublicEndpoint, appBaseUrl: daemon.config.appBaseUrl, }); - const { serverId } = decodeOfferFromFragmentUrl(offerUrl); - const ws = new WebSocket( + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_client", + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + + await client.channel.send(JSON.stringify({ type: "ping" })); + const pong = await client.waitFor( + (message) => (message as { type?: string }).type === "pong", + ); + expect(pong).toEqual({ type: "pong" }); + await waitForCapturedLog( + lines, + (line) => line.includes('"securityMode":"protected"'), + 5_000, + ); + } finally { + client.close(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; + } finally { + await daemon.close(); + await stopRelay(); + } + }, 90000); + + test("daemon requiring protected security rejects a legacy relay client and admits an updated one", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay(); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayRequireProtectedSecurity: true, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + relayRequireProtectedSecurity: daemon.config.relayRequireProtectedSecurity, + appBaseUrl: daemon.config.appBaseUrl, + }); + const offer = ConnectionOfferSchema.parse( + JSON.parse( + Buffer.from( + offerUrl.slice(offerUrl.indexOf("#offer=") + "#offer=".length), + "base64url", + ).toString("utf8"), + ), + ); + expect(offer.minimumSecurityMode).toBe("protected"); + const { serverId, daemonPublicKeyB64 } = offer; + + // A client that only speaks the legacy construction is refused before admission. + const legacyWs = new WebSocket( buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${relayPort}`, useTls: false, @@ -650,92 +899,188 @@ async function waitForCapturedLog( role: "client", }), ); - - let receivedServerInfo = false; - const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); - const outcome = await new Promise< - { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } - >((resolve, reject) => { - const timeout = setTimeout(() => { - ws.close(); - reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); - }, 20_000); - - ws.on("open", async () => { - try { - await waitForCapturedLog(lines, isRelayDataConnectedLog); - ws.send( - JSON.stringify({ - type: "e2ee_hello", - key: exportPublicKey(new Uint8Array(32)), - }), - ); - ws.send( - encodeCiphertext( - encrypt( - UNSUPPORTED_PEER_TRANSPORT_KEY, - JSON.stringify({ - type: "hello", - clientId: "cid_relay_unsupported_key", - clientType: "cli", - protocolVersion: 1, - }), - ), - ), - ); - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }); - - ws.on("message", (data) => { - try { - const parsed = WSOutboundMessageSchema.parse( - parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), - ); - if ( - parsed.type === "session" && - parsed.message.type === "status" && - parsed.message.payload?.status === "server_info" - ) { - receivedServerInfo = true; - clearTimeout(timeout); - reject( - new Error("daemon sent server_info after accepting an unsupported handshake key"), - ); - } - } catch { - // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. - } - }); - ws.on("close", (code, reason) => { - clearTimeout(timeout); - resolve({ type: "closed", code, reason: reason.toString() }); - }); - ws.on("error", (error) => { - clearTimeout(timeout); - resolve({ type: "errored", error }); + const legacyTransport: Transport = { + send: (data) => legacyWs.send(data), + close: (code?: number, reason?: string) => legacyWs.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + legacyWs.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + legacyTransport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, }); }); + const legacyClosed = new Promise<{ code: number; reason: string }>((resolve) => { + legacyWs.on("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + await once(legacyWs, "open"); + const legacyMessages: unknown[] = []; + void createClientChannel(legacyTransport, daemonPublicKeyB64, { + onmessage: (data) => legacyMessages.push(data), + }).catch(() => undefined); + const closed = await withTimeout(legacyClosed, 20_000, "legacy client close"); + // The daemon closes its side with 1008; the relay reports its own close code to + // the client (the wrangler relay sends 1012 "Server disconnected"), so only the + // daemon log carries the policy reason. + expect(closed.code).not.toBe(1000); + expect(legacyMessages).toEqual([]); + await waitForCapturedLog( + lines, + (line) => + line.includes("relay_e2ee_handshake_rejected") && + line.includes("legacy endpoint security"), + ); - expect(receivedServerInfo).toBe(false); - if (outcome.type === "closed") { - expect(outcome.code).toBeGreaterThan(0); - } else { - expect(outcome.error).toBeInstanceOf(Error); + // An updated client pinned by the offer is admitted in protected mode. + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_required", + minimumMode: offer.minimumSecurityMode, + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + } finally { + client.close(); } - - const isRejectedHandshakeLog = (line: string) => - line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); - await waitForCapturedLog(lines, isRejectedHandshakeLog); - expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; } finally { await daemon.close(); await stopRelay(); } }, 90000); + // The Elixir relay rejects the invalid key itself, so the daemon never sees this hello. + test.skipIf(usesExternalRelay)( + "daemon closes a relay client that sends an unsupported handshake key", + async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId } = decodeOfferFromFragmentUrl(offerUrl); + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + ); + + let receivedServerInfo = false; + const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); + const outcome = await new Promise< + { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } + >((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); + }, 20_000); + + ws.on("open", async () => { + try { + await waitForCapturedLog(lines, isRelayDataConnectedLog); + ws.send( + JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(new Uint8Array(32)), + }), + ); + ws.send( + encodeCiphertext( + encrypt( + UNSUPPORTED_PEER_TRANSPORT_KEY, + JSON.stringify({ + type: "hello", + clientId: "cid_relay_unsupported_key", + clientType: "cli", + protocolVersion: 1, + }), + ), + ), + ); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + + ws.on("message", (data) => { + try { + const parsed = WSOutboundMessageSchema.parse( + parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), + ); + if ( + parsed.type === "session" && + parsed.message.type === "status" && + parsed.message.payload?.status === "server_info" + ) { + receivedServerInfo = true; + clearTimeout(timeout); + reject( + new Error("daemon sent server_info after accepting an unsupported handshake key"), + ); + } + } catch { + // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. + } + }); + ws.on("close", (code, reason) => { + clearTimeout(timeout); + resolve({ type: "closed", code, reason: reason.toString() }); + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ type: "errored", error }); + }); + }); + + expect(receivedServerInfo).toBe(false); + if (outcome.type === "closed") { + expect(outcome.code).toBeGreaterThan(0); + } else { + expect(outcome.error).toBeInstanceOf(Error); + } + + const isRejectedHandshakeLog = (line: string) => + line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); + await waitForCapturedLog(lines, isRejectedHandshakeLog); + expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } finally { + await daemon.close(); + await stopRelay(); + } + }, + 90000, + ); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts index c8f038e886..59b8c623c7 100644 --- a/packages/server/src/server/pairing-offer.ts +++ b/packages/server/src/server/pairing-offer.ts @@ -18,6 +18,8 @@ export async function generateLocalPairingOffer(args: { relayPublicEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + /** Stamp the offer so new clients require protected endpoint security from their first handshake. */ + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; includeQr?: boolean; logger?: Logger; @@ -42,6 +44,9 @@ export async function generateLocalPairingOffer(args: { serverId, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls }, + ...(args.relayRequireProtectedSecurity === true + ? { minimumSecurityMode: "protected" as const } + : {}), }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 0e0ccf34f7..66740d2ae2 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1221,7 +1221,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { registerWorkspace: () => ({ unsubscribe: () => {}, }), - peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), + getCheckout: async (cwd) => { try { const snapshot = createWorkspaceGitSnapshot(cwd); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 11d94495b1..cccc0228d8 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -278,6 +278,12 @@ export const PersistedConfigSchema = z relay: z .object({ enabled: z.boolean().optional(), + requireProtectedSecurity: z + .boolean() + .optional() + .describe( + "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + ), endpoint: z.string().optional(), publicEndpoint: z.string().optional(), useTls: z.boolean().optional(), diff --git a/packages/server/src/server/relay-runtime.ts b/packages/server/src/server/relay-runtime.ts index e9f2ccf158..885aaa9b6e 100644 --- a/packages/server/src/server/relay-runtime.ts +++ b/packages/server/src/server/relay-runtime.ts @@ -1,5 +1,4 @@ import type pino from "pino"; -import type { KeyPair } from "@getpaseo/relay/e2ee"; import type { ExternalSocketMetadata } from "./websocket-server.js"; import { startRelayTransport, @@ -20,7 +19,6 @@ interface RelayRuntimeOptions { logger: pino.Logger; attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise; serverId: string; - daemonKeyPair: KeyPair; startTransport?: typeof startRelayTransport; } @@ -43,7 +41,6 @@ export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime { relayEndpoint: config.endpoint, relayUseTls: config.useTls, serverId: options.serverId, - daemonKeyPair: options.daemonKeyPair, }); } diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 2dcf29b948..26868e7112 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import pino from "pino"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; -import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; function createMockLogger() { @@ -146,37 +144,6 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); - test("releases a stalled encrypted data connection without restarting control", async () => { - vi.useFakeTimers(); - let attachments = 0; - controllers.push( - startRelayTransport({ - logger: pino({ level: "silent" }), - attachSocket: async () => { - attachments += 1; - }, - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair: generateKeyPair(), - createWebSocket: relay.createWebSocket, - }), - ); - const control = relay.sockets[0]; - control.open(); - const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); - control.message(connected); - const stalled = relay.sockets[1]; - stalled.open(); - await vi.advanceTimersByTimeAsync(15_000); - expect(stalled.terminateCalls).toBe(1); - expect(attachments).toBe(0); - expect(control.terminateCalls).toBe(0); - control.message(connected); - expect(relay.sockets).toHaveLength(3); - expect(relay.sockets[2].url).toBe(stalled.url); - }); - test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ @@ -286,80 +253,6 @@ describe("relay-transport control lifecycle", () => { ]); }); - test("encrypted sends wait for the physical data socket callback", async () => { - const logger = createMockLogger(); - const daemonKeyPair = generateKeyPair(); - let resolveAttached: ((socket: unknown) => void) | undefined; - const attached = new Promise((resolve) => { - resolveAttached = resolve; - }); - const controller = startRelayTransport({ - logger: logger as unknown as pino.Logger, - attachSocket: async (socket) => resolveAttached?.(socket), - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair, - createWebSocket: relay.createWebSocket, - }); - controllers.push(controller); - - const control = relay.sockets[0]; - control.open(); - control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false); - control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false); - - const dataSocket = relay.sockets[1]; - dataSocket.deferSendCompletion = true; - dataSocket.open(); - let clientTransport: Transport; - clientTransport = { - send: (data) => dataSocket.message(data, data instanceof ArrayBuffer), - close: () => undefined, - onmessage: null, - onclose: null, - onerror: null, - }; - dataSocket.onSend = (data) => { - clientTransport.onmessage?.({ - data: data instanceof Uint8Array ? data.slice().buffer : data, - isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, - }); - }; - let resolveClientOpen: (() => void) | undefined; - const clientOpen = new Promise((resolve) => { - resolveClientOpen = resolve; - }); - await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), { - onopen: () => resolveClientOpen?.(), - }); - - let attachedCompleted = false; - void attached.then(() => { - attachedCompleted = true; - return undefined; - }); - await clientOpen; - await Promise.resolve(); - expect(attachedCompleted).toBe(false); - dataSocket.completeNextSend(); - const encryptedSocket = (await attached) as { - send: (data: Uint8Array) => void | Promise; - }; - let completed = false; - - const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { - completed = true; - return undefined; - }); - await Promise.resolve(); - expect(completed).toBe(false); - - dataSocket.completeNextSend(); - await sending; - expect(completed).toBe(true); - }); - test("uses relayUseTls for control and data socket URLs", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 113cce605c..08fcd826d5 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,15 +1,8 @@ /// -import { EventEmitter } from "node:events"; import { WebSocket } from "ws"; import type pino from "pino"; -import { - createDaemonChannel, - type Transport as RelayTransport, - type KeyPair, -} from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import type { ExternalSocketMetadata } from "./websocket-server.js"; -import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js"; export interface RelayTransportOptions { logger: pino.Logger; @@ -17,7 +10,6 @@ export interface RelayTransportOptions { relayEndpoint: string; // "host:port" relayUseTls: boolean; serverId: string; - daemonKeyPair?: KeyPair; createWebSocket?: RelayWebSocketFactory; } @@ -30,13 +22,12 @@ export interface RelaySocketLike { bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; close: (code?: number, reason?: string) => void; - terminate?: () => void; + terminate: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } interface RelayWebSocketLike extends RelaySocketLike { - terminate: () => void; ping: () => void; on: ( event: "open" | "message" | "close" | "error" | "pong", @@ -118,7 +109,6 @@ export function startRelayTransport({ relayEndpoint, relayUseTls, serverId, - daemonKeyPair, createWebSocket = createDefaultRelayWebSocket, }: RelayTransportOptions): RelayTransportController { const relayLogger = logger.child({ module: "relay-transport" }); @@ -385,17 +375,7 @@ export function startRelayTransport({ externalSessionKey: `session:${connectionId}`, relayConnectionId: connectionId, }; - if (daemonKeyPair) { - void attachEncryptedSocket( - socket, - daemonKeyPair, - relayLogger.child({ connectionId }), - attachSocket, - externalMetadata, - ); - } else { - void attachSocket(socket, externalMetadata); - } + void attachSocket(socket, externalMetadata); }); socket.on("close", (code, reason) => { @@ -418,149 +398,3 @@ export function startRelayTransport({ return { stop }; } - -async function attachEncryptedSocket( - socket: RelayWebSocketLike, - daemonKeyPair: KeyPair, - logger: pino.Logger, - attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, - metadata?: ExternalSocketMetadata, -): Promise { - const handshakeTimeout = setTimeout(() => { - logger.warn("relay_e2ee_handshake_timeout_terminating"); - socket.terminate(); - }, 15_000); - try { - const relayTransport = createRelayTransportAdapter(socket, logger); - const emitter = new EventEmitter(); - const pendingMessages: Array = []; - let attached = false; - const emitMessage = (data: string | ArrayBuffer) => { - if (attached) { - emitter.emit("message", data); - return; - } - pendingMessages.push(data); - }; - const channel = await createDaemonChannel(relayTransport, daemonKeyPair, { - onmessage: emitMessage, - onclose: (code, reason) => emitter.emit("close", code, reason), - onerror: (error) => { - logger.warn({ err: error }, "relay_e2ee_error"); - emitter.emit("error", error); - }, - }); - clearTimeout(handshakeTimeout); - const encryptedSocket = createEncryptedRelaySocket({ - channel, - emitter, - getTransportBufferedAmount: () => socket.bufferedAmount, - terminateTransport: () => socket.terminate(), - }); - await attachSocket(encryptedSocket, metadata); - attached = true; - for (const message of pendingMessages) { - emitter.emit("message", message); - } - pendingMessages.length = 0; - } catch (error) { - logger.warn({ err: error }, "relay_e2ee_handshake_failed"); - try { - socket.close(1011, "E2EE handshake failed"); - } catch { - // ignore - } - } finally { - clearTimeout(handshakeTimeout); - } -} - -function createRelayTransportAdapter( - socket: RelayWebSocketLike, - logger: pino.Logger, -): RelayTransport { - const relayTransport: RelayTransport = { - send: (data) => - new Promise((resolve, reject) => { - try { - socket.send(data, (error) => { - if (!error) { - resolve(); - return; - } - logger.warn({ err: error }, "relay_socket_send_failed"); - reject(error); - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logger.warn({ err }, "relay_socket_send_failed"); - reject(err); - } - }), - close: (code?: number, reason?: string) => socket.close(code, reason), - onmessage: null, - onclose: null, - onerror: null, - }; - - socket.on("message", (data, isBinary) => { - const binary = isBinary === true; - relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); - }); - socket.on("close", (code, reason) => { - const closeCode = typeof code === "number" ? code : 1006; - relayTransport.onclose?.(closeCode, String(reason ?? "")); - }); - socket.on("error", (err) => { - relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); - }); - - return relayTransport; -} - -function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { - if (!isBinary) { - if (typeof data === "string") return data; - const buffer = bufferFromWsData(data); - if (buffer) return buffer.toString("utf8"); - return String(data); - } - - if (data instanceof ArrayBuffer) return data; - - const buffer = bufferFromWsData(data); - if (buffer) { - const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - - return String(data); -} - -function bufferFromWsData(data: unknown): Buffer | null { - if (Buffer.isBuffer(data)) return data; - if (Array.isArray(data)) { - const buffers: Buffer[] = []; - for (const part of data) { - if (Buffer.isBuffer(part)) { - buffers.push(part); - } else if (part instanceof ArrayBuffer) { - buffers.push(Buffer.from(part)); - } else if (ArrayBuffer.isView(part)) { - buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); - } else if (typeof part === "string") { - buffers.push(Buffer.from(part, "utf8")); - } else { - return null; - } - } - return Buffer.concat(buffers); - } - if (data instanceof ArrayBuffer) return Buffer.from(data); - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } - return null; -} diff --git a/packages/server/src/server/session-admission-auth.ts b/packages/server/src/server/session-admission-auth.ts index b4195b8d3f..8c523c44a6 100644 --- a/packages/server/src/server/session-admission-auth.ts +++ b/packages/server/src/server/session-admission-auth.ts @@ -12,17 +12,12 @@ export async function resolveSessionAdmission(input: { credential: WSHelloMessage["auth"]; passwordHash: string | undefined; localCredential: string | null; - transport: "direct" | "relay"; }): Promise { - const { credential, passwordHash, localCredential, transport } = input; + const { credential, passwordHash, localCredential } = input; if (!passwordHash) { return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; } if (!credential) { - // COMPAT(relayPasswordOptional): added in v0.9.1, remove once release N mobile builds are live on App Store and Play. - if (transport === "relay") { - return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; - } return { rejection: "password_required" }; } if (credential.kind === "localCredential") { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 7d881286fb..2fc05d0131 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -289,6 +293,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + workspaceRuntimeStore?: WorkspaceRuntimeStore; sendToSource?: SessionOptions["onMessageToSource"]; clientId?: string; permissions?: readonly DaemonPermission[]; @@ -302,7 +307,6 @@ interface SessionForTestOptions { getSnapshot?: ReturnType; suggestBranchesForCwd?: ReturnType; listStashes?: ReturnType; - peekSnapshot?: ReturnType; validateBranchRef?: ReturnType; hasLocalBranch?: ReturnType; resolveRepoRemoteUrl?: ReturnType; @@ -353,7 +357,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn(), listStashes: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn(), hasLocalBranch: vi.fn(), resolveRepoRemoteUrl: vi.fn(), @@ -369,6 +373,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -3898,11 +3903,11 @@ describe("session checkout refresh handling", () => { describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3938,6 +3943,7 @@ describe("session checkout status handling", () => { test("returns fresh service data on the first checkout status read for a cwd", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/cold-worktree", { @@ -3950,9 +3956,8 @@ describe("session checkout status handling", () => { }, }), ), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3960,7 +3965,7 @@ describe("session checkout status handling", () => { requestId: "request-cold-status", }); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); expect(messages).toContainEqual({ type: "checkout_status_response", @@ -4000,15 +4005,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { - list: vi.fn().mockResolvedValue([project]), - get: vi.fn().mockResolvedValue(project), - }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/app", { git: { remoteUrl: "https://github.com/acme/app.git", @@ -4018,6 +4017,16 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { + list: vi.fn().mockResolvedValue([project]), + get: vi.fn().mockResolvedValue(project), + }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4076,12 +4085,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/local", { git: { remoteUrl: null, @@ -4091,6 +4097,13 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4127,16 +4140,19 @@ describe("session workspace descriptors", () => { }); }); - test("reads descriptor diff stat from the workspace git service snapshot", async () => { - const workspaceGitService = { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + test("reads descriptor diff stat from the workspace runtime store", async () => { + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/tmp/workspace", { git: { diffStat: { additions: 7, deletions: 2 } }, }), ), }; - const session = createSessionForTest({ workspaceGitService }); + const workspaceGitService = { + getSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({ additions: 99, deletions: 88, @@ -4158,7 +4174,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled(); expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled(); @@ -4166,11 +4182,11 @@ describe("session workspace descriptors", () => { }); test("does not cold-load git data while describing a workspace", async () => { + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); const descriptor = await asSessionInternals(session).describeWorkspaceRecordWithGitData( { @@ -4188,7 +4204,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(descriptor.diffStat).toBeNull(); expect(descriptor.gitRuntime).toBeUndefined(); @@ -4198,14 +4214,15 @@ describe("session workspace descriptors", () => { describe("session branch validation", () => { test("validates branches through the workspace git service", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi .fn() .mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "validate_branch_request", @@ -4243,12 +4260,17 @@ describe("session branch validation", () => { execSync("git tag v1", { cwd: repoDir }); const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + workspaceGitService, + messages, + }); await session.handleMessage({ type: "validate_branch_request", @@ -4323,11 +4345,11 @@ describe("session checkout switch branch handling", () => { describe("session checkout rename branch handling", () => { test("rejects invalid branch slugs without renaming", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout.rename_branch.request", @@ -4356,11 +4378,11 @@ describe("session checkout rename branch handling", () => { test("reports null current branch when branch rename fails", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); await session.handleMessage({ @@ -4374,7 +4396,7 @@ describe("session checkout rename branch handling", () => { "/tmp/repo", "feature/new-name", ); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(messages).toContainEqual({ type: "checkout.rename_branch.response", @@ -4394,6 +4416,14 @@ describe("session checkout rename branch handling", () => { test("forces workspace git refresh after renaming the current branch", async () => { const messages: unknown[] = []; const github = { invalidate: vi.fn() }; + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/repo", { @@ -4403,13 +4433,13 @@ describe("session checkout rename branch handling", () => { }, }), ), - peekSnapshot: vi.fn(() => - createWorkspaceGitSnapshot("/tmp/repo", { - git: { currentBranch: "feature/old-name" }, - }), - ), }; - const session = createSessionForTest({ github, workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + github, + workspaceGitService, + messages, + }); checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ previousBranch: "feature/old-name", currentBranch: "feature/new-name", @@ -4528,12 +4558,12 @@ describe("session branch suggestions handling", () => { const branchDetails = [ { name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "branch_suggestions_request", @@ -4572,12 +4602,12 @@ describe("session stash list handling", () => { isPaseo: true, }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), listStashes: vi.fn().mockResolvedValue(entries), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "stash_list_request", @@ -4723,8 +4753,8 @@ describe("session workspace script handling", () => { remoteUrl: "https://github.com/getpaseo/paseo.git", }, }); + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => snapshot) }; const workspaceGitService = { - peekSnapshot: vi.fn(() => snapshot), getProjectSlug: vi.fn().mockResolvedValue("paseo"), }; const workspaceRegistry = { @@ -4738,6 +4768,7 @@ describe("session workspace script handling", () => { terminalId: "terminal-1", }); const session = createSessionForTest({ + workspaceRuntimeStore, workspaceGitService, workspaceRegistry, terminalManager: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 221d3148c8..9a2b2c0820 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; @@ -477,6 +478,7 @@ export interface SessionOptions { // defaults to the real checkout-git implementation. renameCurrentBranch?: typeof renameCurrentBranchDefault; workspaceGitService: WorkspaceGitService; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { @@ -747,6 +749,7 @@ export class Session { private readonly github: ForgeService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; @@ -850,6 +853,7 @@ export class Session { github, renameCurrentBranch, workspaceGitService, + workspaceRuntimeStore, workspaceAutoName, daemonConfigStore, pluginRuntime, @@ -930,6 +934,7 @@ export class Session { this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; + this.workspaceRuntimeStore = workspaceRuntimeStore; this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -1202,7 +1207,7 @@ export class Session { terminalManager: this.terminalManager, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, - workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, @@ -1223,6 +1228,7 @@ export class Session { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), + workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), @@ -2141,7 +2147,7 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); const checkout = checkoutFromPersistedWorkspacePlacement({ workspace, // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. @@ -5354,7 +5360,7 @@ export class Session { if (!record || record.internal) { return null; } - return { record, lastMessage: null }; + return { record, lastMessage: await this.agentManager.getLastAssistantMessage(agentId) }; } private async getAgentPayloadById(agentId: string): Promise { @@ -5611,7 +5617,7 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (snapshot?.git.diffStat) { diffStat = snapshot.git.diffStat; } @@ -5644,6 +5650,7 @@ export class Session { status: "done", statusEnteredAt: null, activityAt: null, + createdAt: workspace.createdAt, diffStat, scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord @@ -5687,7 +5694,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (!snapshot) { return base; } @@ -5740,6 +5747,8 @@ export class Session { status: "done", statusEnteredAt: result.workspace.createdAt, activityAt: null, + createdAt: result.workspace.createdAt, + lastUserMessageAt: null, diffStat: { additions: 0, deletions: 0 }, scripts: [], gitRuntime: { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index c836e6db5d..f13b90d2c8 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -135,7 +136,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { workspaces: Map>; workspaceGitService: WorkspaceGitService & { registerWorkspace: ReturnType; - peekSnapshot: ReturnType; + getSnapshot: ReturnType; refresh: ReturnType; requestWorkingTreeWatch: ReturnType; @@ -177,7 +178,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { unsubscribe, }; }), - peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ @@ -189,6 +190,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 079dadc26f..2a5402a0c3 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -79,7 +80,6 @@ function createHarness(input: { mainRepoRoot: null, }; }, - peekSnapshot: () => null, }); const emitted: SessionOutboundMessage[] = []; @@ -93,6 +93,7 @@ function createHarness(input: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 71759d33bf..e4a0ca7668 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -48,7 +52,12 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; -import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; +import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import type pino from "pino"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { ForgeService } from "../services/forge-service.js"; @@ -184,10 +193,10 @@ interface SessionTestAccess { killTerminal(id: string): unknown; clearTerminalAttention?(id: string): Promise; } | null; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceGitService: { getCheckout: (cwd: string) => Promise; getSnapshot: (cwd: string, options?: unknown) => Promise; - peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null; registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void }; }; filesystem: { @@ -559,6 +568,7 @@ function createSessionForWorkspaceTests( appVersion?: string | null; onMessage?: (message: SessionOutboundMessage) => void; onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"]; + workspaceRuntimeStore?: WorkspaceRuntimeStore; workspaceGitService?: ReturnType; terminalManager?: TerminalManager | null; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; @@ -588,6 +598,7 @@ function createSessionForWorkspaceTests( listAgents: () => [], listProviderSubagentActivity: () => [], getAgent: () => null, + getLastAssistantMessage: async () => null, archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), archiveSnapshot: async () => ({}), unarchiveSnapshot: async () => true, @@ -647,6 +658,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1010,6 +1022,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1166,6 +1179,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1306,6 +1320,7 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1449,7 +1464,6 @@ test("agent_update placement does not refresh git snapshots", async () => { const workspaceGitService = { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }; const session = asTestSession( createSessionForWorkspaceTests({ @@ -1516,7 +1530,6 @@ test("agent_update emits remove when the agent has no workspaceId", async () => workspaceGitService: { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }, }), ); @@ -1578,6 +1591,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2063,6 +2077,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2234,6 +2249,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2396,6 +2412,7 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2606,7 +2623,7 @@ test("workspace placements preserve checkout facts independently from the projec (workspace) => workspace.workspaceId === workspaceId, ) ?? null; session.projectRegistry.get = async () => project; - session.workspaceGitService.peekSnapshot = (cwd: string) => + session.workspaceRuntimeStore.get = (cwd: string) => cwd === paseoSubdirectory.cwd ? createWorkspaceRuntimeSnapshot(cwd, { git: { repoRoot: "/tmp/paseo-worktree" }, @@ -3654,6 +3671,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -4624,7 +4642,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the isPaseoOwnedWorktree: false, mainRepoRoot: null, }); - session.workspaceGitService.peekSnapshot = () => peeked.value; + session.workspaceRuntimeStore.get = () => peeked.value; session.workspaceGitService.registerWorkspace = ( _params, incomingListener: (snapshot: WorkspaceGitRuntimeSnapshot) => void, @@ -6403,7 +6421,9 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); expect(describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); - expect(descriptors).toEqual([baselineDescriptor]); + expect(descriptors).toEqual([ + { ...baselineDescriptor, createdAt: workspace.createdAt, lastUserMessageAt: null }, + ]); }); test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => { @@ -6428,7 +6448,7 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; - session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD); + session.workspaceRuntimeStore.get = () => createWorkspaceRuntimeSnapshot(REPO_CWD); const descriptors = Array.from( (await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(), @@ -6982,9 +7002,10 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git }, }, }); - const peekSnapshotRuntimeFetch = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForFetch = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeFetch; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForFetch; workspaceGitService.registerWorkspace = vi.fn(() => ({ unsubscribe: () => {}, })); @@ -6992,6 +7013,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, }), ); const project = createPersistedProjectRecord({ @@ -7041,7 +7063,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git | { type: "fetch_workspaces_response"; payload: Record } | undefined; - expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForFetch).toHaveBeenCalledWith(REPO_CWD); expect(response?.payload.entries).toEqual([ expect.objectContaining({ id: "ws-runtime-fetch", @@ -7146,13 +7168,15 @@ test("workspace_update includes updated runtime fields", async () => { }, }, }); - const peekSnapshotRuntimeUpdate = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForUpdate = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeUpdate; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForUpdate; const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, onMessage: (message) => emitted.push(message), }), ); @@ -7199,7 +7223,7 @@ test("workspace_update includes updated runtime fields", async () => { await session.emitWorkspaceUpdateForCwd(REPO_CWD); - expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForUpdate).toHaveBeenCalledWith(REPO_CWD); expect(emitted).toContainEqual({ type: "workspace_update", payload: { @@ -9692,3 +9716,212 @@ test.each(["failed", "blocked"] as const)( expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]); }, ); + +// Diagnostic reproduction: PR status on workspace descriptors must survive a +// session teardown (client reconnect after the 90s grace, or any routed-session +// disconnect) when the daemon already observed it. +test("workspace PR status survives session cleanup and reconnect", async () => { + const cwd = path.resolve("/tmp/repro-pr-repo"); + const project = createPersistedProjectRecord({ + projectId: "proj-repro", + rootPath: cwd, + kind: "git", + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-repro", + projectId: project.projectId, + cwd, + kind: "worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (id) => (id === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [workspace], + get: async (id) => (id === workspace.workspaceId ? workspace : null), + update: async (id, updater) => (id === workspace.workspaceId ? updater(workspace) : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + + const checkoutStatus: CheckoutStatusGit = { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "feature", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + }; + const facts: CheckoutSnapshotFacts = { + isGit: true, + worktreeRoot: cwd, + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + absoluteGitDir: path.join(cwd, ".git"), + gitCommonDir: path.join(cwd, ".git"), + paseoWorktree: { isPaseoOwnedWorktree: false }, + storedBaseRef: null, + resolvedBaseRef: "main", + mainRepoRoot: null, + comparisonBaseRef: null, + branchRemoteName: "origin", + branchMergeRef: "refs/heads/feature", + pullRequestLookupTarget: { headRef: "feature" }, + }; + const githubStub = { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ + items: [], + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + getPullRequest: async () => { + throw new Error("unused"); + }, + getPullRequestHeadRef: async () => "feature", + getPullRequestCheckoutTarget: async () => { + throw new Error("unused"); + }, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ url: "https://github.com/acme/repo/pull/1", number: 1 }), + mergePullRequest: async () => ({ success: true }), + isAuthenticated: async () => true, + authProbeCanThrow: true, + invalidate: () => {}, + } as unknown as ForgeService; + const gitService = new WorkspaceGitServiceImpl({ + logger: createTestLogger() as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + deps: { + subscribe: async () => ({ updateIgnore: async () => {}, unsubscribe: async () => {} }), + getCheckoutSnapshotFacts: async () => facts, + getCheckoutRefDerivedState: async (_cwd, f, current) => ({ + ...current, + upstreamStatus: f.upstreamStatus, + }), + getCheckoutStatus: async () => checkoutStatus, + getCheckoutShortstat: async () => ({ additions: 1, deletions: 0 }), + getCheckoutWorktreeState: async () => ({ + isDirty: false, + diffStat: { additions: 1, deletions: 0 }, + }), + getPullRequestStatus: async () => ({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Merged feature", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + checksStatus: "success", + }, + authState: "authenticated", + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + forgeOverrides: { github: githubStub }, + resolveAbsoluteGitDir: async () => path.join(cwd, ".git"), + hasOriginRemote: async () => false, + runGitFetch: async () => ({ changes: [], error: null }), + runGitCommand: async () => ({ + stdout: `${cwd}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }), + getWorkspaceGitSelfHealPhaseMs: () => 30_000, + createWatcherLivenessCanary: () => ({ + path: "", + filterEvents: (events) => events, + verify: async () => {}, + }), + now: () => new Date(), + } as never, + }); + + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + gitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + + const emitted1: SessionOutboundMessage[] = []; + const session1 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted1.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session1.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted1, "rpc_error")).toEqual([]); + const prUrlFrom = (m: SessionOutboundMessage) => + m.type === "workspace_update" && m.payload.kind === "upsert" + ? (m.payload.workspace.githubRuntime?.pullRequest?.url ?? null) + : null; + await vi.waitFor( + () => { + expect(emitted1.map(prUrlFrom)).toContain("https://github.com/acme/repo/pull/123"); + }, + { timeout: 5_000 }, + ); + expect(workspaceRuntimeStore.get(cwd)?.forge.pullRequest?.url).toBe( + "https://github.com/acme/repo/pull/123", + ); + + // Client disconnect past the reconnect grace (or any routed-session drop). + await session1.cleanup(); + + const emitted2: SessionOutboundMessage[] = []; + const session2 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted2.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session2.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-2", + subscribe: { subscriptionId: "sub-2" }, + }); + const response = filterByType(emitted2, "fetch_workspaces_response")[0]; + const entry = response?.payload.entries.find((e) => e.id === workspace.workspaceId); + const reconnectEntryPrUrl = entry?.githubRuntime?.pullRequest?.url; + try { + // Expected: the daemon already knew this PR; the reconnecting client should get it back + // in the directory listing instead of a descriptor with no githubRuntime at all. + expect(entry?.githubRuntime).toBeDefined(); + expect(reconnectEntryPrUrl).toBe("https://github.com/acme/repo/pull/123"); + } finally { + await session2.cleanup(); + await gitService.dispose(); + } +}); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 95caafc58b..fbe1a06415 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -24,6 +24,7 @@ export interface DaemonRuntimeConfig { publicEndpoint: string; useTls: boolean; publicUseTls: boolean; + requireProtectedSecurity?: boolean; } | null; } @@ -224,6 +225,7 @@ export class DaemonSession { relayPublicEndpoint: relay?.publicEndpoint, relayUseTls: relay?.useTls, relayPublicUseTls: relay?.publicUseTls, + relayRequireProtectedSecurity: relay?.requireProtectedSecurity ?? false, appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl, includeQr: true, logger: this.logger, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 2692250b49..0a3b8cb257 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -53,7 +53,6 @@ let provisioning: WorkspaceProvisioningService; function gitService() { return createNoopWorkspaceGitService({ - peekSnapshot: () => null, getSnapshot: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); if (!restoredMergedChangeRequestUrl) return snapshot; @@ -242,7 +241,6 @@ test("persists manual worktree ownership separately from its workspace kind", as projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -330,7 +328,6 @@ test("reopening archived exact-root records restores the fresh Git project", asy projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -886,7 +883,6 @@ test("a failed import keeps an archived worktree's placement when its directory logger, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { const status = await getCheckoutStatus(cwd, { logger }); return checkoutLiteFromGitSnapshot( @@ -960,7 +956,6 @@ test("a directory that goes away while the git read is in flight keeps its place isDirectory, logger, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { rmSync(repo, { recursive: true, force: true }); return { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 6dbcabdda6..3ac91a834a 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -103,7 +103,7 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick; isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index aa366a3495..18ae1f1366 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -22,11 +22,11 @@ import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; -// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, +// The production module reads only WorkspaceRuntimeStore.get, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected // spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and // runtime store are the real in-memory implementations, and spawning is injected so no process runs. @@ -51,7 +51,7 @@ function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick; + runtimeStore?: Pick; automationError?: Error; } @@ -106,7 +106,7 @@ function buildService(options: BuildOptions = {}) { options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), projectRegistry: fakeProjectRegistry(options.project ?? null), - workspaceGitService: options.gitService ?? fakeGitService(), + workspaceRuntimeStore: options.runtimeStore ?? fakeRuntimeStore(), getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, @@ -204,7 +204,7 @@ describe("buildSnapshot", () => { workspace, project, serviceProxy, - gitService: { peekSnapshot: () => undefined }, + runtimeStore: { get: () => null }, }); expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index d449a48855..103c3ed16b 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import type pino from "pino"; import type { ScriptStatusUpdateMessage, @@ -10,7 +11,6 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, @@ -51,15 +51,13 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; - export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; projectRegistry: Pick; - workspaceGitService: WorkspaceScriptsGitSource; + workspaceRuntimeStore: Pick; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; @@ -78,7 +76,7 @@ export function createWorkspaceScriptsService(deps: { terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, @@ -95,7 +93,7 @@ export function createWorkspaceScriptsService(deps: { workspace: PersistedWorkspaceRecord, project: { projectId: string; rootPath: string } | null, ) { - const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = workspaceRuntimeStore.get(workspace.cwd); const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; if (project) { return { diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 4af5e45c5b..2917d0fec9 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -104,6 +105,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/test-utils/finish-subscription-daemon.ts b/packages/server/src/server/test-utils/finish-subscription-daemon.ts new file mode 100644 index 0000000000..d35bc55ec8 --- /dev/null +++ b/packages/server/src/server/test-utils/finish-subscription-daemon.ts @@ -0,0 +1,40 @@ +import pino from "pino"; +// Isolated process fixture for restart/termination tests. Never targets a developer home. +import { appendFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestPaseoDaemon } from "./paseo-daemon.js"; +import { createTestAgentClients } from "./fake-agent-client.js"; + +const root = process.argv[2]; +if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent"); +await mkdir(join(root, "static"), { recursive: true }); +const held = Promise.withResolvers(); +const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: root, + staticDir: join(root, "static"), + logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))), + cleanup: false, + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) { + void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`); + } + }, + beforeTurnComplete: (prompt) => + String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(), + }), +}); +process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir }); +process.on("message", (message) => { + if (message === "finish") held.resolve(); + if (message === "stop") { + void daemon.close().then( + () => process.exit(0), + (error) => { + console.error(error); + process.exit(1); + }, + ); + } +}); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 91b125328c..289d556bec 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -30,6 +30,7 @@ interface TestPaseoDaemonOptions { relayEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + relayRequireProtectedSecurity?: boolean; daemonStatusRpcCapability?: boolean; relayConfigCapability?: boolean; agentClients?: Partial>; @@ -200,6 +201,7 @@ async function prepareTestDaemonConfig( relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", relayUseTls: options.relayUseTls, relayPublicUseTls: options.relayPublicUseTls, + relayRequireProtectedSecurity: options.relayRequireProtectedSecurity, appBaseUrl: "https://app.paseo.sh", auth: options.auth, pushNotificationSender: options.pushNotificationSender, diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index 53b8a44c1e..0027173ab7 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -38,7 +38,7 @@ export function createNoopWorkspaceGitService( onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, + getCheckout: async (cwd: string) => ({ cwd, isGit: false, diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index df0b02ad43..313d82ff7b 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createServer, type Server as HTTPServer } from "node:http"; import type { AddressInfo } from "node:net"; @@ -306,6 +307,7 @@ function createVoiceAssistantWebSocketServer(params: { null, { allowedOrigins: new Set(["*"]) }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 8ff692d580..c16a079628 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -117,6 +118,7 @@ function createServer(agentManagerOverrides?: Record) { null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index a3c821abd1..2d48a93c87 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -285,6 +286,7 @@ function createServer(options?: { null, { allowedOrigins: new Set(), startPaused: options?.startPaused }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), options?.auth, speechReadiness ? { @@ -539,7 +541,7 @@ describe("relay external socket reconnect behavior", () => { await server.close(); } }); - test("admits a hello password and an old relay hello, but rejects a wrong password", async () => { + test("admits a relay hello password, but rejects a wrong or missing password", async () => { const server = createServer({ auth: { password: "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW", @@ -561,7 +563,8 @@ describe("relay external socket reconnect behavior", () => { const legacySocket = new MockSocket(); await server.attachExternalSocket(legacySocket, { transport: "relay" }); legacySocket.emit("message", JSON.stringify(createHelloMessage("relay-legacy"))); - await vi.waitFor(() => expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(1)); + await vi.waitFor(() => expect(legacySocket.readyState).toBe(3)); + expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(0); const wrongSocket = new MockSocket(); await server.attachExternalSocket(wrongSocket, { transport: "relay" }); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 3f77a0482e..e22e5bf9a7 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -148,6 +149,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, terminalManager, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 214fc8c9ce..88fb2c327d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDelivery, type AgentMessageDelivery, @@ -111,6 +112,7 @@ import { } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -132,6 +134,8 @@ export interface ExternalSocketMetadata { transport: "relay" | "hub"; externalSessionKey?: string; relayConnectionId?: string; + /** Negotiated endpoint security for relay-carried sockets. */ + securityMode?: SecurityMode; hubDaemonId?: string; } @@ -159,6 +163,7 @@ interface WebSocketConnectionIdentity { userAgent?: string; remoteAddress?: string; relayConnectionId?: string; + securityMode?: SecurityMode; hubDaemonId?: string; clientId?: string; sessionId?: string; @@ -241,7 +246,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, getCheckout: async (cwd: string) => ({ cwd, isGit: false, @@ -649,6 +653,7 @@ export class VoiceAssistantWebSocketServer { mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, workspaceAutoName: WorkspaceAutoName, + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore, auth?: DaemonAuthConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, @@ -1606,6 +1611,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager: this.checkoutDiffManager, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, pluginRuntime: this.pluginRuntime, @@ -1810,7 +1816,6 @@ export class VoiceAssistantWebSocketServer { credential: message.auth, passwordHash: this.passwordHash, localCredential: this.credentialSource?.localCredential?.() ?? null, - transport: pending.identity.transport === "relay" ? "relay" : "direct", }); if (this.pendingConnections.get(ws) !== pending) return false; if ("rejection" in resolved) { @@ -1941,6 +1946,8 @@ export class VoiceAssistantWebSocketServer { features: { connectedHosts: true, hostMultiplexing: true, + // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. + relayProtectedSecurity: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, @@ -1955,6 +1962,7 @@ export class VoiceAssistantWebSocketServer { // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, + workspaceSortTimes: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. @@ -2982,6 +2990,7 @@ function createWebSocketConnectionIdentity( ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), ...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}), ...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}), + ...(metadata?.securityMode ? { securityMode: metadata.securityMode } : {}), ...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}), }; } @@ -3000,6 +3009,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record = []; private resolveSend: (() => void) | null = null; - setState(state: "open"): void { - expect(state).toBe("open"); - } - send(data: string | ArrayBuffer): Promise { this.sent.push(data); return new Promise((resolve) => { diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index 3b76a49711..f11162761c 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -2,7 +2,6 @@ import { EventEmitter } from "node:events"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; export interface EncryptedRelayChannel { - setState: (state: "open") => void; send: (data: string | ArrayBuffer) => Promise; outboundWireByteLength: (data: string | ArrayBuffer) => number; close: (code?: number, reason?: string) => void; @@ -27,8 +26,6 @@ export function createEncryptedRelaySocket(params: { const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; let readyState = 1; - channel.setState("open"); - const terminate = () => { if (readyState === 3) return; readyState = 3; diff --git a/packages/server/src/server/websocket/secure-relay-ingress.test.ts b/packages/server/src/server/websocket/secure-relay-ingress.test.ts new file mode 100644 index 0000000000..d6bae93889 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.test.ts @@ -0,0 +1,272 @@ +import { afterEach, expect, test, vi } from "vitest"; +import pino from "pino"; +import { + createClientChannel, + exportPublicKey, + generateKeyPair, + openClientSecureChannel, +} from "@getpaseo/relay/e2ee"; +import type { Transport } from "@getpaseo/relay/e2ee"; +import { admitSecuredRelaySocket, type SecuredRelaySocket } from "./secure-relay-ingress.js"; + +type Listener = (...args: unknown[]) => void; + +class FakeIngressSocket { + readyState = 1; + bufferedAmount = 0; + terminateCalls = 0; + closes: Array<{ code?: number; reason?: string }> = []; + deferSendCompletion = false; + onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null; + private pendingSendCallbacks: Array<(error?: Error) => void> = []; + private listeners = new Map(); + + on(event: "message" | "close" | "error", listener: Listener) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); + } + + send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) { + this.onSend?.(data); + if (!callback) return; + if (this.deferSendCompletion) { + this.pendingSendCallbacks.push(callback); + return; + } + callback(); + } + + completeNextSend() { + this.pendingSendCallbacks.shift()?.(); + } + + close(code?: number, reason?: string) { + this.closes.push({ code, reason }); + this.readyState = 3; + } + + terminate() { + this.terminateCalls += 1; + this.readyState = 3; + this.emit("close", 1006, ""); + } + + message(data: string | ArrayBuffer) { + this.emit("message", data, data instanceof ArrayBuffer); + } + + private emit(event: string, ...args: unknown[]) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } +} + +const logger = pino({ level: "silent" }); +const allowLegacy = { requireProtectedMode: () => false }; + +/** Wires a client secure channel to the fake socket so both ends talk directly. */ +async function openClientAgainst( + socket: FakeIngressSocket, + daemonPublicKeyB64: string, + events: { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void } = {}, +) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return openClientSecureChannel(clientTransport, { daemonPublicKeyB64, events }); +} + +/** An older client that only speaks the legacy construction. */ +async function openLegacyClientAgainst(socket: FakeIngressSocket, daemonPublicKeyB64: string) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return createClientChannel(clientTransport, daemonPublicKeyB64); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +test("terminates a relay socket whose peer never completes the handshake", async () => { + vi.useFakeTimers(); + const socket = new FakeIngressSocket(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair: generateKeyPair(), logger, ...allowLegacy }, + async () => { + attachments += 1; + }, + ); + await vi.advanceTimersByTimeAsync(15_000); + await admitted; + expect(socket.terminateCalls).toBe(1); + expect(attachments).toBe(0); +}); + +test("admits the secured socket with its negotiated security mode", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured).not.toBeNull(); + expect(secured!.securityMode).toBe("protected"); + expect(secured!.socket.readyState).toBe(1); +}); + +test("admits a legacy client while protected mode is not required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured!.securityMode).toBe("legacy"); +}); + +test("rejects a legacy client with a policy close when protected mode is required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => true }, + async () => { + attachments += 1; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes).toEqual([{ code: 1008, reason: "protected endpoint security required" }]); +}); + +test("reads the policy when the hello arrives, not when the socket was accepted", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let required = false; + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => required }, + async () => { + attachments += 1; + }, + ); + required = true; + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes[0]?.code).toBe(1008); +}); + +test("delivers records received before admission completes, in order, after attach", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let releaseAttach: (() => void) | undefined; + const attachGate = new Promise((resolve) => { + releaseAttach = resolve; + }); + const received: unknown[] = []; + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + next.socket.on("message", (data) => received.push(data)); + await attachGate; + }, + ); + let clientOpen: () => void = () => undefined; + const opened = new Promise((resolve) => { + clientOpen = resolve; + }); + const client = await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => clientOpen(), + }); + await opened; + await vi.waitFor(() => expect(secured).not.toBeNull()); + await client.send("first"); + await client.send("second"); + expect(received).toEqual([]); + releaseAttach?.(); + await admitted; + expect(received).toEqual(["first", "second"]); +}); + +test("encrypted sends wait for the physical socket send callback", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + socket.deferSendCompletion = true; + let resolveAttached: ((secured: SecuredRelaySocket) => void) | undefined; + const attached = new Promise((resolve) => { + resolveAttached = resolve; + }); + void admitSecuredRelaySocket(socket, { daemonKeyPair, logger, ...allowLegacy }, async (next) => + resolveAttached?.(next), + ); + let resolveClientOpen: (() => void) | undefined; + const clientOpen = new Promise((resolve) => { + resolveClientOpen = resolve; + }); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => resolveClientOpen?.(), + }); + + let attachedCompleted = false; + void attached.then(() => { + attachedCompleted = true; + return undefined; + }); + await clientOpen; + await Promise.resolve(); + expect(attachedCompleted).toBe(false); + socket.completeNextSend(); + const { socket: encryptedSocket } = await attached; + + let completed = false; + const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + + socket.completeNextSend(); + await sending; + expect(completed).toBe(true); +}); diff --git a/packages/server/src/server/websocket/secure-relay-ingress.ts b/packages/server/src/server/websocket/secure-relay-ingress.ts new file mode 100644 index 0000000000..013e3e8e03 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.ts @@ -0,0 +1,210 @@ +/// +/** + * Endpoint security boundary for relay-carried daemon sessions. + * + * A relay data socket arrives here as plaintext bytes. This module runs the + * shared secure channel over it and hands the secured socket to session + * admission, together with the negotiated security mode. Relay routing lives in + * `relay-transport.ts`; credentials and authorization live in admission. + */ +import { EventEmitter } from "node:events"; +import type pino from "pino"; +import { + openDaemonSecureChannel, + SecureChannelRejectedError, + type KeyPair, + type SecurityMode, + type Transport as RelayTransport, +} from "@getpaseo/relay/e2ee"; +import { createEncryptedRelaySocket, type EncryptedRelaySocket } from "./encrypted-relay-socket.js"; + +const HANDSHAKE_TIMEOUT_MS = 15_000; + +export interface RelayIngressSocket { + readyState: number; + bufferedAmount?: number; + send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; + close: (code?: number, reason?: string) => void; + terminate: () => void; + on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; +} + +export interface SecuredRelaySocket { + socket: EncryptedRelaySocket; + securityMode: SecurityMode; +} + +export interface SecureRelayIngressOptions { + daemonKeyPair: KeyPair; + logger: pino.Logger; + /** + * Current policy, read when the peer's hello arrives so a flag flipped at + * runtime applies to every admission from that moment on. + */ + requireProtectedMode: () => boolean; +} + +/** + * Secures `socket` and admits it through `attach`. Messages the peer sends + * before `attach` resolves are delivered once it has. + * + * A peer that never completes the handshake is terminated after + * {@link HANDSHAKE_TIMEOUT_MS}; a failed handshake closes the socket. + */ +export async function admitSecuredRelaySocket( + socket: RelayIngressSocket, + options: SecureRelayIngressOptions, + attach: (secured: SecuredRelaySocket) => Promise, +): Promise { + const { logger } = options; + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, HANDSHAKE_TIMEOUT_MS); + try { + const relayTransport = createRelayTransportAdapter(socket, logger); + const emitter = new EventEmitter(); + const pendingMessages: Array = []; + let attached = false; + const emitMessage = (data: string | ArrayBuffer) => { + if (attached) { + emitter.emit("message", data); + return; + } + pendingMessages.push(data); + }; + const channel = await openDaemonSecureChannel(relayTransport, { + daemonKeyPair: options.daemonKeyPair, + minimumMode: () => (options.requireProtectedMode() ? "protected" : "legacy"), + events: { + onmessage: emitMessage, + onclose: (code, reason) => emitter.emit("close", code, reason), + onerror: (error) => { + logger.warn({ err: error }, "relay_e2ee_error"); + emitter.emit("error", error); + }, + }, + }); + clearTimeout(handshakeTimeout); + const encryptedSocket = createEncryptedRelaySocket({ + channel, + emitter, + getTransportBufferedAmount: () => socket.bufferedAmount, + terminateTransport: () => socket.terminate(), + }); + await attach({ socket: encryptedSocket, securityMode: channel.securityMode() }); + attached = true; + for (const message of pendingMessages) { + emitter.emit("message", message); + } + pendingMessages.length = 0; + } catch (error) { + if (error instanceof SecureChannelRejectedError) { + logger.warn({ reason: error.message }, "relay_e2ee_handshake_rejected"); + try { + socket.close(error.closeCode, error.closeReason); + } catch { + // ignore + } + return; + } + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + try { + socket.close(1011, "E2EE handshake failed"); + } catch { + // ignore + } + } finally { + clearTimeout(handshakeTimeout); + } +} + +function createRelayTransportAdapter( + socket: RelayIngressSocket, + logger: pino.Logger, +): RelayTransport { + const relayTransport: RelayTransport = { + send: (data) => + new Promise((resolve, reject) => { + try { + socket.send(data, (error) => { + if (!error) { + resolve(); + return; + } + logger.warn({ err: error }, "relay_socket_send_failed"); + reject(error); + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err }, "relay_socket_send_failed"); + reject(err); + } + }), + close: (code?: number, reason?: string) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + socket.on("message", (data, isBinary) => { + const binary = isBinary === true; + relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); + }); + socket.on("close", (code, reason) => { + const closeCode = typeof code === "number" ? code : 1006; + relayTransport.onclose?.(closeCode, String(reason ?? "")); + }); + socket.on("error", (err) => { + relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + + return relayTransport; +} + +function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (!isBinary) { + if (typeof data === "string") return data; + const buffer = bufferFromWsData(data); + if (buffer) return buffer.toString("utf8"); + return String(data); + } + + if (data instanceof ArrayBuffer) return data; + + const buffer = bufferFromWsData(data); + if (buffer) { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + + return String(data); +} + +function bufferFromWsData(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) { + const buffers: Buffer[] = []; + for (const part of data) { + if (Buffer.isBuffer(part)) { + buffers.push(part); + } else if (part instanceof ArrayBuffer) { + buffers.push(Buffer.from(part)); + } else if (ArrayBuffer.isView(part)) { + buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); + } else if (typeof part === "string") { + buffers.push(Buffer.from(part, "utf8")); + } else { + return null; + } + } + return Buffer.concat(buffers); + } + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index c6896c3fb0..00faf1f441 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -214,6 +215,7 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -262,9 +264,7 @@ function createSessionForWireCompatTest(options?: { async listStashes() { return []; }, - peekSnapshot() { - return null; - }, + async validateBranchRef() { return { ok: false, error: "not found" }; }, diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index e317b5427d..be59c3a24a 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -1,3 +1,8 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent/agent-storage.js"; +import { AgentDirectory } from "./agent/directory.js"; import { describe, expect, test } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -70,6 +75,16 @@ class WorkspaceStatus { projectRegistry: { list: async () => [this.project] }, workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, + workspaceLastUserMessageTimes: async () => { + const times = new Map(); + for (const agent of this.agents) { + if (!agent.workspaceId || !agent.lastUserMessageAt) continue; + const previous = times.get(agent.workspaceId); + if (!previous || agent.lastUserMessageAt > previous) + times.set(agent.workspaceId, agent.lastUserMessageAt); + } + return times; + }, listProviderSubagentActivity: async () => this.providerSubagents, listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, @@ -242,6 +257,8 @@ class WorkspaceStatus { interface AgentState { id: string; status: AgentSnapshotPayload["status"]; + lastUserMessageAt?: string | null; + archivedAt?: string | null; pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; @@ -261,7 +278,7 @@ function createAgent( effectiveThinkingOptionId: null, createdAt: NOW, updatedAt: NOW, - lastUserMessageAt: null, + lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status, capabilities: { supportsStreaming: true, @@ -289,7 +306,7 @@ function createAgent( requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: null, - archivedAt: null, + archivedAt: input.archivedAt ?? null, } satisfies AgentSnapshotPayload; } @@ -560,6 +577,7 @@ describe("WorkspaceDirectory empty projects", () => { projectRegistry: { list: async () => input.projects }, workspaceRegistry: { list: async () => input.workspaces }, listAgentPayloads: async () => [], + workspaceLastUserMessageTimes: async () => new Map(), listProviderSubagentActivity: async () => [], listTerminalActivityContributions: async () => [], isProviderVisibleToClient: () => true, @@ -723,6 +741,7 @@ test("Git observation targets exclude archived records without hydrating app des ], }, listAgentPayloads: unexpectedHydration, + workspaceLastUserMessageTimes: unexpectedHydration, listProviderSubagentActivity: unexpectedHydration, listTerminalActivityContributions: unexpectedHydration, buildWorkspaceDescriptor: unexpectedHydration, @@ -732,3 +751,70 @@ test("Git observation targets exclude archived records without hydrating app des { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, ]); }); + +test("workspace sort times retain the latest human prompt after its agent is archived", async () => { + const scenario = new WorkspaceStatus(); + scenario.hasRootAgent({ + id: "older", + status: "idle", + lastUserMessageAt: "2026-03-03T12:00:00.000Z", + }); + scenario.hasRootAgent({ + id: "archived", + status: "idle", + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + archivedAt: "2026-03-05T12:00:00.000Z", + }); + scenario.hasRootAgent({ id: "output-only", status: "running" }); + expect(await scenario.workspaceDescriptor()).toMatchObject({ + createdAt: NOW, + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + status: "running", + }); +}); + +test("prompt recency reads archived records without projecting agents or exposing internal prompts", async () => { + const home = await mkdtemp(join(tmpdir(), "workspace-prompt-times-")); + const storage = new AgentStorage(home, createTestLogger()); + try { + const base = { + provider: "unavailable", + cwd: home, + workspaceId: "w", + createdAt: NOW, + updatedAt: NOW, + labels: {}, + lastStatus: "closed" as const, + }; + await storage.upsert({ + ...base, + id: "archived", + archivedAt: NOW, + lastUserMessageAt: "2026-03-04T00:00:00.000Z", + }); + await storage.upsert({ + ...base, + id: "internal", + internal: true, + lastUserMessageAt: "2026-03-05T00:00:00.000Z", + }); + await storage.upsert({ ...base, id: "output", lastActivityAt: "2026-03-06T00:00:00.000Z" }); + const unexpectedProjection = (): never => { + throw new Error("Prompt sorting projected an agent payload"); + }; + const directory = new AgentDirectory({ + storage, + manager: { listAgents: () => [] }, + workspaces: { get: async () => null, list: async () => [] }, + projectLive: unexpectedProjection, + projectStored: unexpectedProjection, + isProviderVisible: () => false, + isStoredProviderAvailable: () => false, + }); + expect(await directory.workspaceLastUserMessageTimes()).toEqual( + new Map([["w", "2026-03-04T00:00:00.000Z"]]), + ); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8ccb4fa341..50bc58a06d 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -77,6 +77,7 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; listAgentPayloads(): Promise; + workspaceLastUserMessageTimes(): Promise>; listProviderSubagentActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> @@ -218,12 +219,14 @@ export class WorkspaceDirectory { }): Promise> { const [ agents, + lastUserMessageTimes, providerSubagentActivity, persistedWorkspaces, persistedProjects, terminalContributions, ] = await Promise.all([ this.deps.listAgentPayloads(), + this.deps.workspaceLastUserMessageTimes(), this.deps.listProviderSubagentActivity(), this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), @@ -258,6 +261,8 @@ export class WorkspaceDirectory { const workspaceId = includedWorkspaces[i].workspaceId; descriptorsByWorkspaceId.set(workspaceId, { ...workspaceDescriptors[i], + createdAt: includedWorkspaces[i].createdAt, + lastUserMessageAt: lastUserMessageTimes.get(workspaceId) ?? null, archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null, }); } diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 8ac1ef0018..a8a1f11dfb 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -204,7 +205,10 @@ test("recursive observation updates tracked state and prunes ignored storms", as runGitCommand, } as never, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: path.join(tempDir, "paseo-home"), workspaceGitService: service, @@ -229,7 +233,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as await vi.waitFor( () => { expect(activeWatcherCount).toBe(2); - expect(service.peekSnapshot(repoDir)).not.toBeNull(); + expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull(); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8285304570..f91a96e464 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type pino from "pino"; @@ -379,11 +380,13 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -442,7 +445,10 @@ describe("WorkspaceGitService checkout observation", () => { getPullRequestStatus, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -509,10 +515,12 @@ describe("WorkspaceGitService checkout observation", () => { }, ); const service = createService(watcher, { getCheckoutDiff }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); @@ -963,12 +971,14 @@ describe("WorkspaceGitService checkout observation", () => { return { changes: [], nonRemoteRefsChanged: false, error: null }; }), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; // Observation setup also reads facts. Wait for the initial snapshot to be // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.currentBranch).toBe("feature"); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); @@ -1233,7 +1243,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1288,7 +1301,10 @@ describe("WorkspaceGitService checkout observation", () => { diffStat: { additions: 4, deletions: 2 }, })); const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1518,6 +1534,8 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, getPullRequestStatus, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { @@ -1529,7 +1547,7 @@ describe("WorkspaceGitService checkout observation", () => { expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(7_000); @@ -1677,10 +1695,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 60_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(false); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(false); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, @@ -1691,7 +1711,7 @@ describe("WorkspaceGitService checkout observation", () => { isGit = true; await vi.advanceTimersByTimeAsync(5_000); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(true); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(true); expect(service.getMetrics()).toMatchObject({ repositoryTargetCount: 1, repositoryWorkspaceLinkCount: 1, @@ -1728,10 +1748,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 1_000_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); @@ -2743,7 +2765,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -2757,7 +2782,7 @@ describe("WorkspaceGitService checkout observation", () => { ); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); isDirty = true; diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index ce052cdd7c..9a3599740c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -566,19 +567,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(getCheckoutStatus).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); await vi.waitFor(() => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(listener.mock.lastCall?.[0] ?? null).toEqual(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); @@ -703,7 +704,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); await refresh; - expect(service.peekSnapshot(REPO_CWD)).toEqual( + expect(await service.getSnapshot(REPO_CWD)).toEqual( createSnapshot(REPO_CWD, { git: { currentBranch: "feature", @@ -1385,23 +1386,25 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); try { - await service.getSnapshot(REPO_CWD); + workspaceRuntimeStore.set(await service.getSnapshot(REPO_CWD)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); headSha = "2222222222222222222222222222222222222222"; nowMs = 3_000; await service.refresh(REPO_CWD); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull(); expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(0); await flushPromises(); expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); subscription.unsubscribe(); } finally { service.dispose(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index a1d6b392e7..4b0b13b7a2 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import os from "node:os"; import path, { join } from "node:path"; @@ -334,6 +335,8 @@ describe("WorkspaceGitServiceImpl", () => { test("registerWorkspace returns a subscription without an initial snapshot contract", async () => { const service = createService(); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const listener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); @@ -341,34 +344,124 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect("initial" in subscription).toBe(false); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).toBeNull(); subscription.unsubscribe(); service.dispose(); }); - test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => { + test("shared watches publish to the runtime store before listeners and retain data after teardown", async () => { + const subscribe = vi.fn(async () => createAsyncSubscription()); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ subscribe, getPullRequestStatus }); + const store = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => store.set(snapshot)); + const observed: WorkspaceGitRuntimeSnapshot[] = []; + const listener = (snapshot: WorkspaceGitRuntimeSnapshot) => { + expect(store.get(REPO_CWD)).toBe(snapshot); + observed.push(snapshot); + }; + const first = service.registerWorkspace({ cwd: REPO_CWD }, listener); + const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, (snapshot) => + listener(snapshot), + ); + await service.getSnapshot(REPO_CWD, { force: true, reason: "runtime-store" }); + await flushPromises(); + expect(observed.length).toBeGreaterThan(0); + expect(service.getMetrics().workspaceTargetCount).toBe(1); + const subscriptionCount = subscribe.mock.calls.length; + const snapshot = store.get(REPO_CWD); + expect(snapshot?.forge.pullRequest).not.toBeNull(); + first.unsubscribe(); + expect(service.getMetrics().workspaceListenerCount).toBe(1); + second.unsubscribe(); + expect(service.getMetrics().workspaceTargetCount).toBe(0); + const pollCount = getPullRequestStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(360_000); + expect(getPullRequestStatus).toHaveBeenCalledTimes(pollCount); + expect(subscribe).toHaveBeenCalledTimes(subscriptionCount); + expect(store.get(REPO_CWD)).toBe(snapshot); + await service.dispose(); + }); + + test("onSnapshotUpdated delivers without workspace subscribers and can unsubscribe", async () => { const service = createService(); - const snapshotListener = vi.fn(); - const snapshotSubscription = service.onSnapshotUpdated(snapshotListener); + const snapshots: WorkspaceGitRuntimeSnapshot[] = []; + const subscription = service.onSnapshotUpdated((snapshot) => snapshots.push(snapshot)); + try { + const initial = await service.getSnapshot(REPO_CWD); + expect(snapshots).toEqual([initial]); + expect(service.getMetrics().workspaceListenerCount).toBe(0); - await service.getSnapshot(REPO_CWD, { force: true, reason: "unobserved" }); + await service.getSnapshot(REPO_CWD); + expect(snapshots).toEqual([initial]); - expect(snapshotListener).not.toHaveBeenCalled(); + const refreshed = await service.getSnapshot(REPO_CWD, { + force: true, + reason: "unobserved-refresh", + }); + expect(snapshots).toEqual([initial, refreshed]); - const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await service.getSnapshot(REPO_CWD, { force: true, reason: "observed" }); + subscription.unsubscribe(); + await service.getSnapshot(REPO_CWD, { force: true, reason: "after-unsubscribe" }); + expect(snapshots).toEqual([initial, refreshed]); + } finally { + await service.dispose(); + } + }); - expect(snapshotListener).toHaveBeenCalledTimes(1); - expect(snapshotListener).toHaveBeenCalledWith(createSnapshot(REPO_CWD)); + test("a retired target's late refresh cannot overwrite the runtime store's newer snapshot", async () => { + const retiredRead = createDeferred(); + const retiredReadStarted = createDeferred(); + const merged = createPullRequestStatusResult(); + if (!merged.status) throw new Error("Expected pull request status"); + merged.status = { ...merged.status, state: "merged", isMerged: true }; + let reads = 0; + const service = new WorkspaceGitServiceImpl({ + logger: createLogger() as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + deps: { + ...buildDefaultTestServiceDeps(), + getPullRequestStatus: async () => { + reads += 1; + if (reads === 1) { + retiredReadStarted.resolve(); + return retiredRead.promise; + } + return merged; + }, + }, + }); + const store = createWorkspaceRuntimeStore(); + const published: WorkspaceGitRuntimeSnapshot[] = []; + service.onSnapshotUpdated((snapshot) => { + store.set(snapshot); + published.push(snapshot); + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + const oldRefresh = service.getSnapshot(REPO_CWD, { force: true, reason: "retiring-target" }); + try { + await retiredReadStarted.promise; + subscription.unsubscribe(); + expect(service.getMetrics().workspaceTargetCount).toBe(0); - snapshotSubscription.unsubscribe(); - await service.getSnapshot(REPO_CWD, { force: true, reason: "after-unsubscribe" }); + const latest = await service.getSnapshot(REPO_CWD, { + force: true, + reason: "replacement-target", + }); + expect(latest.forge.pullRequest).toMatchObject({ state: "merged", isMerged: true }); + expect(store.get(REPO_CWD)).toBe(latest); - expect(snapshotListener).toHaveBeenCalledTimes(1); - - workspaceSubscription.unsubscribe(); - service.dispose(); + retiredRead.resolve(createPullRequestStatusResult()); + const retired = await oldRefresh; + expect(retired.forge.pullRequest).toMatchObject({ state: "open", isMerged: false }); + expect(store.get(REPO_CWD)).toBe(latest); + expect(published).toEqual([latest]); + } finally { + retiredRead.resolve(createPullRequestStatusResult()); + await oldRefresh; + await service.dispose(); + } }); test("getSnapshot populates github pull request state in the runtime snapshot", async () => { @@ -516,7 +609,6 @@ describe("WorkspaceGitServiceImpl", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); @@ -834,13 +926,15 @@ describe("WorkspaceGitServiceImpl", () => { resolveAbsoluteGitDir, now: () => new Date(nowMs), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual( createSnapshot(REPO_CWD), ); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(workspaceRuntimeStore.get(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); nowMs += 3_000; await service.refresh(REPO_CWD); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index be62376130..dcd2ead214 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -208,8 +208,8 @@ export interface WorkspaceGitService { listener: WorkspaceGitListener, ): WorkspaceGitSubscription; + /** Publishes computed snapshot changes independently of workspace UI subscriptions. */ onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getCheckout(cwd: string): Promise; getSnapshot( cwd: string, @@ -335,7 +335,6 @@ interface WorkspaceGitRefreshRequest { includeForge: boolean; emitUnchanged?: boolean; reason: string; - notify: boolean; queueIfBusy: boolean; movedRemoteRefs: Set; } @@ -748,7 +747,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); cwd = resolve(cwd); - const request = this.normalizeRefreshRequest(options, "getSnapshot", true); + const request = this.normalizeRefreshRequest(options, "getSnapshot"); const target = this.ensureWorkspaceTarget(cwd); if (!request.force && target.latestSnapshot) { return target.latestSnapshot; @@ -765,7 +764,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }); - const cached = this.peekSnapshot(normalizedCwd); + const cached = this.getCachedSnapshot(normalizedCwd); if (cached && cached.git.isGit !== status.isGit) { // A fresh checkout read discovered a repository transition. Keep the // shared snapshot current before exposing the new project capability. @@ -795,7 +794,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { + private getCachedSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { cwd = resolve(cwd); return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } @@ -975,7 +974,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: false, reason: "refresh", - notify: true, queueIfBusy: false, movedRemoteRefs: new Set(), }); @@ -1240,7 +1238,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: true, reason: "initial", - notify: true, queueIfBusy: false, movedRemoteRefs: new Set(), }); @@ -1632,7 +1629,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: false, reason: "working-tree-watch-fallback", - notify: true, queueIfBusy: true, movedRemoteRefs: new Set(), }); @@ -2692,7 +2688,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { includeForge: false, emitUnchanged: true, reason: "git-metadata-watch-fallback", - notify: true, queueIfBusy: true, movedRemoteRefs: new Set(), }); @@ -2825,9 +2820,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.rememberForgePrStatusSnapshot( target, buildForgeSnapshotFromStatus(status, resolution.forge), - { - notify: true, - }, ); }, onError: (error) => { @@ -2902,9 +2894,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!closed && this.isActiveObservedWorkspaceTarget(target)) { latestStatus = status; consecutiveErrors = 0; - this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), { - notify: true, - }); + this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge)); } } catch (error) { consecutiveErrors += 1; @@ -3080,7 +3070,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private normalizeRefreshRequest( options: WorkspaceGitSnapshotOptions | undefined, defaultReason: string, - notify: boolean, ): WorkspaceGitRefreshRequest { if (options?.force && !options.reason) { throw new Error("WorkspaceGitService.getSnapshot force refresh requires a reason"); @@ -3093,7 +3082,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: options?.includeForge ?? true, reason: options?.reason ?? defaultReason, - notify, queueIfBusy: false, movedRemoteRefs: new Set(), }; @@ -3122,7 +3110,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { includeForge: options?.includeForge ?? false, emitUnchanged: options?.emitUnchanged, reason: options?.reason ?? "watch", - notify: true, queueIfBusy: options?.queueIfBusy ?? true, movedRemoteRefs: new Set(options?.movedRemoteRefs), }; @@ -3161,7 +3148,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { upgradesMovedRefs ? request.reason : pending.reason, - notify: pending.notify || request.notify, queueIfBusy: pending.queueIfBusy || request.queueIfBusy, movedRemoteRefs, }; @@ -3182,7 +3168,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.latestSnapshot ) { this.rememberSnapshot(target, target.latestSnapshot, { - notify: request.notify, forceEmit: true, }); } @@ -3199,7 +3184,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } snapshot = admittedSnapshot; this.rememberSnapshot(target, snapshot, { - notify: request.notify, forceEmit: request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0), }); @@ -3486,7 +3470,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private rememberForgePrStatusSnapshot( target: WorkspaceGitTarget, github: WorkspaceGitRuntimeSnapshot["forge"], - options?: { notify?: boolean }, ): void { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; @@ -3495,7 +3478,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.latestForge = github; target.latestForgeLoadedAtMs = this.deps.now().getTime(); this.rememberSnapshot(target, this.combineSnapshot(target), { - notify: options?.notify, forceEmit: false, }); } @@ -3503,8 +3485,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, - options?: { forceEmit?: boolean; notify?: boolean }, + options?: { forceEmit?: boolean }, ): void { + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } target.latestSnapshot = snapshot; if (target.listeners.size > 0) { this.updateForgePrStatusPollForTarget(target); @@ -3515,12 +3500,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.latestFingerprint = fingerprint; - if (!options?.notify || target.listeners.size === 0) { - return; - } - for (const listener of target.listeners) { - listener(snapshot); - } for (const listener of this.snapshotUpdatedListeners) { try { listener(snapshot); @@ -3531,6 +3510,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } } + for (const listener of target.listeners) { + listener(snapshot); + } } private async runRepoFetch(target: RepoGitTarget): Promise { diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 04ffc1d7ac..6c27027edc 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -13,6 +13,7 @@ import { FileBackedWorkspaceRegistry, resolveWorkspaceDisplayName, resolveWorkspaceName, + type WorkspaceMutation, } from "./workspace-registry.js"; describe("resolveWorkspaceName", () => { @@ -430,6 +431,10 @@ describe("workspace registries", () => { test("creates, updates, archives, deletes, and lists workspace records", async () => { await workspaceRegistry.initialize(); + const mutations: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((mutation) => { + mutations.push(mutation); + }); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ workspaceId: "/tmp/repo", @@ -460,6 +465,11 @@ describe("workspace registries", () => { expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z"); await workspaceRegistry.remove("/tmp/repo"); + expect(mutations.at(-1)).toEqual({ + kind: "remove", + workspaceId: "/tmp/repo", + workspace: archived, + }); expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 417af353fe..0e0fb4e62c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -585,7 +585,7 @@ export class FileBackedWorkspaceRegistry override async remove(workspaceId: string): Promise { const workspace = await this.removeIfPresent(workspaceId); if (!workspace) return; - await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); + await this.notifyMutation({ kind: "remove", workspaceId, workspace }); } async commitWorkspaceLabelMutation(input: { diff --git a/packages/server/src/server/workspace-runtime-store/index.test.ts b/packages/server/src/server/workspace-runtime-store/index.test.ts new file mode 100644 index 0000000000..2e83328107 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path, { resolve } from "node:path"; +import pino from "pino"; +import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; +import { expect, test } from "vitest"; +import { createNoGitWorkspaceRuntimeSnapshot } from "../test-utils/workspace-git-service-stub.js"; +import { createWorkspaceRuntimeStore } from "./index.js"; + +test("retains, replaces, and removes snapshots by resolved workspace directory", () => { + const store = createWorkspaceRuntimeStore(); + const cwd = "./workspace"; + const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); + expect(store.get(cwd)).toBeNull(); + store.set(snapshot); + expect(store.get(resolve(cwd))).toBe(snapshot); + const replacement = createNoGitWorkspaceRuntimeSnapshot(cwd); + store.set(replacement); + expect(store.get(cwd)).toBe(replacement); + store.remove(resolve(cwd)); + expect(store.get(cwd)).toBeNull(); +}); + +test("retains refreshed Git changes without a workspace subscription", async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-runtime-store-"))); + const repo = path.join(root, "repo"); + let now = Date.now(); + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { now: () => new Date(now) }, + }); + const store = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => store.set(snapshot)); + try { + execFileSync("git", ["init", "-q", "-b", "main", repo]); + const git = (...args: string[]) => execFileSync("git", args, { cwd: repo, stdio: "pipe" }); + writeFileSync(path.join(repo, "README.md"), "# Temp\n"); + git("add", "README.md"); + git( + "-c", + "user.name=T", + "-c", + "user.email=t@localhost", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "init", + ); + execFileSync("git", ["init", "-q", "--bare", path.join(root, "remote.git")]); + git("remote", "add", "origin", path.join(root, "remote.git")); + git("push", "-q", "-u", "origin", "main"); + + writeFileSync(path.join(repo, "README.md"), "# Temp\none\ntwo\n"); + await service.refresh(repo); + expect(service.getMetrics().workspaceListenerCount).toBe(0); + expect((await service.getSnapshot(repo)).git.diffStat).toEqual({ additions: 2, deletions: 0 }); + expect(store.get(repo)?.git.diffStat).toEqual({ additions: 2, deletions: 0 }); + + now += 3_000; + writeFileSync(path.join(repo, "README.md"), "# Temp\none\ntwo\nthree\n"); + await service.refresh(repo); + expect(store.get(repo)?.git.diffStat).toEqual({ additions: 3, deletions: 0 }); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-runtime-store/index.ts b/packages/server/src/server/workspace-runtime-store/index.ts new file mode 100644 index 0000000000..778939bf93 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.ts @@ -0,0 +1,23 @@ +import { resolve } from "node:path"; +import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; + +export interface WorkspaceRuntimeStore { + get(cwd: string): WorkspaceGitRuntimeSnapshot | null; + set(snapshot: WorkspaceGitRuntimeSnapshot): void; + remove(cwd: string): void; +} + +export function createWorkspaceRuntimeStore(): WorkspaceRuntimeStore { + const snapshots = new Map(); + return { + get(cwd) { + return snapshots.get(resolve(cwd)) ?? null; + }, + set(snapshot) { + snapshots.set(resolve(snapshot.cwd), snapshot); + }, + remove(cwd) { + snapshots.delete(resolve(cwd)); + }, + }; +} diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0ef41b3b6f..3cc5c3dace 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -14,7 +14,7 @@ import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; import pino from "pino"; -import { base64EncryptedWireByteLength } from "@getpaseo/relay"; +import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, @@ -1589,19 +1589,15 @@ const x = 1; it("warms shortstat cache in the background without blocking listing callers", async () => { expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); - warmCheckoutShortstatInBackground(repoDir); + const warmed = new Promise((resolve) => { + warmCheckoutShortstatInBackground(repoDir, undefined, resolve); + }); + // The warm hands off to git and returns; listing callers see no value yet. + expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); + await warmed; // A repo with no origin/main computes to null, but null should still be cached. - for (let attempts = 0; attempts < 20; attempts += 1) { - const cached = getCachedCheckoutShortstat(repoDir); - if (cached !== undefined) { - expect(cached).toBeNull(); - return; - } - await sleep(25); - } - - throw new Error("shortstat background warm did not populate cache in time"); + expect(getCachedCheckoutShortstat(repoDir)).toBeNull(); }); it("commits messages with quotes safely", async () => { @@ -1658,9 +1654,14 @@ const x = 1; CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes, ); - expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208); + // The cap is sized for the tighter of the legacy and protected relay framings. + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe( + maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes, + ); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000); expect(diffWireBytes).toBeLessThan(relayFrameBytes); - expect(maximumFrameWireBytes).toBe(relayFrameBytes); + expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes); }); it("marks tracked generated one-line diffs as too_large by content size", async () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f2001e4651..fbbb494b4a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -7,7 +7,7 @@ import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; -import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { @@ -2162,7 +2162,7 @@ const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { diff --git a/packages/website/package.json b/packages/website/package.json index 8f91671e25..1fb8b0087c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "type": "module", "scripts": { diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index a80b84abd8..ebb29f0237 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -19,7 +19,9 @@ export default defineConfig({ }, { command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", - url: baseURL, + // Probe the server without running the homepage loader's live GitHub requests. + url: new URL("/logo.svg", baseURL).href, + stdout: "pipe", env: { CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 0ba1cdbc7f..7e34e35e5c 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -194,6 +194,10 @@ "enabled": { "type": "boolean" }, + "requireProtectedSecurity": { + "description": "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + "type": "boolean" + }, "endpoint": { "type": "string" }, diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index b11eb4bf51..765e1692c6 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -1,10 +1,12 @@ +import { z } from "zod"; import { PluginFileConflictError, PluginFileDeletedError, type PluginFilePanelProps, useFileText, + useTabState, } from "@getpaseo/plugin/client"; -import { Loading } from "@getpaseo/plugin/client/ui"; +import { Loading, Markdown } from "@getpaseo/plugin/client/ui"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Pressable, ScrollView, Text, View } from "react-native"; import { type MarkdownEditorHandle, mountEditor } from "./web"; @@ -17,7 +19,7 @@ type EditorState = "loading" | "ready" | "conflict" | "deleted"; * Reads the file once, writes 500 ms after the last edit, and follows the disk while nothing is * unsaved. With unsaved edits, a change on disk is a conflict: Reload or Overwrite. */ -function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { +function MarkdownEditor({ file, tab: { setModified }, theme }: PluginFilePanelProps) { const parent = useRef(null); const editor = useRef(null); const text = useRef(""); @@ -34,11 +36,11 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { return; } unsaved.current = null; - tab.setModified(false); + setModified(false); editor.current?.setText(text.current); setError(null); setState("ready"); - }, [file, tab]); + }, [file, setModified]); const save = useCallback( async (force: boolean) => { @@ -54,19 +56,19 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { } if (unsaved.current === next) { unsaved.current = null; - tab.setModified(false); + setModified(false); } setError(null); setState("ready"); }, - [file, tab], + [file, setModified], ); useLayoutEffect(() => { editor.current = mountEditor(parent.current, text.current, theme, (next) => { text.current = next; unsaved.current = next; - tab.setModified(true); + setModified(true); clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => void save(false), SAVE_DELAY_MS); }); @@ -74,7 +76,7 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { editor.current?.destroy(); editor.current = null; }; - }, [save, tab, theme]); + }, [save, setModified, theme]); useEffect(() => { void load(); @@ -166,9 +168,63 @@ function MarkdownText({ file, theme }: PluginFilePanelProps) { ); } +const viewSchema = z.object({ view: z.enum(["preview", "source"]) }); +const defaultView = { view: "source" } as const; + +function MarkdownPreview({ file, theme }: PluginFilePanelProps) { + const text = useFileText(file); + const errorStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]); + if (text.status === "loading") return ; + if (text.status === "error") return {text.error.message}; + return ( + + {text.text} + + ); +} + +const toolbarStyle = { flexDirection: "row", gap: 16, padding: 12 } as const; +const selected = { selected: true }; +const unselected = { selected: false }; + export function RichMarkdownPanel(props: PluginFilePanelProps) { - if (props.layout.platform === "web") return ; - return ; + const [state, setState] = useTabState(props.tab, viewSchema, defaultView); + const styles = useMemo( + () => ({ + panel: { flex: 1, minHeight: 0, backgroundColor: props.theme.colors.surface0 }, + label: { color: props.theme.colors.foreground }, + }), + [props.theme], + ); + const showPreview = useCallback(() => setState({ view: "preview" }), [setState]); + const showSource = useCallback(() => setState({ view: "source" }), [setState]); + let content; + if (state.view === "preview") content = ; + else if (props.layout.platform === "web") content = ; + else content = ; + return ( + + + + Preview + + + Source + + + {content} + + ); } function messageOf(cause: unknown): string { diff --git a/plugins/package.json b/plugins/package.json index f71101b0c6..34c57df528 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "type": "module", "scripts": { diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 8d08343ec0..f430dff92a 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -75,6 +75,7 @@ Use this table as the complete registration checklist. | New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | | New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` | | `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` | | `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | | `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | | `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | @@ -96,6 +97,22 @@ Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerCon `@getpaseo/plugin/server`. Remove imports of the old context type. Client registrations return idempotent removal functions, except header buttons and composer pills, which return `{ update, remove }` handles. Preserve any remover the plugin calls before teardown; Paseo removes outstanding registrations after the entry cleanup runs. +### Panel props in 0.13 + +All panel kinds share [params, revision, and persisted tab state](./reference.md#workspace-panels). + +| Previous API | Replacement | +| ----------------------------------------------------------------------- | ------------------------------------------------ | +| Workspace/agent `context` prop | Select the kind at registration. | +| Agent `agentId` prop | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta file `location.lineStart`, `location.lineEnd`, `location.revision` | `params.lineStart`, `params.lineEnd`, `revision` | +| Beta `PluginFileTab`, `PluginFileLocation` | `PluginTab`, `PluginFilePanelParams` | + +The 0.12 aliases remain available until removal after 2027-04-23. The beta file API changes +outright. Declare `requirements.paseo` as `>=0.13.0` when adopting the new API. + ### Composer pills Update the plugin project's `@getpaseo/plugin` dependency, then run `npm run typecheck`. diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index cb38831b67..600b65f4d9 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -68,6 +68,7 @@ my-plugin/ | 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` | | 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` | | 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` | +| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` | | 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` | | 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` | | 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 | @@ -85,6 +86,22 @@ my-plugin/ 从 `@getpaseo/plugin/client` 导入 `PluginClientContext`,从 `@getpaseo/plugin/server` 导入 `PluginServerContext`。删除对旧 context 类型的导入。客户端注册返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 `{ update, remove }` 句柄。保留插件在销毁之前调用的所有移除函数;入口的清理函数运行后,Paseo 会移除尚未移除的注册。 +### 0.13 的面板属性 {#panel-props-in-013} + +所有面板类型共享 [params、revision 和持久化的标签页状态](./reference.md#workspace-panels)。 + +| 旧 API | 替代方式 | +| ----------------------------------------------------------------------------- | ------------------------------------------------ | +| 工作区/Agent 面板的 `context` 属性 | 在注册时选择面板类型。 | +| Agent 面板的 `agentId` 属性 | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta 文件面板的 `location.lineStart`、`location.lineEnd`、`location.revision` | `params.lineStart`、`params.lineEnd`、`revision` | +| Beta 的 `PluginFileTab`、`PluginFileLocation` | `PluginTab`、`PluginFilePanelParams` | + +0.12 的别名会保留到 2027-04-23 之后移除。Beta 文件 API 直接替换。采用新 API 时,将 +`requirements.paseo` 声明为 `>=0.13.0`。 + ### Composer 标签 {#composer-pills} 更新插件项目的 `@getpaseo/plugin` 依赖,然后运行 `npm run typecheck`。旧的贡献缺少必需的 `button` 字段,`PluginComposerPillProps` 不再导出,并且把新的注册当作函数调用会产生 TypeScript 错误。固定在旧 SDK 上的项目仍然按旧的契约检查;安装或重新加载插件并不会运行 TypeScript 的类型检查器。 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index c35a62ca33..81b99b0dd0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1771,7 +1771,8 @@ for immediate controls, a draft editor with validation, custom content, and Comm ## Workspace panels -Register one panel for workspace or agent context: +Register workspace panels with `addWorkspacePanel` and agent panels with `addAgentPanel`. +Both receive the same panel contract as [file panels](#file-panels). `client/review.tsx`: @@ -1780,9 +1781,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1811,11 +1812,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1823,20 +1823,43 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` fields: +`addWorkspacePanel` and `addAgentPanel` fields: | Field | Required | Meaning | | ----------- | -------- | ------------------------------------------------------------- | | `id` | Yes | Plugin-local panel ID. | | `title` | Yes | Workspace-tab title. | | `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `context` | Yes | `workspace` or `agent`. | | `locations` | No | `workspace` and/or `explorer`. Defaults to `workspace`. | | `Component` | Yes | React Native component matching the selected context's props. | -A workspace panel receives `PluginWorkspacePanelProps`: `context: "workspace"`, `theme`, `host`, `layout`, and `workspaceId`. An agent panel receives `PluginAgentPanelProps`: `context: "agent"`, the same common fields and `workspaceId`, plus `agentId`. +Every panel receives `PluginPanelProps` with `theme`, `host`, `layout`, `navigation`, +`workspaceId`, read-only `params`, `revision`, and `tab`. Workspace panels have empty +`PluginWorkspacePanelParams`; agent panels have `PluginAgentPanelParams` containing `agentId`. +`revision` increments whenever params are re-sent to the same tab, including an identical request. -Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. +`tab.setModified(boolean)` marks unsaved work and asks before closing. `tab.state` is opaque +persisted JSON, initially `undefined`; `tab.setState(value)` replaces it. Each tab owns its state. +Paseo stores it without interpreting it. Validate it with `useTabState(tab, schema, defaultState)`: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +Import `useTabState` from `@getpaseo/plugin/client` and `z` from `zod`. The setter replaces the +entire JSON value. State survives tab restoration and app reloads. + +The unified props, `addAgentPanel`, and `useTabState` require Paseo 0.13.0 or later. + +**Deprecated until removal after 2027-04-23:** the 0.12 panel `context` and flat `agentId` props, +`addWorkspacePanel({ context: "workspace" })`, and `addWorkspacePanel({ context: "agent" })` +continue to work. Omit workspace `context`, register agent panels with `addAgentPanel`, and read +`params.agentId`. + +Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(params.agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. Both hooks return `null` when the record is unavailable. Otherwise they run synchronously against normalized client state. Snapshot DTOs and their nested values are deeply readonly and frozen at runtime. Do not call plugin RPC to discover the current workspace or agent. Fetch optional or vendor-specific enrichment after the component renders. @@ -1923,12 +1946,13 @@ for a filename-only selector. At least one extension or filename is required. `PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation` props described under [Theme and layout](#theme-and-layout), plus: -| Prop | Type | Meaning | -| ------------- | -------------------- | ------------------------------------------------------------------------------- | -| `workspaceId` | `string` | Workspace containing the file tab. | -| `file` | `PluginFile` | Handle to the file. Stable while the tab shows this file. | -| `tab` | `PluginFileTab` | `setModified(modified)` shows the tab's unsaved dot and confirms before close. | -| `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. | +| Prop | Type | Meaning | +| ------------- | ----------------------- | --------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Workspace containing the file tab. | +| `params` | `PluginFilePanelParams` | Read-only `path`, `lineStart?`, and `lineEnd?` from the opener. | +| `revision` | `number` | Increments each time params are re-sent to this tab, even for the same line. | +| `file` | `PluginFile` | File capability resolved from `params.path`, stable while the tab shows this file. | +| `tab` | `PluginTab` | `setModified`, opaque persisted `state`, and `setState`. See [Workspace panels](#workspace-panels). | Paseo mounts your panel once it knows the file exists and its size, without reading it. The `file` object is stable, and its stat fields hold the latest version Paseo knows. Read them while diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 9b9f443cba..62caec83e5 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1525,7 +1525,8 @@ if (current.status === "ready") { ## 工作区面板 {#workspace-panels} -为工作区或 Agent 上下文注册一个面板: +使用 `addWorkspacePanel` 注册工作区面板,使用 `addAgentPanel` 注册 Agent 面板。 +两者与[文件面板](#file-panels)使用相同的面板契约。 `client/review.tsx`: @@ -1534,9 +1535,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1565,11 +1566,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1577,20 +1577,42 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` 的字段: +`addWorkspacePanel` 和 `addAgentPanel` 的字段: | 字段 | 是否必填 | 含义 | | ----------- | -------- | --------------------------------------------------- | | `id` | 是 | 插件内部的面板 ID。 | | `title` | 是 | 工作区标签页的标题。 | | `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 | -| `context` | 是 | `workspace` 或 `agent`。 | | `locations` | 否 | `workspace` 和/或 `explorer`。默认为 `workspace`。 | | `Component` | 是 | 与所选上下文的属性相匹配的 React Native 组件。 | -工作区面板接收 `PluginWorkspacePanelProps`:`context: "workspace"`、`theme`、`host`、`layout` 和 `workspaceId`。Agent 面板接收 `PluginAgentPanelProps`:`context: "agent"`、相同的通用字段和 `workspaceId`,外加 `agentId`。 +每个面板都接收 `PluginPanelProps`,包含 `theme`、`host`、`layout`、`navigation`、 +`workspaceId`、只读的 `params`、`revision` 和 `tab`。工作区面板使用空的 +`PluginWorkspacePanelParams`;Agent 面板的 `PluginAgentPanelParams` 包含 `agentId`。 +每次向同一标签页重新发送 params 时,`revision` 都会递增,即使请求与上次相同。 -用 `useWorkspace(workspaceId, selector)` 和 `useAgent(agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 +`tab.setModified(boolean)` 标记未保存的更改,并在关闭前询问。`tab.state` 是由面板定义的持久化 +JSON,初始为 `undefined`;`tab.setState(value)` 替换整个值。每个标签页拥有独立状态。 +Paseo 只保存状态,不解释其内容。用 `useTabState(tab, schema, defaultState)` 验证它: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +从 `@getpaseo/plugin/client` 导入 `useTabState`,从 `zod` 导入 `z`。setter 替换整个 JSON 值。 +标签页恢复和应用重新加载后,状态仍然保留。 + +统一属性、`addAgentPanel` 和 `useTabState` 需要 Paseo 0.13.0 或更高版本。 + +**已弃用,将在 2027-04-23 之后移除**:0.12 面板的 `context` 和顶层 `agentId` 属性、 +`addWorkspacePanel({ context: "workspace" })` 以及 `addWorkspacePanel({ context: "agent" })` +仍然有效。省略工作区的 `context`,用 `addAgentPanel` 注册 Agent 面板,并读取 `params.agentId`。 + +用 `useWorkspace(workspaceId, selector)` 和 `useAgent(params.agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 当记录不可用时,两个 hook 都返回 `null`。否则它们会针对规范化的客户端状态同步运行。快照 DTO 及其嵌套值都是深度只读的,并在运行时被冻结。不要调用插件 RPC 来获取当前的工作区或 Agent。可选的或特定于厂商的补充数据,请在组件渲染之后再获取。 @@ -1670,12 +1692,13 @@ export default function contribute(client: PluginClientContext) { `PluginFilePanelProps` 包含[主题和布局](#theme-and-layout)中介绍的通用属性 `theme`、`host`、`layout` 和可选的 `navigation`,另外还有: -| 属性 | 类型 | 含义 | -| ------------- | -------------------- | ------------------------------------------------------------------ | -| `workspaceId` | `string` | 包含该文件标签页的工作区。 | -| `file` | `PluginFile` | 文件的句柄。标签页显示该文件期间保持不变。 | -| `tab` | `PluginFileTab` | `setModified(modified)` 会显示标签页的未保存圆点,并在关闭前确认。 | -| `location` | `PluginFileLocation` | `lineStart?`、`lineEnd?` 和 `revision`,后者在每次导航时递增。 | +| 属性 | 类型 | 含义 | +| ------------- | ----------------------- | ------------------------------------------------------------------------------------------ | +| `workspaceId` | `string` | 包含该文件标签页的工作区。 | +| `params` | `PluginFilePanelParams` | 打开请求提供的只读 `path`、`lineStart?` 和 `lineEnd?`。 | +| `revision` | `number` | 每次向该标签页重新发送 params 时递增,即使是同一行。 | +| `file` | `PluginFile` | 从 `params.path` 解析的文件能力;标签页显示该文件期间保持不变。 | +| `tab` | `PluginTab` | `setModified`、持久化的自定义 `state` 和 `setState`。参见[工作区面板](#workspace-panels)。 | Paseo 在知道文件存在及其大小后就会挂载你的面板,不会先读取文件。`file` 对象保持不变,它的 stat 字段保存 Paseo 已知的最新版本。请在渲染时读取这些字段;它们变化时不会重新渲染你的面板。面板要通过 `watch` 监听器,或 `useFileText`、`useFileBytes`、`useFileUrl` 引起的重新渲染,得知它们已变化: diff --git a/public-docs/security.md b/public-docs/security.md index d1b6ee229a..6354c55db3 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -86,7 +86,7 @@ Configure via `daemon.hostnames` in `config.json`: By default, anyone who can reach the daemon's listening address can connect. On localhost this is fine, only local processes have access. But if you bind to a network interface (e.g. your LAN IP or `0.0.0.0`), or if you don't fully trust your local network, you can require a password. -When a password is configured, all HTTP requests must include an `Authorization: Bearer ` header and all WebSocket connections must authenticate via subprotocol. Unauthenticated requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials. +When a password is configured, all HTTP requests must include an `Authorization: Bearer ` header, and every WebSocket client, including relay clients, must send the password when it connects. Relay clients send it inside the end-to-end encrypted channel, so the relay never sees it. Unauthenticated HTTP requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials. If you enable the [bundled web UI](/docs/web-ui), its static files are also served without the password so the login screen can render. This is by design, the API and WebSocket still require authentication before any agent data is returned or any command runs. Set a password before binding the daemon to a network so the data behind the page stays protected. @@ -152,7 +152,7 @@ Hub CLI credentials are bearer secrets. Remote Hub origins must use HTTPS; clear ## Recommendations - **Use the relay** for mobile access, it's the simplest option and all traffic is end-to-end encrypted -- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon +- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon unless you also set a password - **Set a password** if you bind to a network address, it prevents unauthorized clients from controlling your agents - **Never bind to 0.0.0.0 without a password**, without one, any device on your network can connect - **Scope Docker mounts tightly**, agents can access mounted workspaces and provider credentials diff --git a/public-docs/security.zh-CN.md b/public-docs/security.zh-CN.md index 4333193bf7..41ae74e07e 100644 --- a/public-docs/security.zh-CN.md +++ b/public-docs/security.zh-CN.md @@ -83,7 +83,7 @@ Paseo 使用主机白名单校验传入请求的 `Host` 请求头。主机无法 默认情况下,任何能访问 daemon 监听地址的人都可以连接。在 localhost 上这没有问题,只有本地进程能访问。但如果你绑定到某个网络接口(例如你的局域网 IP 或 `0.0.0.0`),或者你不完全信任本地网络,可以要求提供密码。 -配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer ` 请求头,所有 WebSocket 连接都必须通过子协议进行验证。未经验证的请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。 +配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer ` 请求头,所有 WebSocket 客户端(包括中继客户端)都必须在连接时发送密码。中继客户端在端到端加密通道内发送密码,中继无法看到它。未经验证的 HTTP 请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。 如果你启用了[内置的 Web UI](/docs/web-ui),它的静态文件同样无需密码即可提供,以便显示登录界面。这是有意设计的:API 和 WebSocket 在返回任何 Agent 数据或执行任何命令之前仍然需要验证。在把 daemon 绑定到网络之前请设置密码,让页面背后的数据受到保护。 @@ -149,7 +149,7 @@ Hub CLI 凭据是 bearer 密钥。远程 Hub 地址必须使用 HTTPS;只有 ## 建议 {#recommendations} - **使用中继**进行移动端访问,这是最简单的选择,且所有流量都经过端到端加密 -- **像对待密码一样对待二维码**,任何拿到配对信息的人都可以连接你的 daemon +- **像对待密码一样对待二维码**,除非你同时设置了密码,否则任何拿到配对信息的人都可以连接你的 daemon - **设置密码**,如果你绑定到网络地址,它可以阻止未经授权的客户端控制你的 Agent - **不要在没有密码的情况下绑定到 0.0.0.0**,否则你网络中的任何设备都可以连接 - **严格限定 Docker 挂载范围**,Agent 可以访问挂载的工作区和 Provider 凭据 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 7401206108..c16e772172 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -487,7 +487,7 @@ test("Windows units and integration run independently with complete routed cover assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts"', + 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/);