From f989e5e0a018361645219000e552051f19c6bbb4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 16:32:37 +0800 Subject: [PATCH 1/2] feat(plugins): expose owned native terminals with bounded reopen history --- ...026-11-02-plugin-owned-native-terminals.md | 73 ++++ docs/development/macos-plugin-api-plan.md | 4 +- macos/Resources/en.lproj/Localizable.strings | 18 + .../zh-Hans.lproj/Localizable.strings | 18 + .../Features/PluginHostServiceDispatch.swift | 4 +- .../Features/PluginTerminalHostService.swift | 333 ++++++++++++++++++ .../AppModel/AppModel+PluginTerminal.swift | 110 ++++++ .../Models/AppModel/AppModel+Terminal.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 1 + .../MacOS/Terminal/MacTerminalTransport.swift | 41 +++ .../Views/App/PluginMarketplaceView.swift | 2 + .../Ports/TerminalTransport.swift | 11 +- .../Runtime/TerminalSession.swift | 6 + .../PluginTerminalHostServiceTests.swift | 213 +++++++++++ .../PluginTerminalNativeTests.swift | 113 ++++++ .../src/lsp/interface/plugin_function.rs | 43 +++ .../src/tests/declarative_plugins.rs | 20 ++ shared/contracts/plugins/v1/README.md | 92 +++++ .../v1/fixtures/function-protocol.json | 10 +- .../v1/fixtures/terminal-backend.plugin.json | 35 ++ .../plugins/v1/plugin-manifest.schema.json | 3 +- shared/i18n/locales/en-US.json | 11 +- shared/i18n/locales/zh-CN.json | 11 +- shared/i18n/messages.json | 104 ++++++ .../features/plugin-native-terminals.json | 28 ++ 25 files changed, 1297 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md create mode 100644 macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift create mode 100644 macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift create mode 100644 macos/Tests/LitheTests/PluginTerminalNativeTests.swift create mode 100644 shared/contracts/plugins/v1/fixtures/terminal-backend.plugin.json create mode 100644 shared/platform-feature-matrix/features/plugin-native-terminals.json diff --git a/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md b/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md new file mode 100644 index 0000000000..a511752b1a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md @@ -0,0 +1,73 @@ +# Agent 笔记:插件自有原生终端与有界重开上下文 + +状态:已实现 + +## 先说结论 + +Docker 等工具插件可以请求 Lithe 在现有原生终端里启动程序,插件负责可执行程序和参数,宿主负责显示、伪终端(PTY,让命令像在普通终端里运行的系统接口)和进程清理。插件只能控制自己后端会话在指定工作区创建的终端。关闭后可用同一身份重新打开,保留插件指定长度的最近显示文本,但不重放输入,也不承诺恢复旧进程或 Shell 内存状态。 + +## 问题 + +Docker 插件需要一键进入容器,但公共 API 计划中的 API-24 只有候选名称,宿主没有对应服务。只加网页按钮不能完成工作流;把 xterm.js 和 Docker exec 流塞进插件又会重复宿主已有的终端渲染、输入、缩放、窗口和退出流程。 + +用户进一步要求关闭后重开时带上一部分上下文,长度由插件选择。这里的上下文必须说清楚:终端显示历史可以安全地作为历史展示,自动重放历史命令却可能重复删除、部署或数据写入;一个已经结束的进程也不能靠显示历史恢复变量和前台程序状态。 + +## 决策 + +### 复用原生终端,扩展现有宿主服务 + +- 以函数协议 3 的 `terminal` 宿主服务新增能力,通过 `getCapabilities` 发现,不升级协议版本。不另建通信系统,也不把交互客户端伪装成 Build/Run Provider。 +- 新权限 `terminal.create` 展开成具体方法授权,要求 `backend:2`。Web 页面调用插件自己的函数,后端拿本次调用的工作区上下文请求宿主;不从全局焦点猜窗口。 +- `PluginTerminalHostService` 负责句柄、权限之后的归属检查、参数预算和生命周期。`AppModel+PluginTerminal` 把请求适配到既有 `TerminalFeatureModel`;`MacTerminalTransport` 继续拥有实际 PTY 和渲染。 +- 启动参数保持 executable/argv 分离,cwd 是工作区目录资源;原生层校验 canonical cwd 不越界。执行前显示可取消确认,等待期间切工作区、停用插件或取消都不能继续启动。 +- 首版仅本机 macOS。远程 authority 显式拒绝,Windows 未注册 handler,也不宣传已经支持。SDK 的 Node、Java、Rust 封装保留能力发现与旧宿主不可用语义。 + +### 终端身份与资源寿命 + +每个句柄归属 pluginID、backend sessionID 和 context generation;操作其他插件、旧会话或其他工作区的句柄返回不存在。八个句柄的配额包括已经关闭但仍可重开的句柄,插件用 `dispose` 明确释放;不能无限累积历史和启动配置。 + +`close` 结束该进程会话并移除原生标签,保留启动配置及有限历史;`reopen` 先确认,再启动新的 PTY,沿用 terminalID 并增加进程 generation。退出订阅每个 generation 只发布一次,旧原生回调不能结束新进程;正在 close/reopen 时再次变更返回 busy。用户关闭标签或程序自然退出同样可以重开。 + +后端结束、插件禁用/更新和工作区关闭,释放所属句柄并通过既有有界原生清理结束其进程。独立用户终端不参与这张表。网页关闭只取消在途调用,不结束已经交付给用户的终端。 + +### 上下文只恢复显示,不恢复执行 + +- 插件以 UTF-8 字节数配置 `retainedContextBytes`,默认 16 KiB,上限 256 KiB,0 表示不保留;close 可覆盖创建时的选择。总上限受八个句柄配额约束。 +- 只在关闭/重开边界从成熟 SwiftTerm 渲染缓冲区取后缀,不在每个输出字节上维护第二份日志。先二分定位缓冲区末端,只翻译满足字节预算的末尾行,避免为少量上下文测量整篇历史。 +- 历史变成去控制字符的纯显示文本,按 Unicode scalar 边界截断;启动前喂给显示器,不能写新 PTY 的 stdin。颜色、TUI 控制状态和进程内变量不恢复。 +- 历史和启动环境只放当前宿主内存,不写 bundle、插件目录、缓存或持久状态,不影响代码签名和 Sparkle delta 基线。已由程序显示/回显的秘密可能仍在快照中,插件可选择 0;宿主不读取其他终端。 + +正确做法:Docker 插件声明明确连接和容器 ID,调用通用终端服务启动 CLI;关闭后重开显示最近输出,再获得一个新容器 Shell。另一个插件启动数据库交互客户端也用相同接口。 + +不要这样做:宿主判断工具名为 docker 时拼参数、往用户正在编辑的终端输入命令,或重开时自动执行保留输出里的命令文本。 + +## 考虑过的备选方案 + +- 插件内嵌 xterm.js 与私有 exec 传输:会重复原生终端和生命周期,且用户明确要求使用 Lithe 终端,拒绝。 +- 创建普通 Shell 后 sendText:增加 Shell quoting、提示符准备竞态及忙碌终端误注入风险,首版只直接 argv 启动。 +- 把 close 当隐藏、让进程继续运行:与终端关闭含义不符,也让插件禁用后的资源处理含糊,拒绝;如未来需要 detach,必须单独定义能力。 +- 保留原始 ANSI 流并回放:可能触发 OSC 标题/链接及设备查询,还要维护第二份输出缓存,采用成熟终端缓冲区的纯文本后缀。 +- 持久化所有历史并跨重启恢复:带来秘密持久化与进程身份恢复问题,首版限定当前后端会话和工作区内存寿命。 + +## 后果 + +工具插件能复用 Lithe 的完整原生终端,跨工具无特判,并通过 close/reopen 少做手动 Shell 操作。代价是首版没有读取终端输出、注入输入或恢复旧进程状态的公共 API;重开会重新执行原始启动 argv,必须再次确认,插件应为交互会话提供适合重复启动的入口。SDK 封装在独立仓库维护,契约副本仍须在主仓库合并后由同步工具导出,不能手改镜像。 + +## 验证 + +- `scripts/macos/test/test-macos.sh --filter 'PluginTerminalHostServiceTests|PluginTerminalNativeTests|TerminalModuleTests'`:隔离、未知字段、remote 拒绝、关闭工作区期间的在途创建、退出去重、generation、历史后缀及真实 PTY 输入不重放。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function` 和 `--lib terminal_permission`:逐方法授权与旧协议拒绝。 +- `scripts/architecture/verify-service-boundaries.sh`、`scripts/architecture/verify-shared-contracts.sh`、`scripts/macos/verify/verify-runtime-bundle-immutability.sh`。 +- `scripts/agent-notes/verify-agent-notes.sh`、`scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`。 + +原生 PTY 集成测试不替代用户在安装插件中的完整点击、滚动、窗口焦点和退出验收;功能矩阵的待验收状态按真实证据维护。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift` +- `macos/Sources/LitheTerminalModule/` +- `macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift` +- `rust/lithe-core/src/lsp/interface/plugin_function.rs` +- `shared/contracts/plugins/v1/` +- `docs/development/macos-plugin-api-plan.md` diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index e2cc45715c..fd7a307578 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -46,7 +46,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-21 | 既有能力 | 协议 2 credentials/get/set/delete;权限与插件隔离不变 | | API-22 | 部分完成 | 既有函数进度与取消;独立 output channel、通用 log/progress API 未做 | | API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放 | -| API-24 | 未做 | 插件专属终端 create/sendText/onExit 未开放 | +| API-24 | 部分完成 | 已公开插件自有原生终端 create/show/close/reopen/dispose/onExit(macOS、本机);不开放 sendText/输出读取,Windows 待接入 | | API-25 | 部分完成 | 既有语言/LSP 贡献;新增 `languages:2` 的清单与包内 JSON 语法/编辑配置,仅支持 Monarch;运行期传入 JSON 动态注册/替换高亮 Provider、独立语言语义 Provider 未做,详见下方范围说明 | | API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API | | API-27 | 部分完成 | 独立 `editor/registerActions` 绑定普通函数与文档范围,不依赖 LSP/Build/Run;通用主题、片段、其他装饰未做 | @@ -228,7 +228,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | API-21 H | `secrets.get/set/delete`;已有函数协议 v2 credentials 服务 [S2] | 保持本插件隔离和现有凭据授权;不得读取 IDE/Agent/其他插件密钥,不将 secret 放日志、普通配置或状态。先补 SDK/文档一致性,不另造密码库 | M1 | | API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用 | M1:命令结果/进度;M3:独立输出 | | API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时 | M4 | -| API-24 H/E | `terminal.create/show/sendText/onExit`;已有终端模块 [S12] | 创建与写终端都按执行授权处理;只控制自身终端,不能读用户其他会话或注入命令。禁用释放插件拥有资源,不停止用户独立终端 | 后续 | +| API-24 H/E | `terminal.create/show/close/reopen/dispose/onExit` 已复用原生终端模块 [S12];函数协议 3 宿主服务 | `terminal.create` 权限、创建逐操作确认;只控制本 backend session/context 的终端,禁用/关闭工作区清理。无 sendText 或输出读取;本机 macOS,Windows 与远程未实现 | 首版接线,原生验收见功能矩阵 | | API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;`languages:2` 声明式语法已实现(见 Agent Note 2026-10-31-plugin-editor-extension-apis);后续:新 Provider | | API-26 H/P/E | `diagnostics.createCollection/set/delete/onDidChange`;已有 Problems/诊断 owner [S13] | 只能写本插件 collection,带文档版本和稳定范围;编辑/卸载后旧诊断可撤回。不能覆盖其他 LSP 结果或伪造宿主诊断身份 | M4 | | API-27 C/H/P | 主题/图标/片段、只读装饰、CodeLens/Inlay;已有内部 theme/editor 能力 [S5/S16] | 数据贡献/资源路径校验、预算与卸载;图标/主题用宿主 token,不允许 CSS/原生视图注入。语言信息优先走 LSP,UI 资源不得执行脚本 | 独立编辑器动作 API 已接线(Mac,待运行验收;复用 backend 2/函数协议 3,不升领域版本);主题/片段/其他装饰后续 | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f23292d94a..af88bc0f7c 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -336,6 +336,7 @@ "Allow project environment and Maven configuration changes" = "Allow project environment and Maven configuration changes"; "Allow run, build, reload and stop (executes project code)" = "Allow run, build, reload and stop (executes project code)"; "Allow the Agent to continue?" = "Allow the Agent to continue?"; +"Allow “%@” to open a terminal?" = "Allow “%@” to open a terminal?"; "Allow “%@” to run build tools?" = "Allow “%@” to run build tools?"; "Always ask before making changes" = "Ask before making changes or running actions that require approval."; "Always ask to edit external files and use the internet" = "Always ask to edit external files and use the internet"; @@ -2702,6 +2703,7 @@ "Open a Java project before connecting the debugger" = "Open a Java project before connecting the debugger"; "Open a local folder to start working." = "Open a local folder to start working."; "Open a local project folder" = "Open a local project folder"; +"Open a plugin terminal" = "Open a plugin terminal"; "Open a project" = "Open a project"; "Open a project and enable a plugin that defines tools to inspect its environment." = "Open a project and enable a plugin that defines tools to inspect its environment."; "Open a project before choosing project paths." = "Open a project before choosing project paths."; @@ -2720,6 +2722,7 @@ "Open a source file or choose a project Run configuration" = "Open a source file or choose a project Run configuration"; "Open a text file instead of a folder." = "Open a text file instead of a folder."; "Open again" = "Open again"; +"Open and manage its own native terminals, retaining bounded display history for reopening" = "Open and manage its own native terminals, retaining bounded display history for reopening"; "Open file" = "Open file"; "Open file in editor to see problems" = "Open file in editor to see problems"; "Open folder" = "Open folder"; @@ -4174,6 +4177,11 @@ "The plugin package's %@ is not an executable file. Reinstall the plugin." = "The plugin package's %@ is not an executable file. Reinstall the plugin."; "The plugin repository %@ has already been added." = "The plugin repository %@ has already been added."; "The plugin server returned HTTP %@." = "The plugin server returned HTTP %@."; +"The plugin terminal does not exist." = "The plugin terminal does not exist."; +"The plugin terminal is changing state. Try again when it finishes." = "The plugin terminal is changing state. Try again when it finishes."; +"The plugin terminal is closed. Reopen it first." = "The plugin terminal is closed. Reopen it first."; +"The plugin terminal is still running." = "The plugin terminal is still running."; +"The plugin terminal was not opened." = "The plugin terminal was not opened."; "The plugin will be removed after restarting Lithe." = "The plugin will be removed after restarting Lithe."; "The plugin “%@” wants to use your camera and microphone." = "The plugin “%@” wants to use your camera and microphone."; "The plugin “%@” wants to use your camera." = "The plugin “%@” wants to use your camera."; @@ -4259,6 +4267,7 @@ "The stopped source file is no longer available: %@" = "The stopped source file is no longer available: %@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; "The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; +"The terminal is unavailable." = "The terminal is unavailable."; "The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@"; "The toolchain query could not be built." = "The toolchain query could not be built."; "The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again."; @@ -9674,6 +9683,15 @@ "outline.expandAll" = "Expand All"; "plugin.host.invalidParameters" = "The plugin host request parameters are invalid."; "plugin.host.requestFailed" = "The plugin host request could not be completed."; +"plugin.terminal.busy" = "The plugin terminal is changing state. Try again when it finishes."; +"plugin.terminal.cancelled" = "The plugin terminal was not opened."; +"plugin.terminal.closed" = "The plugin terminal is closed. Reopen it first."; +"plugin.terminal.consent" = "Allow “%1$@” to open a terminal?"; +"plugin.terminal.notFound" = "The plugin terminal does not exist."; +"plugin.terminal.open" = "Open a plugin terminal"; +"plugin.terminal.permission" = "Open and manage its own native terminals, retaining bounded display history for reopening"; +"plugin.terminal.running" = "The plugin terminal is still running."; +"plugin.terminal.unavailable" = "The terminal is unavailable."; "plugins.java.installNotice" = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; "plugins.java.installTemplates" = "Install Java Support for Java, Maven and Spring templates…"; "plugins.title" = "Plugins"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 970c7403bc..3191525b8e 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -336,6 +336,7 @@ "Allow project environment and Maven configuration changes" = "允许修改项目环境和 Maven 配置"; "Allow run, build, reload and stop (executes project code)" = "允许运行、构建、重载和停止(会执行项目代码)"; "Allow the Agent to continue?" = "允许 Agent 继续吗?"; +"Allow “%@” to open a terminal?" = "允许“%@”打开终端?"; "Allow “%@” to run build tools?" = "允许“%@”运行构建工具吗?"; "Always ask before making changes" = "修改文件或执行需要权限的操作前请求你的批准。"; "Always ask to edit external files and use the internet" = "修改工作区外文件或访问网络时先请求批准。"; @@ -2702,6 +2703,7 @@ "Open a Java project before connecting the debugger" = "连接调试器前,请先打开 Java 项目"; "Open a local folder to start working." = "打开本地文件夹开始工作。"; "Open a local project folder" = "打开本地项目文件夹"; +"Open a plugin terminal" = "打开插件终端"; "Open a project" = "打开项目"; "Open a project and enable a plugin that defines tools to inspect its environment." = "打开项目并启用定义工具的插件,即可查看环境。"; "Open a project before choosing project paths." = "请先打开项目,再选择项目路径。"; @@ -2720,6 +2722,7 @@ "Open a source file or choose a project Run configuration" = "请打开一个源文件,或选择项目的运行配置"; "Open a text file instead of a folder." = "请选择文本文件,而不是文件夹。"; "Open again" = "重新打开"; +"Open and manage its own native terminals, retaining bounded display history for reopening" = "打开并管理自己的原生终端,保留有限的显示历史用于重新打开"; "Open file" = "打开文件"; "Open file in editor to see problems" = "在编辑器中打开文件以查看问题"; "Open folder" = "打开文件夹"; @@ -4174,6 +4177,11 @@ "The plugin package's %@ is not an executable file. Reinstall the plugin." = "插件包中的 %@ 不是可执行文件。请重新安装该插件。"; "The plugin repository %@ has already been added." = "插件仓库 %@ 已添加。"; "The plugin server returned HTTP %@." = "插件服务器返回了 HTTP %@。"; +"The plugin terminal does not exist." = "插件终端不存在。"; +"The plugin terminal is changing state. Try again when it finishes." = "插件终端正在切换状态,请完成后重试。"; +"The plugin terminal is closed. Reopen it first." = "插件终端已关闭,请先重新打开。"; +"The plugin terminal is still running." = "插件终端仍在运行。"; +"The plugin terminal was not opened." = "未打开插件终端。"; "The plugin will be removed after restarting Lithe." = "插件将在重启 Lithe 后移除。"; "The plugin “%@” wants to use your camera and microphone." = "插件“%@”想使用你的摄像头和麦克风。"; "The plugin “%@” wants to use your camera." = "插件“%@”想使用你的摄像头。"; @@ -4259,6 +4267,7 @@ "The stopped source file is no longer available: %@" = "程序停住的源文件已不存在:%@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; "The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; +"The terminal is unavailable." = "终端不可用。"; "The test file is outside the current workspace: %@" = "测试文件在当前工作区之外:%@"; "The toolchain query could not be built." = "无法构建工具链查询。"; "The update could not be installed. Download the release manually and try again." = "无法安装更新,请手动下载版本后重试。"; @@ -9674,6 +9683,15 @@ "outline.expandAll" = "全部展开"; "plugin.host.invalidParameters" = "插件宿主请求的参数无效。"; "plugin.host.requestFailed" = "未能完成插件宿主请求。"; +"plugin.terminal.busy" = "插件终端正在切换状态,请完成后重试。"; +"plugin.terminal.cancelled" = "未打开插件终端。"; +"plugin.terminal.closed" = "插件终端已关闭,请先重新打开。"; +"plugin.terminal.consent" = "允许“%1$@”打开终端?"; +"plugin.terminal.notFound" = "插件终端不存在。"; +"plugin.terminal.open" = "打开插件终端"; +"plugin.terminal.permission" = "打开并管理自己的原生终端,保留有限的显示历史用于重新打开"; +"plugin.terminal.running" = "插件终端仍在运行。"; +"plugin.terminal.unavailable" = "终端不可用。"; "plugins.java.installNotice" = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; "plugins.java.installTemplates" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "plugins.title" = "插件"; diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index f52816e7dd..3b871ba992 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -312,7 +312,9 @@ final class PluginHostServiceDispatcher { } else { contextID = nil } // User-choice messages have no clock deadline; their session, context // and cancellation own dismissal. Machine operations are locally bounded. - let deadline: Task? = request.method.hasPrefix("lithe/host/window/show") ? nil : Task { @MainActor [weak self] in + let waitsForUser = request.method.hasPrefix("lithe/host/window/show") + || ["lithe/host/terminal/create", "lithe/host/terminal/reopen"].contains(request.method) + let deadline: Task? = waitsForUser ? nil : Task { @MainActor [weak self] in do { try await Task.sleep(for: .seconds(30)) } catch { return } guard let self, self.sessionIDs[pluginID] == currentSession, let pending = self.inFlight[pluginID]?.removeValue(forKey: request.id) else { return } diff --git a/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift b/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift new file mode 100644 index 0000000000..31abeedb90 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift @@ -0,0 +1,333 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// A direct argv launch in a terminal owned by one plugin and workspace. +/// Tool-specific commands stay in the plugin; the host never interprets shell text. +struct PluginTerminalLaunch: Equatable, Sendable { + let pluginID: String + let title: String + let executable: String + let arguments: [String] + let workingDirectory: URL + let environment: [String: String?] + var show: Bool + var retainedContextBytes: Int = 16 * 1024 + var displayHistory: String = "" +} + +struct PluginTerminalExit: Equatable, Sendable { + let code: Int32? + let reason: String +} + +@MainActor +protocol PluginTerminalSessionAccess: AnyObject { + var exit: PluginTerminalExit? { get } + func show() throws + func displayText(maximumBytes: Int) -> String + /// Stops the terminal's process tree, using the existing bounded native cleanup. + func close() async +} + +/// Implemented by the window's terminal owner, not by the plugin dispatcher. +@MainActor +protocol PluginTerminalWindowPort: AnyObject { + func hostCreatePluginTerminal( + _ launch: PluginTerminalLaunch, + onExit: @escaping @MainActor @Sendable (PluginTerminalExit) -> Void + ) async throws -> any PluginTerminalSessionAccess +} + +// Note: 插件原生终端的所有权与直接 argv 启动见 .agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md + +/// Function-protocol-3 terminal service. Handles are private to a backend session +/// and context; it cannot enumerate, read or write any existing user terminal. +@MainActor +final class PluginTerminalHostService: PluginHostServiceHandling { + let service = "terminal" + let methods = Set(["create", "show", "close", "reopen", "dispose", "onExit"].map { "lithe/host/terminal/" + $0 }) + static let maximumTerminals = 8 + static let maximumContextBytes = 256 * 1024 + private let contexts: PluginWorkspaceContexts + private let metadata: PluginWorkspaceHostService + private final class Entry { + let pluginID: String + let sessionID: String + let context: PluginWorkspaceContextReference + var access: (any PluginTerminalSessionAccess)? + var exit: PluginTerminalExit? + var live = true + var busy = false + var launch: PluginTerminalLaunch? + var history = "" + var generation = 1 + var runToken = UUID() + var starting = false + var subscriptions: [String: PluginHostSubscriptions] = [:] + init(_ call: PluginHostCall, context: PluginWorkspaceContextReference) { + pluginID = call.pluginID; sessionID = call.sessionID; self.context = context + } + } + private var entries: [String: Entry] = [:] + private var cleanup: [UUID: Task] = [:] + + init(contexts: PluginWorkspaceContexts, metadata: PluginWorkspaceHostService) { + self.contexts = contexts; self.metadata = metadata + } + + func answer(_ call: PluginHostCall) async -> Result { + do { + let scope = try PluginHostAPIContext(call, contexts: contexts) + let method = String(call.request.method.split(separator: "/").last ?? "") + let allowed: Set + switch method { + case "create": allowed = ["context", "title", "executable", "arguments", "cwd", "environment", "show", "retainedContextBytes"] + case "close": allowed = ["context", "terminalID", "retainedContextBytes"] + case "reopen": allowed = ["context", "terminalID", "show"] + default: allowed = ["context", "terminalID"] + } + let args = try PluginHostArguments(call.params, allowed: allowed) + if method == "create" { + return .success(try await create(call, scope: scope, args: args)) + } + let id = try args.string("terminalID") + guard let entry = entries[id], entry.live, entry.pluginID == call.pluginID, + entry.sessionID == call.sessionID, entry.context == scope.workspace.reference else { + throw PluginHostServiceError(.notFound, L10n.string("The plugin terminal does not exist.")) + } + switch method { + case "show": + guard let access = entry.access else { + throw PluginHostServiceError(.preconditionFailed, L10n.string("The plugin terminal is closed. Reopen it first.")) + } + try access.show() + return .success(.null) + case "close": + guard !entry.busy else { throw busy } + let bytes = try contextBytes(args, fallback: entry.launch?.retainedContextBytes ?? 0) + entry.launch?.retainedContextBytes = bytes + entry.busy = true + defer { entry.busy = false } + if let access = entry.access { entry.history = access.displayText(maximumBytes: bytes) } + else { entry.history = Self.suffix(entry.history, bytes: bytes) } + finish(entry, id: id, exit: .init(code: nil, reason: "closed")) + await entry.access?.close() + if let access = entry.access { entry.history = access.displayText(maximumBytes: bytes) } + entry.access = nil + return .success(.object(["retainedContextBytes": .integer(entry.history.utf8.count)])) + case "reopen": + guard !entry.busy else { throw busy } + guard entry.exit != nil, var launch = entry.launch else { + throw PluginHostServiceError(.preconditionFailed, L10n.string("The plugin terminal is still running.")) + } + guard let window = scope.window as? any PluginTerminalWindowPort else { throw PluginHostArguments.invalid } + launch.show = try args.bool("show", default: true) + entry.busy = true + defer { entry.busy = false } + if let access = entry.access { + entry.history = access.displayText(maximumBytes: launch.retainedContextBytes) + await access.close() + entry.access = nil + } + try Task.checkCancellation() + _ = try PluginHostAPIContext(call, contexts: contexts) + guard entry.live else { throw CancellationError() } + launch.displayHistory = entry.history + let oldExit = entry.exit, oldGeneration = entry.generation + entry.runToken = UUID() + let token = entry.runToken + entry.starting = true + entry.exit = nil; entry.generation += 1 + let generation = entry.generation + do { + let access = try await window.hostCreatePluginTerminal(launch) { [weak self, weak entry] exit in + guard let entry, entry.runToken == token else { return } + self?.finish(entry, id: id, exit: exit) + } + entry.access = access + do { + try Task.checkCancellation() + _ = try PluginHostAPIContext(call, contexts: contexts) + guard entry.live, entries[id] === entry else { throw CancellationError() } + } catch { await access.close(); entry.access = nil; throw error } + if let exit = access.exit { finish(entry, id: id, exit: exit) } + entry.starting = false + if let exit = entry.exit { publish(entry, id: id, exit: exit) } + return .success(.object(["terminalID": .string(id), "generation": .integer(generation), + "restoredContextBytes": .integer(entry.history.utf8.count)])) + } catch { + entry.runToken = UUID(); entry.starting = false + entry.exit = oldExit; entry.generation = oldGeneration + throw error + } + case "dispose": + guard !entry.busy else { throw busy } + entries[id] = nil; entry.live = false + finish(entry, id: id, exit: .init(code: nil, reason: "closed")) + await entry.access?.close() + for (subscription, store) in entry.subscriptions { + store.dispose(pluginID: entry.pluginID, subscriptionID: subscription) + } + entry.history = ""; entry.launch = nil; entry.access = nil + return .success(.null) + case "onExit": + guard !entry.busy else { throw busy } + guard entry.subscriptions.isEmpty else { + throw PluginHostServiceError(.limitExceeded, L10n.string("The plugin host request could not be completed.")) + } + let subscription = call.subscriptions.add(pluginID: call.pluginID, contextID: entry.context.contextID) { [weak entry] in + entry?.subscriptions.removeAll() + } + entry.subscriptions[subscription] = call.subscriptions + if let exit = entry.exit { publish(entry, id: id, exit: exit) } + return .success(.object(["subscriptionID": .string(subscription)])) + default: throw PluginHostArguments.invalid + } + } catch { return .failure(PluginHostAPIValues.failure(error)) } + } + + private func create(_ call: PluginHostCall, scope: PluginHostAPIContext, args: PluginHostArguments) async throws -> ToolingJSONValue { + guard entries.values.filter({ $0.pluginID == call.pluginID }).count < Self.maximumTerminals else { + throw PluginHostServiceError(.limitExceeded, L10n.string("The plugin host request could not be completed.")) + } + guard let window = scope.window as? any PluginTerminalWindowPort else { + throw PluginHostServiceError(.unavailable, L10n.string("The terminal is unavailable.")) + } + let launch = try decodeLaunch(call, scope: scope, args: args) + let id = UUID().uuidString + let entry = Entry(call, context: scope.workspace.reference) + entry.launch = launch + entry.busy = true + entry.starting = true + entries[id] = entry + defer { entry.busy = false } + do { + let token = entry.runToken + let access = try await window.hostCreatePluginTerminal(launch) { [weak self, weak entry] exit in + guard let entry, entry.runToken == token else { return } + self?.finish(entry, id: id, exit: exit) + } + entry.access = access + do { + try Task.checkCancellation() + _ = try PluginHostAPIContext(call, contexts: contexts) + guard entry.live, entries[id] === entry else { throw CancellationError() } + } catch { + await access.close() + throw error + } + if let exit = access.exit { finish(entry, id: id, exit: exit) } + entry.starting = false + return .object(["terminalID": .string(id), "generation": .integer(1)]) + } catch { + entries[id] = nil; entry.live = false; entry.runToken = UUID() + throw error + } + } + + private func decodeLaunch(_ call: PluginHostCall, scope: PluginHostAPIContext, args: PluginHostArguments) throws -> PluginTerminalLaunch { + func text(_ value: String, limit: Int, empty: Bool = true) throws -> String { + guard (empty || !value.isEmpty), value.utf8.count <= limit, !value.contains("\0") else { throw PluginHostArguments.invalid } + return value + } + let executable = try text(args.string("executable"), limit: 4096, empty: false) + // Relative executables can change meaning with cwd. Accept an absolute + // path or a bare command, resolved by the native terminal's PATH lookup. + guard executable.hasPrefix("/") || !executable.contains("/") && !executable.contains("\\") && !executable.hasPrefix("-") else { + throw PluginHostArguments.invalid + } + let arguments = try (args.values["arguments"] == nil ? [] : args.array("arguments")).map { value -> String in + guard case .string(let argument) = value else { throw PluginHostArguments.invalid } + return try text(argument, limit: 16_384) + } + guard arguments.count <= 256 else { throw PluginHostArguments.invalid } + let title = try text(args.string("title", default: executable), limit: 128, empty: false) + guard !title.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { throw PluginHostArguments.invalid } + var cwd = scope.snapshot.workspaceURL + if args.values["cwd"] != nil { + let resource = try PluginHostArguments(args.object("cwd"), allowed: ["rootID", "path"]) + let root = try resource.string("rootID"), path = try resource.string("path") + guard let folder = scope.workspace.folders.first(where: { metadata.rootID(of: $0, pluginID: call.pluginID) == root }) else { + throw PluginHostServiceError(.permissionDenied, L10n.string("The plugin host request parameters are invalid.")) + } + cwd = folder.localURL + if !path.isEmpty { + for component in try PluginHostResourceResolver.components(of: path) { cwd.appendPathComponent(component) } + } + } + var environment: [String: String?] = [:] + if args.values["environment"] != nil { + let values = try args.object("environment") + guard values.count <= 64 else { throw PluginHostArguments.invalid } + for (key, value) in values { + guard key.utf8.count <= 128, key.first?.isLetter == true || key.first == "_", + key != "LITHE_PROCESS_SCOPE", key != "LITHE_AGENT_SCOPE", + key.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 }) else { + throw PluginHostArguments.invalid + } + switch value { + case .null: environment[key] = .some(nil) + case .string(let value): environment[key] = try .some(text(value, limit: 16_384)) + default: throw PluginHostArguments.invalid + } + } + } + let bytes = arguments.reduce(executable.utf8.count) { $0 + $1.utf8.count } + + environment.reduce(0) { $0 + $1.key.utf8.count + ($1.value?.utf8.count ?? 0) } + guard bytes <= 64 * 1024 else { throw PluginHostArguments.invalid } + return PluginTerminalLaunch(pluginID: call.pluginID, title: title, executable: executable, arguments: arguments, + workingDirectory: cwd, environment: environment, show: try args.bool("show", default: true), + retainedContextBytes: try contextBytes(args, fallback: 16 * 1024)) + } + + private var busy: PluginHostServiceError { + PluginHostServiceError(.busy, L10n.string("The plugin terminal is changing state. Try again when it finishes.")) + } + + private func contextBytes(_ args: PluginHostArguments, fallback: Int) throws -> Int { + let count = try args.integer("retainedContextBytes", default: fallback) + guard count <= Self.maximumContextBytes else { throw PluginHostArguments.invalid } + return count + } + + private static func suffix(_ text: String, bytes: Int) -> String { + let tail = Array(text.utf8.suffix(bytes)) + let first = tail.firstIndex { $0 & 0xc0 != 0x80 } ?? tail.endIndex + return String(decoding: tail[first...], as: UTF8.self) + } + + private func finish(_ entry: Entry, id: String, exit: PluginTerminalExit) { + guard entry.exit == nil else { return } + entry.exit = exit + if !entry.starting { publish(entry, id: id, exit: exit) } + } + + private func publish(_ entry: Entry, id: String, exit: PluginTerminalExit) { + for (subscription, store) in entry.subscriptions { + store.notify(pluginID: entry.pluginID, subscriptionID: subscription, event: [ + "type": .string("exit"), "terminalID": .string(id), "reason": .string(exit.reason), + "generation": .integer(entry.generation), + "exitCode": exit.code.map { .integer(Int($0)) } ?? .null, + ]) + } + } + + func endSession(pluginID: String, sessionID: String) { + remove { $0.pluginID == pluginID && $0.sessionID == sessionID } + } + + func closeContext(_ contextID: String) { remove { $0.context.contextID == contextID } } + + private func remove(where matches: (Entry) -> Bool) { + for (id, entry) in entries where matches(entry) { + entries[id] = nil; entry.live = false + guard let access = entry.access else { continue } + let key = UUID() + cleanup[key] = Task { @MainActor [weak self] in + await access.close() + self?.cleanup[key] = nil + } + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift new file mode 100644 index 0000000000..b050f48b6a --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift @@ -0,0 +1,110 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import LitheTerminalModule + +@MainActor +extension AppModel: PluginTerminalWindowPort { + func hostCreatePluginTerminal( + _ request: PluginTerminalLaunch, + onExit: @escaping @MainActor @Sendable (PluginTerminalExit) -> Void + ) async throws -> any PluginTerminalSessionAccess { + guard let identity = currentWorkspaceIdentity, + services.remoteConnections?.workspaces.contains(identity.url) != true else { + throw PluginHostError.remoteWorkspaceUnsupported + } + let invocation = String(data: try JSONEncoder().encode([request.executable] + request.arguments), encoding: .utf8) ?? "" + let outcome = await hostMessages.present(PluginHostMessageEntry( + id: UUID(), pluginID: request.pluginID, pluginName: request.pluginID, severity: .warning, + text: String(format: L10n.string("Allow “%@” to open a terminal?"), request.pluginID) + + "\n\n" + invocation + "\n" + request.workingDirectory.path, + actions: [L10n.string("Allow"), L10n.string("Don’t Allow")], workspaceGeneration: identity.generation + )) + try Task.checkCancellation() + guard currentWorkspaceIdentity == identity else { throw PluginHostError.workspaceChanged } + guard outcome == .selected(index: 0) else { + throw PluginHostServiceError(.cancelled, L10n.string("The plugin terminal was not opened.")) + } + guard await activateTerminalModule(), let feature = terminalFeature else { + throw PluginHostServiceError(.unavailable, L10n.string("The terminal is unavailable.")) + } + try Task.checkCancellation() + guard currentWorkspaceIdentity == identity else { throw PluginHostError.workspaceChanged } + let root = identity.url.resolvingSymlinksInPath().standardizedFileURL.path + let directory = request.workingDirectory.resolvingSymlinksInPath().standardizedFileURL + guard directory.path == root || directory.path.hasPrefix(root.hasSuffix("/") ? root : root + "/") else { + throw PluginHostError.outsideWorkspace + } + let launch = TerminalProcessLaunch( + title: request.title, executablePath: request.executable, arguments: request.arguments, + workingDirectory: directory.path, + environmentChanges: request.environment.sorted { $0.key < $1.key }.map { .init(name: $0.key, value: $0.value) }, + initialDisplayText: request.displayHistory + ) + let created = try await feature.createProcessSessionAsync(launch, workspaceURL: identity.url, + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage) + let session = created.session + do { + try Task.checkCancellation() + guard currentWorkspaceIdentity == identity else { throw PluginHostError.workspaceChanged } + } catch { + await session.stopAndWait() + feature.closeSession(session) + throw error + } + configureInteractiveTerminalSession(session) + terminalPlacementFeature.registerSession(session.id) + let access = AppPluginTerminalSession(model: self, session: session, onExit: onExit) + if request.show { try access.show() } + return access + } + + func hostClosePluginTerminal(_ session: TerminalSession) async { + await session.stopAndWait() + closeTerminalSession(session) + } +} + +/// Retains the native session until its owner releases the API handle. It never +/// obtains the terminal emulator or exposes the user's other terminal sessions. +@MainActor +private final class AppPluginTerminalSession: PluginTerminalSessionAccess { + private weak var model: AppModel? + private let session: TerminalSession + private(set) var exit: PluginTerminalExit? + private let onExit: @MainActor @Sendable (PluginTerminalExit) -> Void + + init(model: AppModel, session: TerminalSession, onExit: @escaping @MainActor @Sendable (PluginTerminalExit) -> Void) { + self.model = model; self.session = session; self.onExit = onExit + let previous = session.onEnded + session.onEnded = { [weak self] ended in + self?.finish(.init(code: ended.lastExitCode, reason: "exited")) + previous?(ended) + } + session.onStopped = { [weak self] in self?.finish(.init(code: nil, reason: "closed")) } + // A short-lived process can exit while the async native launch returns. + if !session.isRunning { finish(.init(code: session.lastExitCode, reason: "exited")) } + } + + func show() throws { + guard let model, model.terminalSessions.contains(where: { $0 === session }) else { + throw PluginHostServiceError(.notFound, L10n.string("The plugin terminal does not exist.")) + } + // Selecting a tab in its own window never raises a background window. + model.revealTerminalSession(session.id) + } + + func displayText(maximumBytes: Int) -> String { session.displayText(maximumBytes: maximumBytes) } + + func close() async { + finish(.init(code: nil, reason: "closed")) + if let model { await model.hostClosePluginTerminal(session) } + else { await session.stopAndWait() } + } + + private func finish(_ value: PluginTerminalExit) { + guard exit == nil else { return } + exit = value + onExit(value) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index a4578f0546..b048fba264 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -382,7 +382,7 @@ extension AppModel { } } - private func closeTerminalSession(_ session: TerminalSession) { + func closeTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } debugTerminalSessionIDs.remove(session.id) for debugSessionID in debugTerminalSessionIDsByDebugSession.keys { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 3e73c8aeb3..60368151e4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -449,6 +449,7 @@ final class MacServiceContainer { ) hostDispatcher.register(workspaceHost) hostDispatcher.register(PluginDocumentHostService(contexts: .shared)) + hostDispatcher.register(PluginTerminalHostService(contexts: .shared, metadata: workspaceHost)) let editorActions = PluginEditorActionHostService(contexts: .shared, decoder: declarativePluginOperations) hostDispatcher.register(editorActions) hostDispatcher.register(PluginFileHostService(contexts: .shared, metadata: workspaceHost, diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index e6feb9dcb2..dd1933bca0 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -568,6 +568,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L options.scrollback = scrollbackLines view.terminal.options = options view.terminal.setup(isReset: false) + } private static func preferredTerminalFont() -> NSFont { @@ -650,6 +651,12 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L "\(key)=\(environment[key] ?? "")" } + // History is display-only and comes from the plain-text native snapshot. + // Feed it before spawning, so it can never become input to the new PTY. + if !launch.initialDisplayText.isEmpty { + view.feed(text: launch.initialDisplayText.replacingOccurrences(of: "\n", with: "\r\n")) + } + view.startProcess( executable: executablePath, args: launch.arguments, @@ -683,6 +690,40 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L view.terminal.resetToInitialState() } + func displayText(maximumBytes: Int) -> String { + guard maximumBytes > 0 else { return "" } + let terminal = view.terminal! + let first = terminal.buffer.totalLinesTrimmed + // Find the buffer end without materializing or measuring the entire + // scrollback. Then translate only the suffix the caller can retain. + var low = first, high = first + scrollbackLines + terminal.rows + 1 + while low < high { + let middle = low + (high - low) / 2 + if terminal.getScrollInvariantLine(row: middle) != nil { low = middle + 1 } + else { high = middle } + } + var parts: [String] = [], used = 0 + var row = low + while row > first, used < maximumBytes { + row -= 1 + guard let line = terminal.getScrollInvariantLine(row: row) else { break } + let text = line.translateToString(trimRight: true, skipNullCellsFollowingWide: true, + characterProvider: { terminal.getCharacter(for: $0) }).unicodeScalars.filter { + $0.value >= 32 && !((127...159).contains($0.value)) + }.map(String.init).joined() + if parts.isEmpty && text.isEmpty { continue } + let value = text + "\n" + let remaining = maximumBytes - used + let bytes = Array(value.utf8.suffix(remaining)) + var start = 0 + while start < bytes.count && bytes[start] & 0xc0 == 0x80 { start += 1 } + let kept = String(decoding: bytes[start...], as: UTF8.self) + parts.append(kept); used += kept.utf8.count + if value.utf8.count >= remaining { break } + } + return parts.reversed().joined() + } + func stop() { if let session { self.session = nil diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 5595b57405..1e23c1deaa 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -758,6 +758,8 @@ enum PluginMarketplacePresentation { PluginMarketplacePresentation.localized("Start a language server when you open files of its language", locale: locale) case "editor.actions": L10n.string("Add clickable actions beside document text") + case "terminal.create": + L10n.string("Open and manage its own native terminals, retaining bounded display history for reopening") case "process.run": PluginMarketplacePresentation.localized("Run your files", locale: locale) case "ui.contribute": diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 1bcea35a26..fb9e946ce3 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -18,19 +18,24 @@ public struct TerminalProcessLaunch: Equatable, Sendable { public let arguments: [String] public let workingDirectory: String public let environmentChanges: [TerminalEnvironmentChange] + /// Plain text shown before the new process starts. It is display history, + /// never input written to the PTY. + public let initialDisplayText: String public init( title: String?, executablePath: String, arguments: [String], workingDirectory: String, - environmentChanges: [TerminalEnvironmentChange] = [] + environmentChanges: [TerminalEnvironmentChange] = [], + initialDisplayText: String = "" ) { self.title = title self.executablePath = executablePath self.arguments = arguments self.workingDirectory = workingDirectory self.environmentChanges = environmentChanges + self.initialDisplayText = initialDisplayText } } @@ -93,6 +98,9 @@ public protocol TerminalTransport: AnyObject { func interrupt() throws func focus() func clear() + /// A bounded plain-text suffix of the rendered terminal, excluding escape + /// sequences and without exposing the input stream. + func displayText(maximumBytes: Int) -> String /// Starts stopping every process of the terminal session, including jobs /// in their own process groups and jobs that outlived the shell. func stop() @@ -118,4 +126,5 @@ public extension TerminalTransport { var reportsLightBackground: Bool? { nil } /// Hosts without wallpaper keep the initial surface configuration a no-op. func configureInitialSurface(showsWorkbenchBackground: Bool) {} + func displayText(maximumBytes: Int) -> String { "" } } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index c0383824e1..4d488a38b9 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -22,6 +22,9 @@ public final class TerminalSession: ObservableObject, Identifiable { public var onOutput: ((String) -> Void)? /// Host callback invoked once this session's process exited on its own, as opposed to being stopped by the host; never fired for launch failures. public var onEnded: ((TerminalSession) -> Void)? + /// Host lifecycle observer for an explicit stop (the natural-exit path + /// continues to use onEnded). No output or input access is implied. + public var onStopped: (() -> Void)? private let transport: any TerminalTransport private var workspaceURL: URL? @@ -185,6 +188,7 @@ public final class TerminalSession: ObservableObject, Identifiable { } public func interrupt() { if isRunning { try? transport.interrupt() } } public func clear() { transport.clear() } + public func displayText(maximumBytes: Int) -> String { transport.displayText(maximumBytes: maximumBytes) } public func focus() { transport.focus() } public func stop() { launchGeneration = UUID() @@ -212,9 +216,11 @@ public final class TerminalSession: ObservableObject, Identifiable { } private func finishStop() { + let wasRunning = isRunning || isReady finishDecodedOutput() isRunning = false; isReady = false if startedAt != nil { endedAt = Date() } + if wasRunning { onStopped?() } } private func finishDecodedOutput() { diff --git a/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift b/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift new file mode 100644 index 0000000000..47d6bef029 --- /dev/null +++ b/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@MainActor +struct PluginTerminalHostServiceTests { + @Test + func createsDirectArgvPublishesExitAndIsolatesOwnership() async throws { + let fixture = Fixture() + defer { fixture.end() } + let created = try await fixture.call("create", [ + "executable": .string("docker"), "arguments": .array([.string("exec"), .string("$(literal)")]), + "environment": .object(["DOCKER_HOST": .string("unix:///fixture/docker.sock"), "DOCKER_CONTEXT": .null]), + ]).get() + let id = try terminalID(created) + #expect(fixture.window.launch?.arguments == ["exec", "$(literal)"]) + #expect(fixture.window.launch?.environment.keys.contains("DOCKER_CONTEXT") == true) + #expect(fixture.window.launch?.environment["DOCKER_CONTEXT"] == .some(nil)) + #expect(fixture.window.launch?.workingDirectory == PluginHostFixture.workspaceURL) + _ = try await fixture.call("show", ["terminalID": .string(id)]).get() + #expect(fixture.window.access.shown == 1) + fixture.dispatcher.beginSession(pluginID: "other", hostServices: Array(fixture.service.methods)) + let foreign = await fixture.dispatcher.dispatch(fixture.request("show", ["terminalID": .string(id)]), pluginID: "other") + #expect(code(foreign) == "plugin.host.notFound") + _ = try await fixture.call("onExit", ["terminalID": .string(id)]).get() + fixture.window.exit?(.init(code: 7, reason: "exited")) + fixture.window.exit?(.init(code: 8, reason: "exited")) + #expect(fixture.events.count == 1) + guard case .object(let event) = fixture.events.first, + case .object(let data)? = event["event"] else { Issue.record("exit missing"); return } + #expect(data["exitCode"] == .integer(7)) + _ = try await fixture.call("close", ["terminalID": .string(id)]).get() + #expect(fixture.window.access.closed) + } + + @Test + func rejectsUnknownOptionsBadLaunchAndRemoteBeforeCreatingAnything() async { + let fixture = Fixture() + defer { fixture.end() } + for params: [String: ToolingJSONValue] in [ + ["executable": .string("docker"), "sendText": .string("bad")], + ["executable": .string("./docker")], + ["executable": .string("docker"), "environment": .object(["LITHE_PROCESS_SCOPE": .string("override")])], + ["executable": .string("docker"), "cwd": .object(["rootID": .string("unknown"), "path": .string("")])], + ] { + #expect(code(await fixture.call("create", params)) != nil) + } + #expect(fixture.window.launch == nil) + fixture.context = fixture.contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, + remote: (connectionID: "connection", remoteRoot: "/workspace")) + #expect(code(await fixture.call("create", ["executable": .string("docker")])) == "plugin.host.authorityUnsupported") + #expect(fixture.window.launch == nil) + } + + @Test + func contextCloseStopsOnlyOwnedTerminalAndPendingLaunchCannotSurviveReplacement() async throws { + let fixture = Fixture() + defer { fixture.end() } + let started = TestGate(), release = TestGate(), finished = TestGate() + fixture.window.duringLaunch = { started.open(); _ = await release.waitUntilOpen() } + let pending = Task { @MainActor in + defer { finished.open() } + return await fixture.call("create", ["executable": .string("docker")]) + } + defer { release.open(); pending.cancel() } + try #require(await started.waitUntilOpen()) + fixture.contexts.close(owner: fixture.window) + release.open() + try #require(await finished.waitUntilOpen()) + #expect(code(await pending.value) != nil) + #expect(fixture.window.access.closed) + #expect(code(await fixture.call("show", ["terminalID": .string("unknown")])) == "plugin.host.contextExpired") + } + + @Test + func sessionEndClosesNativeAccessWithoutTouchingOtherPlugins() async throws { + let fixture = Fixture() + defer { fixture.end() } + _ = try await fixture.call("create", ["executable": .string("docker")]).get() + fixture.dispatcher.endSession(pluginID: "other") + #expect(!fixture.window.access.closed) + fixture.dispatcher.endSession(pluginID: "plugin") + try #require(await fixture.window.access.didClose.waitUntilOpen()) + #expect(fixture.window.access.closed) + } + + @Test + func reopenRestoresOnlyTheChosenDisplaySuffixAndRejectsStaleExit() async throws { + let fixture = Fixture() + defer { fixture.end() } + let created = try await fixture.call("create", ["executable": .string("example-client"), "retainedContextBytes": .integer(8)]).get() + let id = try terminalID(created) + let oldExit = fixture.window.exit + fixture.window.access.history = "older\nrecent\n" + _ = try await fixture.call("onExit", ["terminalID": .string(id)]).get() + _ = try await fixture.call("close", ["terminalID": .string(id)]).get() + #expect(code(await fixture.call("show", ["terminalID": .string(id)])) == "plugin.host.preconditionFailed") + let reopened = try await fixture.call("reopen", ["terminalID": .string(id)]).get() + #expect(fixture.window.launch?.displayHistory == "\nrecent\n") + #expect(fixture.window.launch?.arguments == []) + guard case .object(let result) = reopened else { Issue.record("reopen missing"); return } + #expect(result["terminalID"] == .string(id)) + #expect(result["generation"] == .integer(2)) + oldExit?(.init(code: 9, reason: "exited")) + #expect(fixture.events.count == 1) + fixture.window.exit?(.init(code: 0, reason: "exited")) + #expect(fixture.events.count == 2) + _ = try await fixture.call("dispose", ["terminalID": .string(id)]).get() + #expect(code(await fixture.call("reopen", ["terminalID": .string(id)])) == "plugin.host.notFound") + } + + @Test + func cancelledReopenDoesNotPublishAnExitOrConsumeTheNextGeneration() async throws { + let fixture = Fixture() + defer { fixture.end() } + let id = try terminalID(await fixture.call("create", ["executable": .string("example-client")]).get()) + _ = try await fixture.call("onExit", ["terminalID": .string(id)]).get() + _ = try await fixture.call("close", ["terminalID": .string(id)]).get() + let entered = TestGate(), release = TestGate(), finished = TestGate() + fixture.window.duringLaunch = { entered.open(); _ = await release.waitUntilOpen() } + let reopen = Task { @MainActor in + defer { finished.open() } + return await fixture.call("reopen", ["terminalID": .string(id)]) + } + defer { reopen.cancel(); release.open() } + try #require(await entered.waitUntilOpen()) + reopen.cancel(); release.open() + try #require(await finished.waitUntilOpen()) + #expect(code(await reopen.value) == "plugin.host.cancelled") + #expect(fixture.events.count == 1) + fixture.window.duringLaunch = nil + let result = try await fixture.call("reopen", ["terminalID": .string(id)]).get() + guard case .object(let object) = result else { Issue.record("reopen missing"); return } + #expect(object["generation"] == .integer(2)) + } + + private func terminalID(_ value: ToolingJSONValue) throws -> String { + guard case .object(let object) = value, case .string(let id)? = object["terminalID"] else { + throw PluginHostArguments.invalid + } + return id + } + + private func code(_ result: Result) -> String? { + if case .failure(let failure) = result { return failure.code }; return nil + } + + @MainActor + private final class Access: PluginTerminalSessionAccess { + var exit: PluginTerminalExit? + var shown = 0 + var closed = false + var history = "" + let didClose = TestGate() + func show() throws { shown += 1 } + func displayText(maximumBytes: Int) -> String { String(decoding: history.utf8.suffix(maximumBytes), as: UTF8.self) } + func close() async { closed = true; didClose.open() } + } + + @MainActor + private final class Window: PluginHostWindowPort, PluginTerminalWindowPort { + var hostSnapshot: PluginHostWindowSnapshot? = PluginHostFixture.snapshot(windowID: UUID()) + let hostMessages = PluginHostMessageFeatureModel() + let access = Access() + var launch: PluginTerminalLaunch? + var exit: (@MainActor @Sendable (PluginTerminalExit) -> Void)? + var duringLaunch: (@MainActor () async -> Void)? + func hostCreatePluginTerminal(_ launch: PluginTerminalLaunch, onExit: @escaping @MainActor @Sendable (PluginTerminalExit) -> Void) async throws -> any PluginTerminalSessionAccess { + self.launch = launch; exit = onExit + access.closed = false + await duringLaunch?() + return access + } + func hostDocumentState(at url: URL, flushingEditor: Bool) async -> PluginHostDocumentState? { nil } + func hostOpenDocument(at url: URL, activation: PluginHostEditorActivation) async -> PluginHostDocumentState? { nil } + func hostPlaceCaret(in url: URL, at position: PluginHostPosition) {} + func hostNoteMessageLimit(pluginName: String) {} + } + + @MainActor + private final class Fixture { + let contexts = PluginWorkspaceContexts() + let window = Window() + var context: PluginWorkspaceContext! + let dispatcher: PluginHostServiceDispatcher + let service: PluginTerminalHostService + var events: [ToolingJSONValue] = [] + init() { + dispatcher = PluginHostServiceDispatcher(contexts: contexts) + let metadata = PluginWorkspaceHostService(contexts: contexts, + backendStateDirectory: { _ in URL(fileURLWithPath: "/fixture/state") }, rootIDSecret: { Data("fixture".utf8) }) + service = PluginTerminalHostService(contexts: contexts, metadata: metadata) + context = contexts.open(owner: window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + dispatcher.register(service) + dispatcher.beginSession(pluginID: "plugin", hostServices: Array(service.methods)) + dispatcher.subscriptions.deliver = { [weak self] _, value in self?.events.append(value) } + } + func end() { + window.duringLaunch = nil; window.exit = nil + dispatcher.endSession(pluginID: "plugin"); dispatcher.endSession(pluginID: "other") + contexts.close(owner: window) + } + func request(_ method: String, _ params: [String: ToolingJSONValue]) -> PluginHostRequest { + var params = params + params["context"] = .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]) + return PluginHostRequest(id: UUID().uuidString, method: "lithe/host/terminal/" + method, service: "terminal", params: .object(params)) + } + func call(_ method: String, _ params: [String: ToolingJSONValue]) async -> Result { + await dispatcher.dispatch(request(method, params), pluginID: "plugin") + } + } +} diff --git a/macos/Tests/LitheTests/PluginTerminalNativeTests.swift b/macos/Tests/LitheTests/PluginTerminalNativeTests.swift new file mode 100644 index 0000000000..f75bb3b322 --- /dev/null +++ b/macos/Tests/LitheTests/PluginTerminalNativeTests.swift @@ -0,0 +1,113 @@ +import Combine +import Foundation +import Testing +import LitheModuleAPI +import LitheTerminalModule +@testable import Lithe + +/// Uses the production AppModel terminal owner and a real native PTY. All files +/// and processes belong to this test; user profiles and installed plugins are untouched. +@MainActor +struct PluginTerminalNativeTests { + @Test + func displayContextBudgetKeepsUnicodeAndNeverRetainsControlSequences() { + let native = MacTerminalTransport(scrollbackLines: 100) + native.view.feed(text: "older\r\n\u{1b}[31m中文😀\u{1b}[0m\r\n") + #expect(native.displayText(maximumBytes: 0).isEmpty) + let suffix = native.displayText(maximumBytes: 8) + #expect(suffix == "文😀\n") + #expect(suffix.utf8.count == 8) + #expect(!suffix.contains("\u{1b}")) + #expect(!suffix.contains("�")) + #expect(native.displayText(maximumBytes: 4) == "\n") + native.stop() + } + + @Test + func nativeCreateCloseAndReopenRestoreDisplayWithoutReplayingInput() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("plugin-terminal-native-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MemoryStore(), settings = AppSettings(store: MemoryStore()) + let model = AppModel(settings: settings, services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let native = MacTerminalTransport(scrollbackLines: 100) + let terminals = TerminalFeatureModel(terminalFactory: { native }) + model.cacheModuleCapability(TerminalModuleCapability(feature: terminals), id: .terminalWorkspace, moduleID: .terminal) + model.workspaceSessionCoordinator.beginWorkspace(at: root) + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: model, rootURL: root, remote: nil) + let metadata = PluginWorkspaceHostService(contexts: contexts, + backendStateDirectory: { _ in root.appendingPathComponent("state") }, rootIDSecret: { Data("test".utf8) }) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + let service = PluginTerminalHostService(contexts: contexts, metadata: metadata) + dispatcher.register(service) + dispatcher.beginSession(pluginID: "native-test", hostServices: Array(service.methods)) + let consentShown = TestGate() + let consent = model.hostMessages.$visible.sink { entries in + guard let entry = entries.first else { return } + consentShown.open() + Task { @MainActor [weak model] in model?.hostMessages.choose(entry.id, index: 0) } + } + func request(_ method: String, _ params: [String: ToolingJSONValue]) -> PluginHostRequest { + var params = params + params["context"] = .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]) + return PluginHostRequest(id: UUID().uuidString, method: "lithe/host/terminal/\(method)", service: "terminal", params: .object(params)) + } + do { + // A fixed explicit script supplies real output and then blocks in read. + // Reopening creates a fresh process; history must never be sent to stdin. + let created = try await dispatcher.dispatch(request("create", [ + "executable": .string("/bin/sh"), "arguments": .array([.string("-c"), .string("printf 'native-history-marker\\n'; read line; printf 'input:%s\\n' \"$line\"; read next")]), + "show": .bool(false), "retainedContextBytes": .integer(128), + ]), pluginID: "native-test").get() + guard case .object(let result) = created, case .string(let id)? = result["terminalID"] else { + throw PluginHostArguments.invalid + } + try #require(consentShown.isOpen) + try #require(await eventually { native.displayText(maximumBytes: 128).contains("native-history-marker") }) + let firstSession = try #require(model.terminalSessions.first) + _ = try await dispatcher.dispatch(request("close", ["terminalID": .string(id)]), pluginID: "native-test").get() + #expect(!firstSession.isRunning) + #expect(model.terminalSessions.isEmpty) + _ = try await dispatcher.dispatch(request("reopen", ["terminalID": .string(id), "show": .bool(false)]), pluginID: "native-test").get() + let secondSession = try #require(model.terminalSessions.first) + #expect(secondSession.id != firstSession.id) + try #require(await eventually { + native.displayText(maximumBytes: 512).components(separatedBy: "native-history-marker").count == 3 + }) + #expect(!native.displayText(maximumBytes: 512).contains("input:")) + try native.send(Data("manual-input\n".utf8)) + try #require(await eventually { native.displayText(maximumBytes: 512).contains("input:manual-input") }) + _ = try await dispatcher.dispatch(request("dispose", ["terminalID": .string(id)]), pluginID: "native-test").get() + #expect(model.terminalSessions.isEmpty) + } catch { + consent.cancel(); model.hostMessages.dismissAll(.cancelled) + dispatcher.endSession(pluginID: "native-test"); contexts.close(owner: model) + await terminals.stopAllSessionsAndWait() + throw error + } + consent.cancel(); model.hostMessages.dismissAll(.cancelled) + dispatcher.endSession(pluginID: "native-test"); contexts.close(owner: model) + await terminals.stopAllSessionsAndWait() + #expect(!native.isRunning) + } + + private func eventually(_ condition: @MainActor () -> Bool) async -> Bool { + let clock = ContinuousClock(), deadline = ContinuousClock.now + .seconds(5) + while clock.now < deadline { + if condition() { return true } + // test-stability: allow(swift-real-sleep) reason: the real PTY output is external I/O without a test-owned scheduler; monotonic polling is locally bounded to five seconds. + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + private final class MemoryStore: KeyValueStore { + var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + } +} diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 3bdfb4caf5..e28cdc61fc 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -105,6 +105,7 @@ pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "preview.open", "browser.open", "window.messages", + "terminal.create", ]; const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; @@ -499,6 +500,48 @@ pub(crate) struct HostMethod { /// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and /// the manifest permission, and its handler to the host's dispatcher. pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: "lithe/host/terminal/reopen", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/dispose", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/create", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/show", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/close", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/onExit", + service: "terminal", + permission: Some("terminal.create"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, HostMethod { method: CREDENTIALS_GET, service: HOST_SERVICE_CREDENTIALS, diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 7706a7d625..d6fe571c54 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -2111,3 +2111,23 @@ fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { assert!(!grants.contains(&json!("editor"))); assert!(!grants.contains(&json!("lithe/host/workspace/readFile"))); } + +#[test] +fn terminal_permission_grants_only_owned_terminal_methods_and_requires_backend_two() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/terminal-backend.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let plan = language_server_launch_plan_command(json!({ + "manifest": manifest, "backend": true, + "request": {"pluginDirectory":"/plugins/terminal","stateDirectory":"/state/terminal","architecture":"arm64"} + })).unwrap(); + let grants = plan["plan"]["backend"]["hostServices"].as_array().unwrap(); + for method in ["create", "show", "close", "reopen", "dispose", "onExit"] { + assert!(grants.contains(&json!(format!("lithe/host/terminal/{method}")))); + } + assert!(!grants.contains(&json!("terminal"))); + assert!(!grants.contains(&json!("lithe/host/terminal/sendText"))); + let mut old = manifest; + old["requires"]["backend"] = json!(1); + assert_ne!(validate(old)["status"], "ok"); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 4b08601ba4..7f67dd9a59 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -606,6 +606,7 @@ Rules for publishing across levels and capability versions: | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | | `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | +| `terminal.create` | the backend creates and manages its own [native terminals](#plugin-owned-native-terminals-function-protocol-3), including bounded display history for reopen; requires `backend` 2 | | Activation event | Meaning | | --- | --- | @@ -2972,3 +2973,94 @@ Schema 1 loads the complete node set on refresh. Forms, lazy child data sources, multi-instance selectors, global function commands, native renderer factories and Windows rendering remain follow-up work under #1209; this subset does not claim completion of that whole issue. + +### Plugin-owned native terminals (function protocol 3) + +[`terminal-backend.plugin.json`](fixtures/terminal-backend.plugin.json) is the +minimal manifest. `terminal.create` grants `lithe/host/terminal/create`, `show`, `close`, `reopen`, +`dispose`, and `onExit`. It requires `backend:2`; discover the individual +methods through `getCapabilities` before using them. macOS implements this +service. Windows has no handler and does not advertise its methods. The +plugin's web page calls its backend through `functions.invoke`; the backend +uses these host requests with the invocation's workspace context. There is +no direct web `terminal.*` bridge and no new protocol version. + +| Method (prefix `lithe/host/terminal/`) | Params, in addition to `context` | Result | +| --- | --- | --- | +| `create` | `executable`, optional `arguments`, `title`, `cwd`, `environment`, `show`, `retainedContextBytes` | `{terminalID,generation:1}` | +| `show` | `terminalID` | `null` | +| `close` | `terminalID`, optional `retainedContextBytes` | `{retainedContextBytes}` after bounded native cleanup | +| `reopen` | `terminalID`, optional `show` | `{terminalID,generation,restoredContextBytes}` | +| `dispose` | `terminalID` | `null` after closing and forgetting the handle | +| `onExit` | `terminalID` | `{subscriptionID}` | + +`executable` is an absolute path or a bare command resolved by the native +terminal's PATH lookup; relative paths containing separators are refused. +`arguments` is an argv array, never shell text. `cwd` is a `{rootID,path}` +workspace directory resource (an empty path selects the folder root); without +it, the workspace root is used. The native adapter rejects a canonical cwd +outside that workspace. `environment` maps variable names to string overrides +or `null` removals. Process-ownership scope variables cannot be overridden. +`show` defaults to true; it selects the terminal in its own window without +raising a background window. Unknown options fail before any side effect. + +Creation and reopening ask for execution consent with the program, argv and +working directory, then revalidate context and cancellation before launching +through the existing PTY terminal module. The response waits for user choice +without a machine-operation deadline, but cancellation/session/context closure +still ends it. Native launch and process cleanup retain their own bounds. +The host never types a launch into an existing shell or reads other terminals. +There is no `sendText`, user-terminal enumeration, web emulator, or Docker +special case. First release is local authority only; a remote context fails +with `plugin.host.authorityUnsupported` instead of starting anything locally. + +Each handle belongs to one plugin backend session and one context generation. +A handle from another plugin, context or replacement backend is `notFound`. +At most eight handles per plugin are retained, including closed handles; +`dispose` releases one. Limits are 256 arguments, 16 KiB per argument or +environment value, 64 environment entries, 64 KiB combined launch data, and +128 UTF-8 bytes in a nonempty single-line title. `busy` refuses concurrent +close/reopen/dispose on the same handle. Reopening a running terminal is +`preconditionFailed`; showing a closed terminal asks the plugin to reopen it. + +#### Close, retained context and reopen + +`close` ends the process tree and removes the native tab, keeping the handle's +launch configuration and a plain-text suffix of its displayed terminal output. +`retainedContextBytes` is a nonnegative UTF-8 byte budget: default **16384**, +maximum **262144**, and **0** disables history. It can be chosen at create or +changed at close; over-limit values are refused, never silently expanded. +The result reports actual retained bytes, which can be smaller to avoid cutting +a Unicode scalar. The history is a rendering snapshot, not input events, raw +escape sequences, a resumable process image, or shell state. Secrets already +printed or echoed by a program can be part of that display; choose 0 when no +history should be retained. + +`reopen` uses the saved executable, argv, cwd and environment in a **new** PTY +process. It feeds retained plain text to the display before spawning, never to +stdin. Shell variables, unsubmitted input, process state and automatic command +replay are not restored. Natural exit and user tab close are also reopenable; +the snapshot is bounded by the same budget. It keeps `terminalID` and increments +`generation`; late exit callbacks of previous generations are ignored. These +handles/history exist only in memory, not across app/backend restart or +workspace close. No state is written to the app bundle or plugin package. + +`dispose` closes any live process and permanently forgets the launch settings +and history. Ending the backend session, disabling/updating the plugin or +closing/replacing its workspace does the same for only its owned terminals; +user-created terminals are unaffected. Closing a page alone does not close a +completed terminal creation. Cancellation before launch creates nothing; +cancellation or context loss during launch closes the newly created process. +Cancelled creation/reopen is not safe to retry automatically. + +`onExit` supports one live subscription per handle. Its event is +`{type:"exit",terminalID,generation,exitCode,reason}`, with an integer or null +exit code and `reason:"exited"|"closed"`. Each generation publishes one outcome. +Subscribing after exit delivers its recorded outcome once; the subscription +continues across reopen. Disposing it stops notifications and keeps the +terminal open. A disposed terminal has no further events. + +The executable and argv remain plugin-owned semantics. A container plugin can +launch the user's Docker CLI with an explicit engine and container ID; another +tool can launch its interactive client through exactly the same service. +Neither requires a fake Build/Run provider. diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 6bd6b38809..89cf0f6cf5 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -535,7 +535,15 @@ }, {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, - {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}} + {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, + {"name": "terminal create is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "executable": "example-tool"}, "expected": {"forward": "terminal"}}, + {"name": "terminal show is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/show"], "method": "lithe/host/terminal/show", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal close is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/close"], "method": "lithe/host/terminal/close", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal onExit is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/onExit"], "method": "lithe/host/terminal/onExit", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal aggregate grant cannot authorize terminal creation", "version": 3, "hostServices": ["terminal"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "an older protocol cannot create a native terminal", "version": 2, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, + {"name": "terminal reopen is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/reopen"], "method": "lithe/host/terminal/reopen", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal dispose is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/dispose"], "method": "lithe/host/terminal/dispose", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}} ], "invocationContexts": [ { diff --git a/shared/contracts/plugins/v1/fixtures/terminal-backend.plugin.json b/shared/contracts/plugins/v1/fixtures/terminal-backend.plugin.json new file mode 100644 index 0000000000..18c20e00d0 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/terminal-backend.plugin.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.terminalbackend", + "displayName": "Native Terminal Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "workspace.metadata", + "terminal.create" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 3fa91c61ed..a344452c52 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -81,7 +81,8 @@ "preview.open", "toolchains.provide", "browser.open", - "window.messages"] } + "window.messages", + "terminal.create"] } }, "activationEvents": { "type": "array", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 29b33ad70e..31a2ead522 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9869,7 +9869,16 @@ "native.toolchainSettings.providerDidNotRespond": "{provider} did not respond", "native.toolchainSettings.providerIsUnavailable": "{provider} is unavailable", "native.toolchainSettings.noAnswerWithinSeconds": "No answer within {seconds} s", - "lsp.projectJdkInvalid": "The project JDK is invalid or its version could not be read. Check JDK Home in Project settings." + "lsp.projectJdkInvalid": "The project JDK is invalid or its version could not be read. Check JDK Home in Project settings.", + "plugin.terminal.open": "Open a plugin terminal", + "plugin.terminal.cancelled": "The plugin terminal was not opened.", + "plugin.terminal.unavailable": "The terminal is unavailable.", + "plugin.terminal.notFound": "The plugin terminal does not exist.", + "plugin.terminal.consent": "Allow “{arg1}” to open a terminal?", + "plugin.terminal.closed": "The plugin terminal is closed. Reopen it first.", + "plugin.terminal.running": "The plugin terminal is still running.", + "plugin.terminal.busy": "The plugin terminal is changing state. Try again when it finishes.", + "plugin.terminal.permission": "Open and manage its own native terminals, retaining bounded display history for reopening" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index c23999cc72..8417235120 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9869,7 +9869,16 @@ "native.toolchainSettings.providerDidNotRespond": "{provider} 未响应", "native.toolchainSettings.providerIsUnavailable": "{provider} 不可用", "native.toolchainSettings.noAnswerWithinSeconds": "{seconds} 秒内未响应", - "lsp.projectJdkInvalid": "项目 JDK 无效或无法读取其版本。请检查项目设置中的 JDK Home。" + "lsp.projectJdkInvalid": "项目 JDK 无效或无法读取其版本。请检查项目设置中的 JDK Home。", + "plugin.terminal.open": "打开插件终端", + "plugin.terminal.cancelled": "未打开插件终端。", + "plugin.terminal.unavailable": "终端不可用。", + "plugin.terminal.notFound": "插件终端不存在。", + "plugin.terminal.consent": "允许“{arg1}”打开终端?", + "plugin.terminal.closed": "插件终端已关闭,请先重新打开。", + "plugin.terminal.running": "插件终端仍在运行。", + "plugin.terminal.busy": "插件终端正在切换状态,请完成后重试。", + "plugin.terminal.permission": "打开并管理自己的原生终端,保留有限的显示历史用于重新打开" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index aff0c69d4b..f498a861cc 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -94816,6 +94816,110 @@ "platforms": [ "windows" ] + }, + { + "id": "plugin.terminal.open", + "description": "Plugin-owned integrated terminal host API", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a plugin terminal", + "arguments": [] + } + }, + { + "id": "plugin.terminal.cancelled", + "description": "Plugin-owned integrated terminal host API", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin terminal was not opened.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.unavailable", + "description": "Plugin-owned integrated terminal host API", + "platforms": [ + "macos" + ], + "macos": { + "key": "The terminal is unavailable.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.notFound", + "description": "Plugin-owned integrated terminal host API", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin terminal does not exist.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.consent", + "description": "Plugin-owned terminal process launch confirmation", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "Allow “%@” to open a terminal?", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "plugin.terminal.closed", + "description": "Plugin native terminal lifecycle failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin terminal is closed. Reopen it first.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.running", + "description": "Plugin native terminal lifecycle failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin terminal is still running.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.busy", + "description": "Plugin native terminal lifecycle failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin terminal is changing state. Try again when it finishes.", + "arguments": [] + } + }, + { + "id": "plugin.terminal.permission", + "description": "Install review for plugin-owned terminal permission", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open and manage its own native terminals, retaining bounded display history for reopening", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/plugin-native-terminals.json b/shared/platform-feature-matrix/features/plugin-native-terminals.json new file mode 100644 index 0000000000..bad5885b91 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-native-terminals.json @@ -0,0 +1,28 @@ +{ + "id": "plugin-native-terminals", + "area": "扩展", + "group": "插件与主题", + "capability": "插件自有原生终端:直接 argv 创建、显示、关闭、带有界显示上下文的 reopen、dispose 与按 generation 的退出订阅", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift", + "macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", + "macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift", + "macos/Tests/LitheTests/PluginTerminalNativeTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Extensions / Terminal", + "verification": "运行 PluginTerminalHostServiceTests、PluginTerminalNativeTests 与 TerminalModuleTests:权限和归属、argv 原样、远程拒绝、取消和工作区关闭、真实 PTY create/close/reopen、UTF-8 历史字节预算、只恢复显示而不重放输入、dispose 释放及迟到 generation 隔离。在真实安装插件中核对本窗口终端标签、焦点、输入、Ctrl-C、缩放、用户关标签重开、插件停用与退出无残留进程;完成整机验收后再更新 verified。三个官方 SDK 在 lithe-plugin-docs 独立分支验证。", + "notes": "首版本机 macOS,复用现有终端 owner;终端权限要求 backend:2,方法由函数协议 3 能力发现。未开放 sendText/输出读取。close 保留内存句柄和默认 16 KiB(最多 256 KiB)显示上下文,reopen 启动新进程,dispose 或会话/工作区结束彻底释放;不跨重启持久化。" +} From 002dd8c3afe5da17afe3e44afaa8cf0a17ee2c5e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 17:24:11 +0800 Subject: [PATCH 2/2] fix(plugins): preserve terminal focus and release closed sessions --- ...026-11-02-plugin-owned-native-terminals.md | 8 +- .../PluginHostMessageFeatureModel.swift | 3 + .../Features/PluginTerminalHostService.swift | 38 ++++- .../AppModel/AppModel+PluginTerminal.swift | 20 ++- .../Models/AppModel/AppModel+Terminal.swift | 4 +- .../MacStoppedChildProcessReaper.swift | 18 +-- .../PluginHostMessageDetailsView.swift | 41 ++++++ .../Workbench/PluginHostMessageStack.swift | 4 + .../Application/TerminalFeatureModel.swift | 5 +- .../TerminalModuleTests.swift | 17 +++ .../PluginTerminalHostServiceTests.swift | 9 +- .../PluginTerminalNativeTests.swift | 137 +++++++++++++++++- shared/contracts/plugins/v1/README.md | 14 +- .../features/plugin-native-terminals.json | 8 +- 14 files changed, 290 insertions(+), 36 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Workbench/PluginHostMessageDetailsView.swift diff --git a/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md b/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md index a511752b1a..e145bafacc 100644 --- a/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md +++ b/.agents/notes/implemented/architecture/2026-11-02-plugin-owned-native-terminals.md @@ -20,6 +20,8 @@ Docker 插件需要一键进入容器,但公共 API 计划中的 API-24 只有 - 新权限 `terminal.create` 展开成具体方法授权,要求 `backend:2`。Web 页面调用插件自己的函数,后端拿本次调用的工作区上下文请求宿主;不从全局焦点猜窗口。 - `PluginTerminalHostService` 负责句柄、权限之后的归属检查、参数预算和生命周期。`AppModel+PluginTerminal` 把请求适配到既有 `TerminalFeatureModel`;`MacTerminalTransport` 继续拥有实际 PTY 和渲染。 - 启动参数保持 executable/argv 分离,cwd 是工作区目录资源;原生层校验 canonical cwd 不越界。执行前显示可取消确认,等待期间切工作区、停用插件或取消都不能继续启动。 +- 执行确认把完整程序、参数数组和工作目录放入只读的原生文本视口,支持滚动、选择与复制;用 JSON 转义区分参数边界和控制字符。不能把执行详情塞进限制八行的普通通知正文,否则长参数会遮住后续参数和目录。文本布局与可见字形绘制由 TextKit 负责,不随滚动重建全量控件。 +- `show:false` 在创建与重开时都保留原有活动终端,不挂载标签、不显示面板或请求输入焦点;后续显式 show 才注册标签并选择新终端。否则仅有编辑器区域终端时,工具区域会把新标签当成默认显示项。终端模块创建入口显式接收选择选项,不依赖创建后再切回旧标签。 - 首版仅本机 macOS。远程 authority 显式拒绝,Windows 未注册 handler,也不宣传已经支持。SDK 的 Node、Java、Rust 封装保留能力发现与旧宿主不可用语义。 ### 终端身份与资源寿命 @@ -28,6 +30,10 @@ Docker 插件需要一键进入容器,但公共 API 计划中的 API-24 只有 `close` 结束该进程会话并移除原生标签,保留启动配置及有限历史;`reopen` 先确认,再启动新的 PTY,沿用 terminalID 并增加进程 generation。退出订阅每个 generation 只发布一次,旧原生回调不能结束新进程;正在 close/reopen 时再次变更返回 busy。用户关闭标签或程序自然退出同样可以重开。 +用户关闭标签与自然退出也要经过有界原生清理、按预算截取历史并释放整个终端会话和渲染缓冲区,不能只从界面移除标签。清理期间句柄保持 busy;清理结束后才发布该 generation 的退出通知,收到通知后可立即重开。`retainedContextBytes:0` 在这两条路径同样不保留显示历史;无需等待插件再调用 close 或 reopen 才释放。 + +用户关标签已发起 stop 后,退出收尾仍会调用 stopAndWait 等待其清理,所以子进程回收器必须按 PID 复用同一观察源。重复登记只追加完成回调;不要先创建一个 DispatchSource 再判断是否重复,未激活的重复源被释放时会让 libdispatch 直接终止应用。原生 PTY 的用户关标签回归覆盖这条真实重复调用链。 + 后端结束、插件禁用/更新和工作区关闭,释放所属句柄并通过既有有界原生清理结束其进程。独立用户终端不参与这张表。网页关闭只取消在途调用,不结束已经交付给用户的终端。 ### 上下文只恢复显示,不恢复执行 @@ -55,7 +61,7 @@ Docker 插件需要一键进入容器,但公共 API 计划中的 API-24 只有 ## 验证 -- `scripts/macos/test/test-macos.sh --filter 'PluginTerminalHostServiceTests|PluginTerminalNativeTests|TerminalModuleTests'`:隔离、未知字段、remote 拒绝、关闭工作区期间的在途创建、退出去重、generation、历史后缀及真实 PTY 输入不重放。 +- `scripts/macos/test/test-macos.sh --filter 'PluginTerminalHostServiceTests|PluginTerminalNativeTests|TerminalModuleTests'`:隔离、未知字段、remote 拒绝、关闭工作区期间的在途创建、退出去重、generation、历史后缀及真实 PTY 输入不重放;后台创建/重开保留已有选择,用户关标签和自然退出按 0/8 字节预算释放原生缓冲区,长参数完整进入原生可选择视口且取消不启动进程。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function` 和 `--lib terminal_permission`:逐方法授权与旧协议拒绝。 - `scripts/architecture/verify-service-boundaries.sh`、`scripts/architecture/verify-shared-contracts.sh`、`scripts/macos/verify/verify-runtime-bundle-immutability.sh`。 - `scripts/agent-notes/verify-agent-notes.sh`、`scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`。 diff --git a/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift b/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift index 07cebc6149..759e620fac 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift @@ -37,6 +37,9 @@ struct PluginHostMessageEntry: Identifiable, Equatable, Sendable { let text: String let actions: [String] let workspaceGeneration: Int + /// Host-owned execution details, displayed in a selectable native viewport. + /// Public plugin messages cannot set this field. + var details: String? = nil } /// The plugin message surface of one window. diff --git a/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift b/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift index 31abeedb90..2bfa07000b 100644 --- a/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift +++ b/macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift @@ -64,6 +64,7 @@ final class PluginTerminalHostService: PluginHostServiceHandling { var generation = 1 var runToken = UUID() var starting = false + var retirement: Task? var subscriptions: [String: PluginHostSubscriptions] = [:] init(_ call: PluginHostCall, context: PluginWorkspaceContextReference) { pluginID = call.pluginID; sessionID = call.sessionID; self.context = context @@ -98,7 +99,7 @@ final class PluginTerminalHostService: PluginHostServiceHandling { } switch method { case "show": - guard let access = entry.access else { + guard entry.exit == nil, let access = entry.access else { throw PluginHostServiceError(.preconditionFailed, L10n.string("The plugin terminal is closed. Reopen it first.")) } try access.show() @@ -111,10 +112,12 @@ final class PluginTerminalHostService: PluginHostServiceHandling { defer { entry.busy = false } if let access = entry.access { entry.history = access.displayText(maximumBytes: bytes) } else { entry.history = Self.suffix(entry.history, bytes: bytes) } + let needsExitNotification = entry.exit == nil finish(entry, id: id, exit: .init(code: nil, reason: "closed")) await entry.access?.close() if let access = entry.access { entry.history = access.displayText(maximumBytes: bytes) } entry.access = nil + if needsExitNotification, entry.live, let exit = entry.exit { publish(entry, id: id, exit: exit) } return .success(.object(["retainedContextBytes": .integer(entry.history.utf8.count)])) case "reopen": guard !entry.busy else { throw busy } @@ -124,7 +127,10 @@ final class PluginTerminalHostService: PluginHostServiceHandling { guard let window = scope.window as? any PluginTerminalWindowPort else { throw PluginHostArguments.invalid } launch.show = try args.bool("show", default: true) entry.busy = true - defer { entry.busy = false } + defer { + entry.busy = false + retireExitedTerminal(entry, id: id) + } if let access = entry.access { entry.history = access.displayText(maximumBytes: launch.retainedContextBytes) await access.close() @@ -153,7 +159,6 @@ final class PluginTerminalHostService: PluginHostServiceHandling { } catch { await access.close(); entry.access = nil; throw error } if let exit = access.exit { finish(entry, id: id, exit: exit) } entry.starting = false - if let exit = entry.exit { publish(entry, id: id, exit: exit) } return .success(.object(["terminalID": .string(id), "generation": .integer(generation), "restoredContextBytes": .integer(entry.history.utf8.count)])) } catch { @@ -201,7 +206,10 @@ final class PluginTerminalHostService: PluginHostServiceHandling { entry.busy = true entry.starting = true entries[id] = entry - defer { entry.busy = false } + defer { + entry.busy = false + retireExitedTerminal(entry, id: id) + } do { let token = entry.runToken let access = try await window.hostCreatePluginTerminal(launch) { [weak self, weak entry] exit in @@ -300,7 +308,24 @@ final class PluginTerminalHostService: PluginHostServiceHandling { private func finish(_ entry: Entry, id: String, exit: PluginTerminalExit) { guard entry.exit == nil else { return } entry.exit = exit - if !entry.starting { publish(entry, id: id, exit: exit) } + retireExitedTerminal(entry, id: id) + } + + /// Native tab closure and natural exit must release the renderer just like + /// an API close. Keep the handle busy until process cleanup and its bounded + /// snapshot finish, so reopen cannot race the previous native session. + private func retireExitedTerminal(_ entry: Entry, id: String) { + guard entry.live, !entry.starting, !entry.busy, entry.retirement == nil, + let exit = entry.exit, let access = entry.access else { return } + entry.busy = true + entry.retirement = Task { @MainActor [weak self] in + await access.close() + entry.history = access.displayText(maximumBytes: entry.live ? entry.launch?.retainedContextBytes ?? 0 : 0) + entry.access = nil + entry.retirement = nil + entry.busy = false + if entry.live { self?.publish(entry, id: id, exit: exit) } + } } private func publish(_ entry: Entry, id: String, exit: PluginTerminalExit) { @@ -322,7 +347,10 @@ final class PluginTerminalHostService: PluginHostServiceHandling { private func remove(where matches: (Entry) -> Bool) { for (id, entry) in entries where matches(entry) { entries[id] = nil; entry.live = false + entry.history = ""; entry.launch = nil + if entry.retirement != nil { continue } guard let access = entry.access else { continue } + entry.access = nil let key = UUID() cleanup[key] = Task { @MainActor [weak self] in await access.close() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift index b050f48b6a..d78f853374 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift @@ -13,12 +13,18 @@ extension AppModel: PluginTerminalWindowPort { services.remoteConnections?.workspaces.contains(identity.url) != true else { throw PluginHostError.remoteWorkspaceUnsupported } - let invocation = String(data: try JSONEncoder().encode([request.executable] + request.arguments), encoding: .utf8) ?? "" + // JSON preserves argument boundaries and escapes control characters; + // the native text viewport lets the user inspect the entire launch. + let program = String(data: try JSONEncoder().encode(request.executable), encoding: .utf8) ?? "" + let directoryPath = String(data: try JSONEncoder().encode(request.workingDirectory.path), encoding: .utf8) ?? "" + let arguments = String(data: try JSONEncoder().encode(request.arguments), encoding: .utf8) ?? "" let outcome = await hostMessages.present(PluginHostMessageEntry( id: UUID(), pluginID: request.pluginID, pluginName: request.pluginID, severity: .warning, - text: String(format: L10n.string("Allow “%@” to open a terminal?"), request.pluginID) - + "\n\n" + invocation + "\n" + request.workingDirectory.path, - actions: [L10n.string("Allow"), L10n.string("Don’t Allow")], workspaceGeneration: identity.generation + text: String(format: L10n.string("Allow “%@” to open a terminal?"), request.pluginID), + actions: [L10n.string("Allow"), L10n.string("Don’t Allow")], workspaceGeneration: identity.generation, + details: L10n.string("Program") + "\n" + program + "\n\n" + + L10n.string("Working directory") + "\n" + directoryPath + "\n\n" + + L10n.string("Arguments") + "\n" + arguments )) try Task.checkCancellation() guard currentWorkspaceIdentity == identity else { throw PluginHostError.workspaceChanged } @@ -42,7 +48,7 @@ extension AppModel: PluginTerminalWindowPort { initialDisplayText: request.displayHistory ) let created = try await feature.createProcessSessionAsync(launch, workspaceURL: identity.url, - showsWorkbenchBackground: workbenchBackgroundFeature.hasImage) + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage, selectsSession: request.show) let session = created.session do { try Task.checkCancellation() @@ -53,7 +59,6 @@ extension AppModel: PluginTerminalWindowPort { throw error } configureInteractiveTerminalSession(session) - terminalPlacementFeature.registerSession(session.id) let access = AppPluginTerminalSession(model: self, session: session, onExit: onExit) if request.show { try access.show() } return access @@ -91,6 +96,9 @@ private final class AppPluginTerminalSession: PluginTerminalSessionAccess { throw PluginHostServiceError(.notFound, L10n.string("The plugin terminal does not exist.")) } // Selecting a tab in its own window never raises a background window. + // Background launches remain unmounted until explicitly shown, including + // when the only existing selection belongs to the editor area. + model.terminalPlacementFeature.registerSession(session.id) model.revealTerminalSession(session.id) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index b048fba264..ec50131e81 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -39,9 +39,9 @@ extension AppModel { sessions(orderedBy: terminalPlacementFeature.editorSessionIDs) } var activeToolTerminalSession: TerminalSession? { + guard let activeTerminalSessionID else { return nil } let toolSessions = toolTerminalSessions - if let activeTerminalSessionID, - let activeSession = toolSessions.first(where: { $0.id == activeTerminalSessionID }) { + if let activeSession = toolSessions.first(where: { $0.id == activeTerminalSessionID }) { return activeSession } return toolSessions.first diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift index 7b2f8b301a..11f33dd02c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift @@ -34,18 +34,18 @@ final class MacStoppedChildProcessReaper: @unchecked Sendable { return } - let processSource = DispatchSource.makeProcessSource( - identifier: processID, - eventMask: .exit, - queue: queue - ) - let shouldActivate = lock.withLock { () -> Bool in + let processSource = lock.withLock { () -> DispatchSourceProcess? in completions[processID, default: []].append(completion) - guard sources[processID] == nil else { return false } + guard sources[processID] == nil else { return nil } + // A second stop may register while the first source still waits. + // Never create and discard an inactive dispatch source for it. + let processSource = DispatchSource.makeProcessSource( + identifier: processID, eventMask: .exit, queue: queue + ) sources[processID] = processSource - return true + return processSource } - guard shouldActivate else { return } + guard let processSource else { return } // Retaining self until the event fires keeps reaping alive even when a // terminal session is closed immediately after stop(). diff --git a/macos/Sources/Lithe/Views/Workbench/PluginHostMessageDetailsView.swift b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageDetailsView.swift new file mode 100644 index 0000000000..3b600d7177 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageDetailsView.swift @@ -0,0 +1,41 @@ +import AppKit +import LitheModuleAPI +import SwiftUI + +/// Execution consent must expose every argument and the directory, including +/// long single-line values. TextKit owns layout, selection and viewport drawing. +struct PluginHostMessageDetailsView: NSViewRepresentable { + let text: String + + func makeNSView(context: Context) -> PluginHostMessageDetailsScrollView { + PluginHostMessageDetailsScrollView(text: text) + } + + func updateNSView(_ scroll: PluginHostMessageDetailsScrollView, context: Context) { + guard let document = scroll.documentView as? NSTextView else { return } + if document.string != text { document.string = text } + document.textColor = NSColor(LitheTheme.primaryText) + } +} + +final class PluginHostMessageDetailsScrollView: NSScrollView { + init(text: String) { + super.init(frame: .zero) + drawsBackground = false + hasVerticalScroller = true + scrollerStyle = .overlay + verticalScroller = LitheScrollViewChrome.CompactScroller() + autohidesScrollers = true + let document = MacConfigurationTextView() + document.isEditable = false + document.isSelectable = true + document.allowsUndo = false + document.font = LitheTheme.editorFont(size: 11) + document.setAccessibilityLabel(L10n.string("Open a plugin terminal")) + document.string = text + documentView = document + LitheTextViewportLayout.apply(to: document, in: self, softWrap: true) + } + + required init?(coder: NSCoder) { nil } +} diff --git a/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift index f62f3674e0..29b42b1331 100644 --- a/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift +++ b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift @@ -56,6 +56,10 @@ struct PluginHostMessageBanner: View { .foregroundStyle(LitheTheme.Notification.foreground) .lineLimit(8) .fixedSize(horizontal: false, vertical: true) + if let details = entry.details { + PluginHostMessageDetailsView(text: details) + .frame(height: 180) + } if !entry.actions.isEmpty { HStack(spacing: 12) { ForEach(Array(entry.actions.enumerated()), id: \.offset) { index, title in diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index 1b2b77316c..b400faf7d4 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -73,7 +73,7 @@ public final class TerminalFeatureModel: ObservableObject { } public var activeTerminalSession: TerminalSession? { - guard let activeTerminalSessionID else { return terminalSessions.first } + guard let activeTerminalSessionID else { return nil } return terminalSessions.first { $0.id == activeTerminalSessionID } } @@ -135,6 +135,7 @@ public final class TerminalFeatureModel: ObservableObject { _ launch: TerminalProcessLaunch, workspaceURL: URL, showsWorkbenchBackground: Bool = false, + selectsSession: Bool = true, onOutput: ((String) -> Void)? = nil ) async throws -> (session: TerminalSession, processID: Int32) { let transport = workspaceTerminalFactory?(workspaceURL) ?? terminalFactory() @@ -145,7 +146,7 @@ public final class TerminalFeatureModel: ObservableObject { defer { pendingProcessSessions[session.id] = nil } let processID = try await session.startProcessAsync(launch) terminalSessions.append(session) - activeTerminalSessionID = session.id + if selectsSession { activeTerminalSessionID = session.id } return (session, processID) } diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index 14690cb440..f3a416b7c2 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -4,6 +4,23 @@ import Testing @MainActor struct TerminalModuleTests { + @Test + func backgroundProcessCreationPreservesEmptyAndExistingSelection() async throws { + let feature = TerminalFeatureModel(terminalFactory: { TestTransport() }) + defer { feature.stopAllSessions() } + let root = URL(fileURLWithPath: "/workspace") + let launch = TerminalProcessLaunch(title: "Background client", executablePath: "/fixture/client", arguments: [], workingDirectory: root.path) + let background = try await feature.createProcessSessionAsync(launch, workspaceURL: root, selectsSession: false) + #expect(feature.activeTerminalSessionID == nil) + #expect(feature.activeTerminalSession == nil) + #expect(feature.terminalSessions.map(\.id) == [background.session.id]) + let user = feature.createSession(in: root, shellPath: "/bin/zsh") + _ = try await feature.createProcessSessionAsync(launch, workspaceURL: root, selectsSession: false) + #expect(feature.activeTerminalSession === user) + let foreground = try await feature.createProcessSessionAsync(launch, workspaceURL: root) + #expect(feature.activeTerminalSession === foreground.session) + } + @Test func explicitlyChosenShellNamesToolTabsWithoutRenamingDefaultSessions() { let feature = TerminalFeatureModel(terminalFactory: { TestTransport() }) diff --git a/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift b/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift index 47d6bef029..7ba6b651cb 100644 --- a/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift +++ b/macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift @@ -26,6 +26,7 @@ struct PluginTerminalHostServiceTests { _ = try await fixture.call("onExit", ["terminalID": .string(id)]).get() fixture.window.exit?(.init(code: 7, reason: "exited")) fixture.window.exit?(.init(code: 8, reason: "exited")) + try #require(await fixture.exitDelivered.waitUntilOpen()) #expect(fixture.events.count == 1) guard case .object(let event) = fixture.events.first, case .object(let data)? = event["event"] else { Issue.record("exit missing"); return } @@ -104,7 +105,9 @@ struct PluginTerminalHostServiceTests { #expect(result["generation"] == .integer(2)) oldExit?(.init(code: 9, reason: "exited")) #expect(fixture.events.count == 1) + fixture.exitDelivered = TestGate() fixture.window.exit?(.init(code: 0, reason: "exited")) + try #require(await fixture.exitDelivered.waitUntilOpen()) #expect(fixture.events.count == 2) _ = try await fixture.call("dispose", ["terminalID": .string(id)]).get() #expect(code(await fixture.call("reopen", ["terminalID": .string(id)])) == "plugin.host.notFound") @@ -186,6 +189,7 @@ struct PluginTerminalHostServiceTests { let dispatcher: PluginHostServiceDispatcher let service: PluginTerminalHostService var events: [ToolingJSONValue] = [] + var exitDelivered = TestGate() init() { dispatcher = PluginHostServiceDispatcher(contexts: contexts) let metadata = PluginWorkspaceHostService(contexts: contexts, @@ -194,7 +198,10 @@ struct PluginTerminalHostServiceTests { context = contexts.open(owner: window, rootURL: PluginHostFixture.workspaceURL, remote: nil) dispatcher.register(service) dispatcher.beginSession(pluginID: "plugin", hostServices: Array(service.methods)) - dispatcher.subscriptions.deliver = { [weak self] _, value in self?.events.append(value) } + dispatcher.subscriptions.deliver = { [weak self] _, value in + self?.events.append(value) + self?.exitDelivered.open() + } } func end() { window.duringLaunch = nil; window.exit = nil diff --git a/macos/Tests/LitheTests/PluginTerminalNativeTests.swift b/macos/Tests/LitheTests/PluginTerminalNativeTests.swift index f75bb3b322..6038da830c 100644 --- a/macos/Tests/LitheTests/PluginTerminalNativeTests.swift +++ b/macos/Tests/LitheTests/PluginTerminalNativeTests.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation import Testing @@ -31,7 +32,12 @@ struct PluginTerminalNativeTests { let store = MemoryStore(), settings = AppSettings(store: MemoryStore()) let model = AppModel(settings: settings, services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) let native = MacTerminalTransport(scrollbackLines: 100) - let terminals = TerminalFeatureModel(terminalFactory: { native }) + let userNative = MacTerminalTransport(scrollbackLines: 100) + var createsUserTerminal = true + let terminals = TerminalFeatureModel(terminalFactory: { + if createsUserTerminal { createsUserTerminal = false; return userNative } + return native + }) model.cacheModuleCapability(TerminalModuleCapability(feature: terminals), id: .terminalWorkspace, moduleID: .terminal) model.workspaceSessionCoordinator.beginWorkspace(at: root) let contexts = PluginWorkspaceContexts() @@ -54,6 +60,10 @@ struct PluginTerminalNativeTests { return PluginHostRequest(id: UUID().uuidString, method: "lithe/host/terminal/\(method)", service: "terminal", params: .object(params)) } do { + let user = try await terminals.createProcessSessionAsync(TerminalProcessLaunch(title: "User terminal", + executablePath: "/bin/sh", arguments: ["-c", "read line"], workingDirectory: root.path), workspaceURL: root) + model.terminalPlacementFeature.registerSession(user.session.id) + model.showToolWindow(.terminal) // A fixed explicit script supplies real output and then blocks in read. // Reopening creates a fresh process; history must never be sent to stdin. let created = try await dispatcher.dispatch(request("create", [ @@ -64,22 +74,31 @@ struct PluginTerminalNativeTests { throw PluginHostArguments.invalid } try #require(consentShown.isOpen) + #expect(model.activeTerminalSessionID == user.session.id) + #expect(model.activeToolTerminalSession === user.session) + #expect(model.toolTerminalSessions.map(\.id) == [user.session.id]) try #require(await eventually { native.displayText(maximumBytes: 128).contains("native-history-marker") }) - let firstSession = try #require(model.terminalSessions.first) + let firstSession = try #require(model.terminalSessions.first { $0 !== user.session }) _ = try await dispatcher.dispatch(request("close", ["terminalID": .string(id)]), pluginID: "native-test").get() #expect(!firstSession.isRunning) - #expect(model.terminalSessions.isEmpty) + #expect(model.terminalSessions.map(\.id) == [user.session.id]) _ = try await dispatcher.dispatch(request("reopen", ["terminalID": .string(id), "show": .bool(false)]), pluginID: "native-test").get() - let secondSession = try #require(model.terminalSessions.first) + let secondSession = try #require(model.terminalSessions.first { $0 !== user.session }) #expect(secondSession.id != firstSession.id) + #expect(model.activeTerminalSessionID == user.session.id) + #expect(model.activeToolTerminalSession === user.session) + #expect(model.toolTerminalSessions.map(\.id) == [user.session.id]) try #require(await eventually { native.displayText(maximumBytes: 512).components(separatedBy: "native-history-marker").count == 3 }) #expect(!native.displayText(maximumBytes: 512).contains("input:")) try native.send(Data("manual-input\n".utf8)) try #require(await eventually { native.displayText(maximumBytes: 512).contains("input:manual-input") }) + _ = try await dispatcher.dispatch(request("show", ["terminalID": .string(id)]), pluginID: "native-test").get() + #expect(model.activeToolTerminalSession === secondSession) + #expect(model.toolTerminalSessions.map(\.id) == [user.session.id, secondSession.id]) _ = try await dispatcher.dispatch(request("dispose", ["terminalID": .string(id)]), pluginID: "native-test").get() - #expect(model.terminalSessions.isEmpty) + #expect(model.terminalSessions.map(\.id) == [user.session.id]) } catch { consent.cancel(); model.hostMessages.dismissAll(.cancelled) dispatcher.endSession(pluginID: "native-test"); contexts.close(owner: model) @@ -90,6 +109,114 @@ struct PluginTerminalNativeTests { dispatcher.endSession(pluginID: "native-test"); contexts.close(owner: model) await terminals.stopAllSessionsAndWait() #expect(!native.isRunning) + #expect(!userNative.isRunning) + } + + @Test(arguments: [0, 8], [false, true]) + func tabCloseAndNaturalExitReleaseNativeBufferBeforeDeliveringExit(bytes: Int, naturalExit: Bool) async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("plugin-terminal-release-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let settings = AppSettings(store: MemoryStore()) + let model = AppModel(settings: settings, services: MacServiceContainer(store: MemoryStore(), settings: settings, moduleLaunchMode: .safeMode).services) + weak var originalNative: MacTerminalTransport? + let terminals = TerminalFeatureModel(terminalFactory: { + let native = MacTerminalTransport(scrollbackLines: 100) + originalNative = native + return native + }) + model.cacheModuleCapability(TerminalModuleCapability(feature: terminals), id: .terminalWorkspace, moduleID: .terminal) + model.workspaceSessionCoordinator.beginWorkspace(at: root) + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: model, rootURL: root, remote: nil) + let metadata = PluginWorkspaceHostService(contexts: contexts, + backendStateDirectory: { _ in root.appendingPathComponent("state") }, rootIDSecret: { Data("test".utf8) }) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(PluginTerminalHostService(contexts: contexts, metadata: metadata)) + dispatcher.beginSession(pluginID: "release-test", hostServices: ["create", "onExit", "reopen", "dispose"].map { "lithe/host/terminal/" + $0 }) + let exited = TestGate() + dispatcher.subscriptions.deliver = { _, _ in exited.open() } + let consent = model.hostMessages.$visible.sink { entries in + guard let entry = entries.first else { return } + Task { @MainActor [weak model] in model?.hostMessages.choose(entry.id, index: 0) } + } + func request(_ method: String, _ params: [String: ToolingJSONValue]) -> PluginHostRequest { + var params = params + params["context"] = .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]) + return PluginHostRequest(id: UUID().uuidString, method: "lithe/host/terminal/\(method)", service: "terminal", params: .object(params)) + } + do { + let created = try await dispatcher.dispatch(request("create", [ + "executable": .string("/bin/sh"), "arguments": .array([.string("-c"), .string("if [ ! -f started ]; then printf 'discard-secret-prefix\\nrecent\\n'; : > started; fi; read line")]), + "retainedContextBytes": .integer(bytes), "show": .bool(false), + ]), pluginID: "release-test").get() + guard case .object(let result) = created, case .string(let id)? = result["terminalID"] else { throw PluginHostArguments.invalid } + _ = try await dispatcher.dispatch(request("onExit", ["terminalID": .string(id)]), pluginID: "release-test").get() + try #require(await eventually { originalNative?.displayText(maximumBytes: 128).contains("recent") == true }) + weak var session = model.terminalSessions.first + if naturalExit { try originalNative?.send(Data("\n".utf8)) } + else { model.closeTerminalSession(try #require(session)) } + try #require(await exited.waitUntilOpen()) + #expect(model.terminalSessions.isEmpty) + #expect(session == nil, "A closed handle must not retain its native session") + #expect(originalNative == nil, "Even a zero-byte history budget must release the full renderer") + let reopened = try await dispatcher.dispatch(request("reopen", ["terminalID": .string(id), "show": .bool(false)]), pluginID: "release-test").get() + guard case .object(let restored) = reopened else { throw PluginHostArguments.invalid } + #expect(restored["restoredContextBytes"] == .integer(bytes == 0 ? 0 : 8)) + #expect(originalNative?.displayText(maximumBytes: 512).contains("discard-secret-prefix") == false) + _ = try await dispatcher.dispatch(request("dispose", ["terminalID": .string(id)]), pluginID: "release-test").get() + } catch { + consent.cancel(); model.hostMessages.dismissAll(.cancelled) + dispatcher.endSession(pluginID: "release-test"); contexts.close(owner: model) + await terminals.stopAllSessionsAndWait() + throw error + } + consent.cancel(); model.hostMessages.dismissAll(.cancelled) + dispatcher.endSession(pluginID: "release-test"); contexts.close(owner: model) + await terminals.stopAllSessionsAndWait() + } + + @Test + func longExecutionConsentKeepsEveryArgumentAndDirectoryInspectableAndCancellable() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("plugin-terminal-consent-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let settings = AppSettings(store: MemoryStore()) + let model = AppModel(settings: settings, services: MacServiceContainer(store: MemoryStore(), settings: settings, moduleLaunchMode: .safeMode).services) + model.workspaceSessionCoordinator.beginWorkspace(at: root) + let presented = TestGate(), finished = TestGate() + let observation = model.hostMessages.$visible.sink { if !$0.isEmpty { presented.open() } } + defer { observation.cancel(); model.hostMessages.dismissAll(.cancelled) } + let arguments = [String(repeating: "长参数", count: 1800), "last-argument\nwith-control"] + let pending = Task { @MainActor in + defer { finished.open() } + let access = try await model.hostCreatePluginTerminal(PluginTerminalLaunch(pluginID: "consent-test", title: "Client", + executable: "/bin/sh", arguments: arguments, workingDirectory: root, environment: [:], show: false), onExit: { _ in }) + await access.close() + } + defer { pending.cancel() } + try #require(await presented.waitUntilOpen()) + let entry = try #require(model.hostMessages.visible.first) + let details = try #require(entry.details) + let document = PluginHostMessageDetailsScrollView(text: details) + document.frame.size = NSSize(width: 300, height: 180) + let text = try #require(document.documentView as? NSTextView) + text.frame.size.width = 300 + #expect(text.string == details) + #expect(!text.isEditable && text.isSelectable) + #expect(document.hasVerticalScroller) + let manager = try #require(text.layoutManager), container = try #require(text.textContainer) + manager.ensureLayout(for: container) + #expect(manager.usedRect(for: container).height > document.frame.height) + let encoder = JSONEncoder() + #expect(details.contains(String(decoding: try encoder.encode(arguments), as: UTF8.self))) + #expect(details.contains(String(decoding: try encoder.encode(root.path), as: UTF8.self))) + pending.cancel() + try #require(await finished.waitUntilOpen()) + do { _ = try await pending.value; Issue.record("Cancelled consent started a terminal") } + catch is CancellationError { } + #expect(model.hostMessages.pendingCount == 0) + #expect(model.terminalSessions.isEmpty) } private func eventually(_ condition: @MainActor () -> Bool) async -> Bool { diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 7f67dd9a59..12b4059b2b 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -3002,10 +3002,14 @@ it, the workspace root is used. The native adapter rejects a canonical cwd outside that workspace. `environment` maps variable names to string overrides or `null` removals. Process-ownership scope variables cannot be overridden. `show` defaults to true; it selects the terminal in its own window without -raising a background window. Unknown options fail before any side effect. +raising a background window. With `show:false`, creation and reopening preserve +the current terminal selection and do not reveal the terminal panel or request +input focus; the native tab is mounted when the plugin explicitly calls `show`. +Unknown options fail before any side effect. Creation and reopening ask for execution consent with the program, argv and -working directory, then revalidate context and cancellation before launching +working directory, all available in a scrollable, selectable native text view, +then revalidate context and cancellation before launching through the existing PTY terminal module. The response waits for user choice without a machine-operation deadline, but cancellation/session/context closure still ends it. Native launch and process cleanup retain their own bounds. @@ -3045,6 +3049,12 @@ the snapshot is bounded by the same budget. It keeps `terminalID` and increments handles/history exist only in memory, not across app/backend restart or workspace close. No state is written to the app bundle or plugin package. +Natural exit and user tab close run the same bounded native cleanup before +retaining the budgeted snapshot and releasing the complete native session and +render buffer. The handle is `busy` during cleanup; its exit notification is +sent after cleanup, so a subscriber can reopen immediately. A zero-byte budget +retains no display text on any close or exit path. + `dispose` closes any live process and permanently forgets the launch settings and history. Ending the backend session, disabling/updating the plugin or closing/replacing its workspace does the same for only its owned terminals; diff --git a/shared/platform-feature-matrix/features/plugin-native-terminals.json b/shared/platform-feature-matrix/features/plugin-native-terminals.json index bad5885b91..6867d9657f 100644 --- a/shared/platform-feature-matrix/features/plugin-native-terminals.json +++ b/shared/platform-feature-matrix/features/plugin-native-terminals.json @@ -12,7 +12,9 @@ "macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift", "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", "macos/Tests/LitheTests/PluginTerminalHostServiceTests.swift", - "macos/Tests/LitheTests/PluginTerminalNativeTests.swift" + "macos/Tests/LitheTests/PluginTerminalNativeTests.swift", + "macos/Sources/Lithe/Views/Workbench/PluginHostMessageDetailsView.swift", + "macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift" ] }, "windows": { @@ -23,6 +25,6 @@ ] }, "owner": "Extensions / Terminal", - "verification": "运行 PluginTerminalHostServiceTests、PluginTerminalNativeTests 与 TerminalModuleTests:权限和归属、argv 原样、远程拒绝、取消和工作区关闭、真实 PTY create/close/reopen、UTF-8 历史字节预算、只恢复显示而不重放输入、dispose 释放及迟到 generation 隔离。在真实安装插件中核对本窗口终端标签、焦点、输入、Ctrl-C、缩放、用户关标签重开、插件停用与退出无残留进程;完成整机验收后再更新 verified。三个官方 SDK 在 lithe-plugin-docs 独立分支验证。", - "notes": "首版本机 macOS,复用现有终端 owner;终端权限要求 backend:2,方法由函数协议 3 能力发现。未开放 sendText/输出读取。close 保留内存句柄和默认 16 KiB(最多 256 KiB)显示上下文,reopen 启动新进程,dispose 或会话/工作区结束彻底释放;不跨重启持久化。" + "verification": "运行 PluginTerminalHostServiceTests、PluginTerminalNativeTests 与 TerminalModuleTests:权限和归属、argv 原样、远程拒绝、取消和工作区关闭、真实 PTY create/close/reopen、UTF-8 历史字节预算、只恢复显示而不重放输入、dispose 释放及迟到 generation 隔离。在真实安装插件中核对本窗口终端标签、焦点、输入、Ctrl-C、缩放、用户关标签重开、插件停用与退出无残留进程;完成整机验收后再更新 verified。三个官方 SDK 在 lithe-plugin-docs 独立分支验证。 回归验证 show:false 创建/重开保留已有活动终端;用户关标签与自然退出后,在退出通知前释放原生会话和完整渲染缓冲区,按 0/8 字节保留历史;长参数和工作目录可完整滚动、选择、复制,取消确认不启动终端。 本次在 Swift 6.3.3 + macOS 26.5 SDK 下运行 75 项相关 Swift 测试全部通过(含四种真实 PTY 退出释放场景);HTML/JUnit 逐项计时在 .artifacts/test-stability/plugin-terminal-review-fixes.*。完整安装插件的 GUI 验收仍 pending。", + "notes": "首版本机 macOS,复用现有终端 owner;终端权限要求 backend:2,方法由函数协议 3 能力发现。未开放 sendText/输出读取。close 保留内存句柄和默认 16 KiB(最多 256 KiB)显示上下文,reopen 启动新进程,dispose 或会话/工作区结束彻底释放;不跨重启持久化。 后台创建不改变当前选择;关闭与自然退出都先清理并释放原生缓冲区,再发送退出事件。执行确认的完整详情使用原生文本视口。 show:false 不挂载原生标签,显式 show 后才注册标签并选择,避免仅有编辑器终端时工具面板默认展示后台会话。" }