diff --git a/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md b/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md index adc9a8ceb4..a4e60ce9e4 100644 --- a/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md +++ b/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md @@ -8,6 +8,8 @@ 调试能力分三层。断点、线程与调用栈、变量是**必选层**:每个适配器、两个平台都必须做到,也是 #1210 的关闭条件。监视表达式与调试控制台是**推荐层**。寄存器、内存、反汇编、反编译源码、子会话等是**可选扩展**:按适配器协商的结果显示,各自单独推进,见[可选调试扩展](2026-10-25-optional-debug-extensions.md)。可选功能可以晚做,但新内核第一版的数据模型就要为它们留好位置,以后只做增量,不改已有结构。 +当前传输这轮只完成普通 TCP 连接与生命周期,复用 #1520,不实现代理、隧道、端口转发或 SSH 远端执行。这些网络通路单独设计,见[调试网络通路与普通 TCP 生命周期的边界](2026-10-27-debug-network-paths-and-tcp-lifecycle.md);#1525 当前仅有 attach 契约和 Rust 校验,不代表宿主已支持远端直连。 + ## 问题 ### 现在是两套内核,按语言接线 @@ -168,6 +170,94 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa - **端口请求沿用 Core 的期限和取消。** `workspace/executeCommand` 的写入走会话已有的后台发送路径,避免插件不读 stdin 时卡住主线程;停止调试取消对应的 Core operation,不终止共享语言服务器。请求前后核对工作区和会话身份,拒绝换工作区或服务器替换后的旧端口。不要只取消等待 TCP 的 Task,却留下端口命令继续执行。 - 被否方案:让宿主按 `adapterID == "java"` 特判端口获取。这正是要删除的东西。 +#### 远程 attach(连接到另一台机器上的被调试程序) + +“远程调试”要分清两条连接:IDE 通过 DAP 连接适配器(stdio 或 TCP),适配器再用调试器自己的协议连接被调试程序。JVM 的 JDWP、Node inspector、本机 gdb 连远端 `gdbserver` 属于后一条连接;本节契约把目标主机和端口写进适配器参数,适配器仍在本机,不放宽宿主 DAP 传输的 loopback 规则。 + +适配器也可以在远端:默认 debugpy `--listen` 暴露的是 DAP 适配器端点,需要 IDE 直接连接,不能仅给本机另起的适配器传入 `connect.host/port` 就算支持(见 G2)。`ssh host gdb -i=dap` 则是远端启动适配器、经 SSH 承载 stdio 的另一种方式。普通 TCP 不依赖 SSH;代理、隧道、转发和远端执行另做[独立网络方案](2026-10-27-debug-network-paths-and-tcp-lifecycle.md),不在本轮实施。 + +做法是给 `attach` 配置新增一种目标绑定 `targetBindings.connection`,目标种类 `connection`(`TARGET_KINDS` 里本来就有,之前没有事实和绑定)。 + +| 决定 | 内容 | 原因 | +| --- | --- | --- | +| 绑定的值是**目标参数路径** | `host`、`port`(须成对)、`address`(一个 `host:port` 字符串,IPv6 带方括号)、可选 `pathMappings`,值是适配器参数里的点分路径(最多 4 段)。事实来源是封闭的:`target.host`、`target.port`、`target.pathMappings` | 参数名和嵌套不同:java-debug 是 `hostName`/`port`,嵌套形状可以是 `connect.host`/`connect.port`,js-debug 是 `address`/`port`,gdb 是一个 `target` 字符串。若沿用 `program`/`processId` 的写法(键是固定的目标参数名),就得宿主为每个适配器记名字,也就回到按名字分支。这些参数名只是形状参考,尤其嵌套绑定不代表已实现 debugpy DAP 直连;Core 不内置任何一个,具体以各适配器文档和对应插件的验收为准 | +| 路径映射有四种形状 | `objects`:对象列表,`argument` 是列表名,`localKey`/`remoteKey` 是每项的键(debugpy 的 `pathMappings`、delve 的 `substitutePath`,后者键是 `from`/`to`);`first`:唯一一条映射写成两个字符串(js-debug 的 `localRoot`/`remoteRoot`);`dictionary`:以 `keyedBy`(`local` 或 `remote`)一侧为键的对象(CodeLLDB 的 `sourceMap`、PHP 的 `pathMappings`,都以远端路径为键);`pairs`:二元数组列表,`order` 是 `remoteLocal` 或 `localRemote`(lldb-dap 的 `sourceMap`,`[源, 目标]`) | 按 2026-10-27 对主流适配器文档的核对,映射参数就这四种写法(见下面的兼容性审计),且声明仍是纯数据 | +| 形状装不下的映射**拒绝**,不截断 | `first` 只接受一条映射,`dictionary` 在键的一侧不能出现两次同一个根,违反返回 `debug.target.invalid`(`target.pathMappings`);`plan.debugAdapter.remoteAttach.maxPathMappings` 告诉宿主最多可以放几行(`first` 为 1,其余 16,无映射为 0) | 和下一行同一理由:悄悄丢掉用户填的一条映射,断点就对不上源码。首版的 `first` 曾只取第一条,审计时发现与此原则矛盾,已改为拒绝 | +| 声明时拒绝目的地冲突 | 目的地路径与另一个目的地、`processId`/`cwd` 参数,或同一配置 `arguments` 里已有的名字相同、包含或被包含,清单就是 `plugin.manifest.invalid` | 启动参数里“默认值优先”。如果插件默认值里已经写了 `hostName`,宿主让用户确认的主机会被默认值盖掉,适配器连到用户没有确认的地方。这个风险必须在声明期堵死,运行期再检查只是兜底(`set_argument` 只写空位) | +| 回环判断只看字面量 | `localhost`(不分大小写)、`127.0.0.0/8`、`::1` 是 `remote: false`;其余全是 `remote: true`,包括 `0.0.0.0`、`::ffff:127.0.0.1`、`localhost.example.org` | Core 不解析域名、不开 socket。名字本身证明不了它在本机,把不确定的当成远端只会多问一次,反过来则会静默连出去 | +| 不新增权限 | `debug.attach` 已覆盖;`debug.attach` 的说明里写明它包含连接远程主机和端口 | 新权限只是把同一件事换个名字,安装审查已经能看到这个适配器会 attach | +| 每个新的非回环端点单独确认 | `startArguments` 返回 `connection: { host, port, remote }`,宿主对 `remote: true` 的端点弹确认,默认不记住 | 和摄像头/麦克风一样:网页或插件可以请求,但每次请求都不默认有权限;以后若加“记住”也只能由用户主动选 | +| 路径映射被不支持的适配器**拒绝**而不是忽略 | 适配器没有 `pathMappings` 绑定,目标却带了映射,返回 `debug.target.invalid`(`target.pathMappings`) | 悄悄丢掉用户填的映射,会话能启动但断点对不上源码,比启动失败更难排查 | +| 主机与路径的输入校验 | 主机是 DNS 名或 IP 字面量(1–253 字符,标签不超过 63 且不以 `-` 开头或结尾),不接受 scheme、端口、方括号、空白、用户名;全数字点分必须是合法 IPv4;端口 1–65535;映射最多 16 条,每个根 1–1024 字符且无控制字符,本地根必须是绝对路径,远程根不限制形状(它是远端的路径) | 这些字符串最终会进入适配器参数,宁可严格;远端的路径风格由对方决定,Core 不替它判断 | + +`plan.debugAdapter.remoteAttach` 返回 `{ pathMappings: bool, maxPathMappings: n }` 或 `null`,宿主据此决定显示“连接到远程主机”和映射列表以及最多几行,不必按适配器名字判断。 + +这一轮不做,且不是遗漏(需要契约的缺口统一在下面的兼容性审计里编号、写明触发条件): +- IPv6 zone ID(`fe80::1%en0`)和国际化域名:后者用 punycode;前者依赖本机网卡,写进可分享的配置不可移植。 +- 网络代理、隧道和端口转发:用户可以自行提供可达端点,但能否使用仍取决于对应宿主入口;不能把契约校验写成已完成的 attach 功能。Lithe 本轮不创建、配置或清理这些网络设施。 +- SSH 远端执行:现有 SSH + stdio Note 暂留为子方案草案,后续与独立网络方案一起评审,不作为本轮 TCP 的前置或下一项交付。路径映射优先复用上游能力,不预设远端适配器必须由 Core 翻译。 +- 绑定键等于参数名的写法:见上表第一行,已否决。 + +#### 兼容性审计:契约能装下哪些调试器,缺口在哪 + +2026-10-27 按各调试器的上游文档逐项核对“附加到远端程序”的参数写法,要回答的是“这个契约装不装得下主流调试器”,而不是等某个插件撞上缺口再改契约。**参数形状能表达不等于连接方式可用**:以下逐项区分现有绑定、尚缺的表达方式与尚未实现的宿主入口,不作“全部覆盖”的承诺。 + +“核对状态”一列区分来源:**已核对**是读到了上游文档或 README 的参数表;**待核验**来自记忆或搜索摘要,没有读到原文,只能当设计参考,对应插件落地时必须按文档重新核对,这一列不是验收结论。参数名同样不由 Core 内置,具体以适配器文档和插件验收为准。 + +| 调试器(适配器) | 附加到远端程序的参数写法 | 契约现状 | 核对状态 | +| --- | --- | --- | --- | +| Java(java-debug,JDWP) | `hostName`、`port` | 覆盖:平铺 `host`/`port`;没有路径映射参数 | 已核对 | +| Python(debugpy) | 默认 `--listen host:port` 启动远端 DAP 适配器;attach 的 `pathMappings` 为 `[{ localRoot, remoteRoot }]` | 映射形状可表达;标准 `--listen` 场景缺 IDE 直连 DAP 端点入口,见 G2。嵌套参数绑定本身不能代替该连接;反向模式见 G4 | 已核对 debugpy 1.8.22 的适配器启动;反向模式待插件实测 | +| Node(js-debug) | `address`、`port`、`localRoot`、`remoteRoot`、`processId`、`websocketAddress` | 覆盖:平铺加 `first`。`websocketAddress` 见 G5 | 已核对 | +| 浏览器(js-debug chrome) | `address`、`port`、`pathMapping`、`webRoot`、`urlFilter` | 连接部分可表达,页面 URL 与 `webRoot` 不可,见 G7 | 已核对 | +| Go(delve) | `mode: "remote"`、`host`、`port`、`substitutePath: [{ from: 本地, to: 远端 }]` | 覆盖:`mode` 放默认 `arguments`,其余走平铺加 `objects`(`localKey: from`、`remoteKey: to`)。直连远端的 `dlv dap --listen`(不起本地适配器)见 G2 | 参数已核对;本地适配器连远端的组合待 Go 插件实测 | +| C/C++/Rust(lldb-dap) | `sourceMap: [[源, 目标], …]`;gdb-remote 的主机字段名在其 README 里前后不一 | 映射覆盖:`pairs`(`remoteLocal`)。主机字段名未定 | 映射已核对;主机字段名待核验 | +| C/C++/Rust(CodeLLDB) | `sourceMap: { 远端: 本地 }`;连 gdbserver 用 `processCreateCommands: ["gdb-remote host:port"]` | 映射覆盖:`dictionary`(`keyedBy: remote`)。主机端口嵌在命令字符串里,写不进,见 G6 | 已核对 | +| GDB(`gdb -i=dap`) | attach 接受 `pid` 或 `target` 字符串;`target` 使用裸 `host:port`,GDB 自行拼 `target remote` | 覆盖:`address` 绑定写入 `target`,不额外加 `remote ` 前缀 | 已核对 GDB 16.3 的 `gdb/dap/launch.py`;仍需插件运行验收 | +| .NET(netcoredbg) | `--interpreter=vscode`(stdio);TCP 模式 `--server[=port]`,默认 4711,不宣告实际端口 | stdio 覆盖;TCP 需要宿主分配空闲端口,见 G1 | 已核对 | +| PHP(Xdebug 适配器) | 适配器**监听** 9003,等 PHP 进程连进来;`pathMappings: { 服务器路径: 本地路径 }`;`hostname` 是监听地址 | 映射覆盖:`dictionary`(`keyedBy: remote`)。监听方向见 G4 | 已核对 | +| Dart / Flutter | stdio;attach 用 `vmServiceUri` 或 `vmServiceInfoFile` | stdio 覆盖;URI 目标见 G5 | 已核对 | +| Godot | 编辑器内置 DAP 服务器,端口 6006,没有适配器进程 | 见 G2 | 已核对 | +| C/C++(cpptools) | `pipeTransport` 加 `sourceFileMap` | 这是“适配器在远端”的先例,由下一节和独立 Note 承接;导入规则见 G10 | 已核对 | +| Ruby(rdbg)、Kotlin 适配器、PowerShell Editor Services、Scala(Metals) | 来自记忆:rdbg 的 DAP 配置、Kotlin 适配器参数、PSES 的命名管道、Metals 经 LSP 命令取得适配器端点 | 未判断,涉及 G3、G8 | 待核验 | + +缺口只记录,不预先实现。每一条写明现状、决定和触发条件,避免下一个写插件的人重新猜: + +| 编号 | 缺口 | 现状 | 决定 | 触发条件 | +| --- | --- | --- | --- | --- | +| G1 | 适配器的 TCP 模式需要空闲端口且不宣告(netcoredbg 的 `--server=`) | `tcpPort` 只接受清单里写死的回环端口,固定端口会冲突 | 以后加“宿主分配空闲回环端口、替换参数里的 `{port}` 占位符”的传输 | 第一个只能走 TCP 的适配器。它们大多也支持 stdio,所以不阻塞 | +| G2 | 连接一个已经在运行的 DAP 服务器,没有本机适配器进程(debugpy `--listen`、Godot、`dlv dap --listen`) | `launch.process` 对除 `tcpLanguageServer` 外的传输必填;已有 LSP 端点仅为回环端口 | 后续扩展用户提供的 DAP 端点入口(暂称 `tcpConnect`),复用 #1520 的 socket 生命周期,不另写 TCP。DAP 端点不混用 debuggee 的 `connection`,不绑定 `attach` 请求;非回环须确认,不放宽 LSP 自动端点限制 | 对应插件接入直连时单独完成契约、宿主和运行验收;不等待代理或 SSH 方案 | +| G3 | Unix socket、Windows 命名管道 | 只有 stdio 和回环 TCP | 不做:它们是新的传输种类,命名管道还要平台各自实现 | 有适配器只提供这种传输 | +| G4 | 反向 / 监听 attach:适配器监听、被调试程序连进来(PHP Xdebug、debugpy 的 `--connect`、JDWP 的 `server=n`) | `connection` 的 `host`/`port` 是“连出去”的语义 | 单独建模:需要“只有端口”的绑定,默认只绑回环,绑定非回环地址要单独确认,因为这是在本机开放端口;不给 `host` 加一个反向开关,两种方向的安全语义不同 | 第一个 PHP 插件。这是 Xdebug 的标准用法,不是边角,优先级高于其他缺口 | +| G5 | 目标是 URI 而不是主机端口(Dart 的 `vmServiceUri`、js-debug 的 `websocketAddress`) | `connection` 只有主机端口 | 以后加 `uri` 绑定和 `target.uri` 事实,scheme 白名单(`ws`、`wss`、`http`、`https`),回环仍只按字面量判断;不尝试从 URI 里拆主机端口 | 第一个 Dart 或需要 websocket 的插件 | +| G6 | 主机端口嵌在命令字符串里(CodeLLDB 的 `processCreateCommands`) | 绑定的值写成一个独立参数 | 以后加 `template`(如 `"gdb-remote {address}"`),占位符封闭为 `{host}`、`{port}`、`{address}`,且路径要支持数组下标;写入的值已经过主机校验 | CodeLLDB 插件 | +| G7 | 浏览器调试(`url`、`webRoot`、`urlFilter`,目标是页面不是进程或端口) | 没有 `url` 目标种类 | 不在本契约内:它是新的目标种类,页面地址涉及隐私和权限,另开提案 | 有人要做浏览器调试插件 | +| G8 | 启动适配器要带参数的 LSP 命令(Metals 一类) | `tcpLanguageServer.command` 只有命令名,没有参数 | 若插件确实需要,扩展“清单里写死的固定参数”,让用户随安装一起审查;不允许宿主在运行时拼接参数 | 先核验 Metals 的真实调用方式,再决定 | +| G9 | TCP 子会话(多进程 Python 的 debugpy 通过 `connect` 开子会话) | 子会话端点被忽略,已记录在上面 P1 的“多会话”一节 | 属于 DAP 会话层,不是本契约;由具体语言插件覆盖 `makeChildSession` | Python 插件要求多进程调试时 | +| G10 | 导入 VS Code `launch.json` 里的远程字段(`pipeTransport`、`miDebuggerServerAddress`、`debugServer`、`listen`) | 导入器还不存在 | 导入时**拒绝并说明**是哪个字段、为什么、该用哪个入口,不悄悄忽略;与“映射装不下就拒绝”同一原则 | 做导入器时 | +| G11 | 许可条款限制使用场景的适配器 | 契约不内置任何适配器 | 不在契约层处理:能否随插件分发、能否由用户自行安装,是插件作者和用户的合规判断;插件作者应在插件说明里写明适配器的来源和许可 | 无 | + +所以当前只是兼容性盘点,不是“兼容所有调试器”的验收结论:四种映射形状与平铺 / 嵌套 / 联合地址解决部分参数表达,直连 DAP、监听方向、命令模板等缺口仍须按 G1–G11 分别落地。 + +后续路线:先收敛普通连接和生命周期,不将网络通路扩展排成紧随其后的必做步骤。 + +| 顺序 | 内容 | 依赖 | +| --- | --- | --- | +| 1 | 本 PR:契约、Core 校验、四种映射形状、拒绝 fixtures | 无 | +| 2 | 普通 TCP 正常连接与生命周期:复用 #1520,覆盖收发、取消、超时、失败、断开、过期回调和资源所有权 | 不依赖任何代理、隧道或 SSH 方案;具体要求见独立网络边界 Note | +| 3 | macOS 宿主接入 `attach`,通用对话框与非回环端点确认;G2 按真实插件需求单独接入 | 契约与普通 TCP 基础;外部 DAP 入口不能假定已存在 | +| 4 | G4(监听 attach)及 G1、G5、G6、G8 | 对应插件出现并核对文档;不挤入本轮 TCP | +| 5 | Windows 宿主 | Windows 的 P2–P4 先开始 | +| 独立后续 | 代理、隧道、端口转发、SSH 远端执行 | 单独网络方案评审,无本轮交付承诺 | + +#### 暂缓的独立网络子方案:适配器在远端(SSH + stdio) + +候选形态:宿主经系统 `ssh` 在远端执行适配器命令(如 `gdb -i=dap`),DAP 仍走 stdio;认证、加密、主机指纹交给 ssh,复用已有的 `TransportChain`。这不是普通 TCP 的唯一远程方式,也不能据此说“不经 SSH 就不能调试”。裸 TCP 不自带认证和加密,本轮只在用户认可的可达网络内使用,不推荐将调试端口暴露到公网。 + +目的地、权限、路径映射、诊断和生命周期的探索见 [SSH 草案](2026-10-27-remote-debug-adapter-over-ssh.md)。它暂缓实现,原来的四项默认选择也只是候选,不因确认就自动进入本轮;先完成独立网络方案评审,并优先复用适配器的原生路径映射,避免双重转换。 + +宿主侧还没有接入:macOS 的 `attach` 仍被拒绝,Java 的 `JavaAttachView` 是仅限 Java 的界面。后续由一个通用的“附加到远程”对话框取代它:主机、端口、可选映射、非回环确认,文案走 `shared/i18n`。Attach 会话结束时发送 `terminateDebuggee: false`,只断开不结束远端程序,沿用上面“`attach` 默认只断开”的所有权规则。 + P0–P5 完成后关闭 #1210。可选扩展、插件依赖和私有 resolver 都不是关闭条件。 ### 正确做法 @@ -249,3 +339,4 @@ P0–P5 完成后关闭 #1210。可选扩展、插件依赖和私有 resolver - [公共扩展契约](2026-10-13-public-extension-contract-and-provider-api.md)、[能力版本](../../implemented/architecture/2026-10-17-plugin-capability-versions.md)、[插件函数契约](../../implemented/architecture/2026-10-20-plugin-function-contract.md)。 - [Java 构建与启动边界](../../implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md)、[Java Debug 使用 Run 的 JDK](../../implemented/bug-fix/2026-10-14-java-debug-uses-the-run-jdk.md)、[Windows Java 调试生命周期](../../implemented/bug-fix/2026-10-07-windows-java-debug-lifecycle.md)。 - [DAP 标准](https://microsoft.github.io/debug-adapter-protocol/overview)、[DAP 定义](https://github.com/microsoft/debug-adapter-protocol/blob/main/debugAdapterProtocol.json)、[GDB DAP](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Debugger-Adapter-Protocol.html)、[LLDB DAP](https://lldb.llvm.org/use/lldbdap.html)。 +- [debugpy 1.8.22 的 listen 启动适配器](https://github.com/microsoft/debugpy/blob/v1.8.22/src/debugpy/server/api.py#L189-L225)、[GDB 16.3 的 attach target 处理](https://github.com/RTEMS/sourceware-mirror-binutils-gdb/blob/gdb-16.3-release/gdb/python/lib/gdb/dap/launch.py#L129-L145)。 diff --git a/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md b/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md new file mode 100644 index 0000000000..f449853fce --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md @@ -0,0 +1,101 @@ +# Agent 笔记:调试网络通路与普通 TCP 生命周期的边界 + +状态:提议中 + +## 先说结论 + +当前调试传输这轮只做普通 TCP(可靠的双向字节流)连接和生命周期,复用 [#1520](https://github.com/1lck/Lithe-IDEA/pull/1520) 的 socket 基础,不另写一套 TCP 实现,也不取代标准输入输出(stdio)传输。代理、隧道、端口转发和远端执行是独立的网络通路方案,本轮不实现,不作为普通 TCP 的交付前置。 + +远程调试不等于必须使用 SSH:端点可以直接可达,也可以经用户自己配置的 VPN 或隧道可达;Lithe 本轮只连接最终可达的端点,不创建或管理这些网络设施。[#1525](https://github.com/1lck/Lithe-IDEA/pull/1525) 当前只实现远程 attach 的契约与 Rust 校验;本 Note 记录后续连接实现的边界和验收要求,不表示宿主已经支持全部场景。 + +## 问题 + +一次远程调试可能有两条不同的连接,不能把它们的主机、端口和权限混成一个目标: + +```text +IDE -- DAP(调试适配器协议,stdio 或 TCP)--> 调试适配器 +调试适配器 -- 调试器自己的协议 --> 被调试程序 +``` + +- Java:IDE 可以通过回环 TCP 连接 JDT LS 内的 Java Debug Server;适配器再通过 Java 调试线协议(JDWP)连接远端 JVM。`targetBindings.connection` 绑定的是后一条连接的参数。 +- GDB:IDE 可以通过 stdio 连接本机 `gdb -i=dap`,GDB 再通过 GDB Remote 协议连接远端 `gdbserver`,并非把 DAP 发给 gdbserver。 +- Python:默认 `python -m debugpy --listen host:port` 会启动 DAP 适配器,IDE 需要直接连接该 DAP 端点。只在本机另起适配器并填入 `connect.host/port`,不能据此宣称这种模式可用。 + +连接方式与 DAP 的 `launch` / `attach` 请求也是两个维度:连接一个已有 DAP 服务,不等于发送 `attach`,更不等于已经完成 SSH 或代理配置。 + +## 提案 + +### 本轮只交付普通 TCP 与生命周期 + +| 边界 | 开发动作 | +| --- | --- | +| 复用已有传输 | 复用 #1520 的 socket 接口、异步端点解析和会话清理思路;`tcpLanguageServer` 从同插件 LSP 命令获取回环端口。现有实现不是任意远端 DAP 的直连入口。 | +| 不混用端点 | 自动取得的语言服务器端点继续限制在回环。未来用户提供的外部 DAP 端点要有独立来源、确认和校验,不能直接把 attach 目标的 `host/port` 当作 IDE 的 socket 地址。 | +| 正常连接与收发 | 明确连接准备、连接中、可收发、停止和失败;有界等待端点解析、建连及停止,连接成功前的写入有顺序和资源上限,读写失败不静默丢弃。 | +| 取消与过期结果 | 解析端点、建连、等待响应时都能取消。回调绑定会话和连接代次,旧连接晚到的成功、数据或失败不能改变新会话,也不能重新打开已停止的连接。 | +| 断开与清理 | EOF、连接失败、读写失败、超时、用户停止都进入同一条幂等清理链;关闭 socket,撤销回调和计时器,清空待发数据,终止通知只发一次。失联不承诺瞬时可知,也不自动重放请求或重连会话。 | +| 按所有权停止 | 仅清理本会话创建的 socket、适配器进程和其他资源。共享语言服务器、用户启动的外部 DAP 服务、attach 目标和用户网络设施不归 socket 所有;attach 默认只断开,不结束被调试程序。 | + +会话可以持续任意长时间,不设置调试会话总时长;有界的是准备、建连、请求和停止等操作。哪些错误属于传输失败,哪些属于 DAP 请求失败,要保留各自原因,不能把连接失败误报为适配器参数无效。 + +### 独立网络方案:仅记录后续问题 + +网络通路指“让某个端点可达的办法”,不负责 DAP 帧、语言语义或调试器参数。本轮不新增代理、路由或隧道的声明字段,也不把所有远程调试统一成 SSH。 + +后续独立方案再决定: + +- 直连、SOCKS / HTTP CONNECT 代理、SSH 隧道 / 跳板和平台转发如何选择和组合;先核对可复用的系统工具、远程工作区能力及各平台限制,不自研代理协议栈。 +- 访问的是 IDE → 适配器,还是适配器 → 被调试程序。后一条由适配器所在机器发起,本机代理设置不会自动让它走同一条通路。 +- 目的地、认证、主机身份、权限、用户确认和诊断怎样表达;敏感信息不得进入普通日志,不能承诺会话重连或网络自动恢复。 +- 谁创建通路、谁持有它、是否共享,以及取消或失败后如何回滚已创建的监听端口、转发连接与辅助进程。 +- 远端执行与路径映射如何协作。优先使用适配器已有的映射能力;同一对路径空间只转换一次,不因为适配器在远端就强制 Core 再翻译一遍。 + +已有[远程开发的传输链、隧道与端口转发](2026-10-24-remote-development-workspace-host.md)是后续复用候选,不代表调试产品已经接入或具备完整的托管生命周期。[SSH + stdio 草案](2026-10-27-remote-debug-adapter-over-ssh.md)只是其中一个暂缓的子方案,不再是本轮的下一项交付。 + +### 正确做法与反例 + +- 正确:用户自己建立隧道,提供最终端点;在对应端点入口已实现后,Lithe 按普通 TCP 连接,结束时只关闭自己的 socket。 +- 正确:Java 结束 attach 时断开 DAP 会话并释放 socket,但不停止共享 JDT LS 或远端 JVM。 +- 不要:把 `tcpLanguageServer` 的回环检查放开,就宣称支持任意远程 DAP 服务。 +- 不要:在 TCP 的建连函数里临时拉起 `ssh -L`、解析代理密码,或在停止时杀死用户的隧道。 + +## 考虑过的备选方案 + +- **把代理、SSH 和转发一起做进本轮 TCP。** 看起来一次覆盖远程调试,但认证、监听暴露和共享资源的清理远超 socket 生命周期,还会重复已有远程工作区能力。改为独立方案。 +- **所有调试都改成 TCP 或经 SSH 的 stdio。** 接口看似单一,但 GDB 的 stdio、Java 的回环 DAP 与 debugpy 的远端 DAP 都有合理用法;保留传输选择,不强迫适配器换协议。 +- **为远端直连另写一套 TCP 传输。** 容易重复建连、取消和过期回调处理。端点来源可以扩展,底层 socket 和生命周期应复用;扩展入口未实现时仍明确报告不支持。 + +## 验收标准 + +- 正常连接可有序双向收发;端点解析失败、拒绝连接、建连超时、读写失败、EOF 都有明确结束结果。 +- 取消覆盖端点解析中、建连中和连接成功后;停止后到达的回调全部失效,重复停止不重复通知、释放或污染下一次连接。 +- 待发数据、操作等待和清理都有界;在对端不读、静默失联或停止期间不会无限积压,也不依赖自动重连掩盖失败。 +- 结束后 socket、回调、任务及本轮创建的进程被释放;外部 DAP 服务、共享语言服务器、attach 目标与用户的 VPN / 隧道保持不变。 +- #1520 的回环端点限制不放宽,stdio 行为不退化;外部 DAP 直连只有对应入口实现并验收后才能标为支持。 +- 不引入代理认证、托管隧道、端口转发或 SSH 远端启动代码;这些能力另行评审,不阻塞本轮 TCP。 + +## 风险 + +- 普通 TCP 不提供认证和加密。最终端点必须位于用户认可的受保护网络,不能把裸调试端口推荐为公网服务。 +- 网络可达不等于协议匹配或源码路径可用。真实适配器验收必须核对端点协议、DAP 请求和上游路径映射。 +- 复用现有 socket 不代表生命周期要求已全部满足;有界写入、失联策略和清理必须通过实际实现及测试证明,不能靠设计文档标为已完成。 + +## 验证 + +本轮仅补充设计:运行 `./scripts/verify-agent-notes.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-platform-feature-matrix.sh` 和 `git diff --check`,不据此声称有运行时连接证据。 + +后续实现先加载 `write-stable-tests`,用可控 socket 和有界等待覆盖验收标准,再执行对应平台测试与真实适配器流程;同步平台功能矩阵,验证发行包只读,清理本次启动的所有进程。 + +## 适用范围 + +- `rust/lithe-core/src/debug/` +- `macos/Sources/Lithe/Platform/MacOS/Debug/` +- `windows/tauri/src-tauri/src/debug.rs` +- `shared/contracts/plugins/v1/` +- `rust/lithe-remote-client/src/transport.rs` +- `rust/lithe-remote-client/src/forward.rs` + +## 依据 + +- [debugpy 1.8.22 的 listen 会启动适配器](https://github.com/microsoft/debugpy/blob/v1.8.22/src/debugpy/server/api.py#L189-L225)。 +- [统一调试内核与远程 attach 兼容性审计](2026-10-25-unified-debug-engine-and-adapter-registry.md)。 diff --git a/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md b/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md new file mode 100644 index 0000000000..5b3904b3ff --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md @@ -0,0 +1,237 @@ +# Agent 笔记:适配器运行在远端——经 SSH 的 stdio 调试 + +状态:提议中 + +## 先说结论 + +本 Note 是**暂缓实现的 SSH 子方案草案**:当前这轮只做普通 TCP 连接和生命周期,代理、隧道、端口转发与 SSH 远端执行另行评审,范围以[独立网络边界 Note](2026-10-27-debug-network-paths-and-tcp-lifecycle.md)为准。下文的声明、默认值和实施阶段均为候选,不是已批准的契约或本轮交付。 + +候选形态是**适配器本身跑在远端**:例如 `ssh host gdb -i=dap`,DAP(Debug Adapter Protocol,编辑器和调试器之间的标准协议)照旧走标准输入输出,中间由 ssh 承载。认证、加密、主机指纹交给系统 `ssh`,命令复用已有传输链(`TransportChain`,见[远程开发 Note](2026-10-24-remote-development-workspace-host.md));目的地由用户确认,路径映射优先复用适配器原生能力,不强制 Core 重复翻译。 + +[#1525 的远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)目前只有契约和 Rust 校验,宿主尚未接入 attach,不能称为完整远程调试已经落地。普通 TCP 也不依赖 SSH;默认 debugpy `--listen` 的远端 DAP 直连与这里的 SSH 远端启动是不同场景。 + +本 Note 里出现的适配器命令行(`gdb -i=dap`、`lldb-dap` 等)和参数名只是设计举例,不是验收结论:兼容性审计里标了“已核对”的才读过上游文档,标“待核验”的来自记忆,插件落地时必须按文档重新核对。 + +## 问题 + +以下比较本机适配器和经 SSH 启动的远端适配器;不是所有远程调试方式的全集: + +| | 本机适配器连远端程序(已有 `targetBindings.connection` 契约与校验) | 经 SSH 启动远端适配器(本 Note) | +| --- | --- | --- | +| 适配器进程在哪 | 本机 | 远端,经 ssh 的 stdio | +| 典型场景 | JVM 开了 `-agentlib:jdwp`、Node `--inspect`、本机 GDB 连 gdbserver | `ssh host gdb -i=dap`、远端的 lldb-dap | +| 路径 | 按适配器的客户端路径约定处理,可能需要原生映射 | 同样先核对原生映射;并非所有远端适配器都会向客户端返回远端路径 | +| 需要新东西 | 目的地参数绑定 | 命令怎么在远端执行、目的地由谁给、路径怎么翻译 | + +不处理的后果:用户在编辑器里看不到远端 gdb 的栈帧源码(路径对不上)、断点设不到远端、或者为了绕开这些问题自己手写 `ssh host '…'` 脚本——这正是[远程开发 Note](2026-10-24-remote-development-workspace-host.md) 明确禁止在功能代码里出现的做法。 + +## 提案 + +### 两种候选模型,未来评审时比较 + +- **模型 A:完整远程工作区。** 项目和适配器都在远端,通过 `lithe-remote-server` 访问,路径从头到尾都是远端路径,不需要翻译。这是[远程开发 Note](2026-10-24-remote-development-workspace-host.md) 的 M4(“插件声明运行位置,LSP 和调试适配器在远端运行”),产品界面还没有接入。 +- **模型 B:源码在本地,只有适配器和被调试程序在远端。** 用户已经有本地工程,想用一台远端机器(Linux 开发板、服务器、不同架构的机器)上的 gdb 调试,不想把整个项目搬过去。这是用户提到的 gdb 跨机器场景。需要路径翻译、需要用户提供的目的地,不需要在远端安装 `lithe-remote-server`。 + +本 Note 探索模型 B。若后续采用,优先复用 `TransportChain::command`,不再写第二套 ssh 参数构造;哪些声明、确认和路径处理可以与模型 A 共用,需在独立方案里验证,不能预先承诺完全相同。 + +数据流: + +```text +编辑器(本地路径) + ⇄ Core:DAP 客户端(按上游约定选择路径映射责任方) + ⇄ 本机 ssh 进程(stdin/stdout 管道) + ⇄ 加密网络 + ⇄ 远端 sshd → 适配器(远端路径)→ 被调试程序 +``` + +### 谁负责什么 + +| 事项 | 负责方 | 说明 | +| --- | --- | --- | +| 声明“这个适配器可以在远端运行”,以及远端要执行的命令 | 插件(清单数据) | 只有一个命令名加固定参数,不含目的地,不含任何用户输入 | +| 目的地 `[user@]host[:port]`(或 ssh 配置里的别名)、路径映射、远端程序路径 | 用户 | 在“在远程主机上调试”对话框里填;每次启动前确认 | +| 校验目的地与映射、通过传输链拼命令、必要的路径翻译、ssh 失败分类、首帧前噪声丢弃 | Rust Core | 候选共享规则;原生映射优先,同一对路径空间不重复转换 | +| 定位并拉起系统 `ssh`、显示确认弹窗、进程树清理、退出确认 | 平台 | 沿用现有 `MacProcessDebugAdapterTransport` 一类的进程所有权规则 | +| 认证、加密、主机指纹、跳板(ProxyJump)、`~/.ssh/config` | 系统 `ssh` | 与用户在终端里手动 ssh 的行为一致 | + +### 清单怎么声明(草图,实现时再对照 schema 定稿) + +适配器只多声明一个“允许在远端运行”的标记,命令仍是现有的 `systemCommand`,只是此时 `commands[]` 在**远端**的 `PATH` 上解析,而不是走本机的工具发现: + +```json +"launch": { + "process": { + "launch": { "kind": "systemCommand", "commands": ["gdb"] }, + "arguments": ["-i=dap"] + }, + "transport": "stdio", + "remote": { "via": ["ssh"] } +} +``` + +声明期校验(违反则清单为 `plugin.manifest.invalid`,与[远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)里“目的地冲突在声明期拒绝”同一思路): + +- `remote` 只允许和 `launch.transport: stdio` 同时出现;`tcpPort`、`tcpAnnounced`、`tcpLanguageServer` 的端点是本机回环,放到远端没有意义,直接拒绝。 +- `launch.process.launch.kind` 只能是 `systemCommand`,且 `commands[]` 恰好一个裸命令名。`packaged`(包内可执行文件)、`runtime`、`runSelectedRuntime` 都是“本机解析出的路径”,对远端没有意义。 +- `arguments[]` 只能由固定字符串组成,字符限于 `[A-Za-z0-9._/=:+@%,-]`,不允许 `${…}` 模板展开。这样用户输入永远不会进入远端命令行。 +- 声明了 `remote` 的适配器不得声明 `process.environment`、`process.workingDirectory`:它们在远端没有明确含义,需要时由适配器协议内部的 `launch` 参数传递(走 DAP JSON,不走命令行)。 +- 需要权限 `debug.remote`(见下),并且 `debug.launch`/`debug.attach` 照常需要。 + +`plan_descriptor` 增加 `remoteRun: {via: ["ssh"], pathMappings: bool}` 或 `null`,宿主据此决定是否显示“在远程主机上调试”,不按适配器名字判断。这与已有的 `remoteAttach` 并列,不合并:两者回答的问题不同。 + +### 命令怎么构造 + +1. 宿主把用户填的目的地交给 Core。Core 用 `validate_ssh_destination` 同样的规则校验(非空、不以 `-` 开头、无空白与控制字符),再解析成 `SshHop { program, destination, port, options }`。 +2. `program` 是平台定位到的系统 `ssh`(平台负责,因为 macOS 和 Windows 的位置与发现方式不同)。 +3. `options` 固定加入:`BatchMode=yes`(见下“认证”)、`ConnectTimeout=10`、`ClearAllForwardings=yes`、`ForwardAgent=no`、`ForwardX11=no`。命令行里的 `-o` 优先于 `~/.ssh/config`,所以用户配置里的 `LocalForward`、`ForwardAgent` 不会被带进调试会话;`ProxyJump`、`ProxyCommand` 照常生效,与用户在终端里的行为一致。 +4. 用 `TransportChain::command(&[command, arguments…])` 得到最终 argv。链已经负责:`-T`(不分配 PTY)、`-e none`、`ServerAliveInterval=15`/`ServerAliveCountMax=3`、`--` 后接目的地、对远端命令的 shell 引用与对 `'`、`\`、`!`、控制字符的拒绝。**Lithe 的调试代码里不写任何一行 shell 引用。** +5. 用户输入的远端程序路径(`launch.program`)和远端进程号只出现在 DAP JSON 里,不进入命令行。 + +### 路径翻译 + +**先核对上游映射语义,不因适配器在远端就强制 Core 翻译。** 例如 debugpy 的 `pathMappings.localRoot` 表示客户端路径前缀,不要求它存在于适配器所在机器;它已经可以转换客户端与远端路径。之前把适配器映射一律解释成“编译路径到适配器文件系统”,并据此断言必须由 Core 再翻译,是不准确的。 + +每对路径空间只设一个转换责任方:优先把映射交给支持该语义的适配器;只有上游能力不足且真实流程证明需要时,才考虑 **Core 会话层**的纯字符串翻译。平台继续负责文件授权。以下是该回退方案的候选规则,不是所有 SSH 会话都要启用: + +- **映射表**:用户在对话框里填,最多 16 条 `local ↔ remote`;本地根必须是绝对路径,远端根必须是 POSIX 绝对路径(第一版不支持 Windows 远端,见“不做”)。 +- **匹配规则**:最长前缀优先,只在路径组件边界匹配(`/work/app` 不匹配 `/work/application`),区分大小写,规范化多余的 `/`,不解析符号链接(Core 不碰文件系统)。 +- **出站**(本地 → 远端,仅在构造发给适配器的 DAP 帧时转换):`setBreakpoints` 请求里的 `source.path`,以及由本地目标事实绑定出来的路径(`target.path`、`target.workingDirectory`)。用户**直接当作远端路径输入**的值(`executable` 目标的程序路径、远端进程号)不翻译。断点库、断点重定位、投影都只保存本地路径,不会因翻译而漂移。 +- **入站**(远端 → 本地):`stackTrace` 帧的 `source`、`breakpoint`/`loadedSource` 事件中的来源、`output.source`,在 `map_source` 之前先翻译,之后沿用现有“工作区相对路径 → 会话来源(`sourceReference`)→ 无源码”的回退顺序;匹配“已解析断点”也先翻译再比较。 +- **映射不到本地就不猜。** 回退到现有的无源码或 `sourceReference` 行为,界面显示远端原路径。`modules` 里的二进制路径按原样显示。 +- **与适配器自带映射的关系**:逐个核对 `substitutePath`、`sourceMap`、`pathMappings` 等参数的路径方向和客户端约定,不能只看字段名。编译路径到运行路径、运行路径到客户端路径确实不同且必要时才允许分层;同一对根不能同时交给上游和 Core 转换。 +- **上游没有合适的映射时**:不能直接声称可用。先为回退方案记录上游限制,覆盖断点和栈帧的双向流程,再决定契约与实现;本 Note 不提供已实现的通用翻译能力。 +- 附加(`attach`)在 ssh 模式下,`connection` 的主机和端口按**远端机器的视角**解释:例如远端的 gdb 连同机的 `localhost:1234` 的 gdbserver。确认弹窗同时显示 ssh 目的地和这个端点。 + +### 认证、主机指纹与确认 + +- **第一版只支持密钥和 ssh-agent**,用 `BatchMode=yes`:任何需要交互输入(密码、口令、2FA)的情形直接失败并给出说明,不弹终端、不卡住。密码和口令通过 `SSH_ASKPASS` 回传到 Lithe 弹窗,等[远程开发 Note](2026-10-24-remote-development-workspace-host.md) M2 的 askpass 入口落地后再接,不在这里单独做一套。 +- **不关闭主机指纹校验**:不传 `StrictHostKeyChecking=no`,也不传 `accept-new`。未知主机直接失败,错误里说明“请先在终端里 ssh 一次确认指纹”。理由:调试适配器会在这台机器上以用户身份执行命令,静默接受未知主机等于让中间人替用户执行命令。 +- **新权限 `debug.remote`**:它不是“连一个端口”,而是“以你的身份在你选择的另一台机器上执行一个命令”,比 `debug.attach` 严重得多。安装审查里单独列出(“在你指定的远程主机上运行 `gdb -i=dap`”);声明的命令变化,需要重新批准。声明 `remote` 的适配器必须同时有 `debug.launch`,用于 attach 时还需要 `debug.attach`。 +- **每次启动都确认,默认不记住**:弹窗显示目的地、完整远端命令(原样,不脱敏,与退出确认弹窗一致)、映射表,以及 attach 时的端点。和摄像头/麦克风同一原则:插件可以请求,但每次请求都不默认有权限。以后若要“记住这个主机”,只能由用户主动选择并按“主机 + 命令”记录。 +- 退出确认(正在调试时退出应用)显示的命令包含目的地,同样原样显示。 + +### 失败与诊断 + +适配器在远端时,“启动失败”可能出在很多层,用户看到一句笼统的“适配器启动失败”几乎无法排查。Core 捕获 ssh 的 stderr,按下面分类,**每一类都附上 ssh 的原文**(原文是上游诊断,不翻译,也不改写): + +| 现象 | 常见原因 | 提示 | +| --- | --- | --- | +| `Connection refused` | 目的地端口上没有进程监听(sshd 没起、端口写错),或有人主动拒绝(防火墙 REJECT、本机代理没启动)。收到的是 RST,**不是超时** | 检查主机、端口;ProxyJump 的跳板是否可达 | +| `Connection timed out` / `Operation timed out` | 报文被丢弃(防火墙 DROP、网络不通) | 检查网络、VPN | +| `Could not resolve hostname` | DNS 或 ssh 配置里的别名写错 | 检查主机名 | +| `Permission denied` | 没有可用的密钥或 agent 未加载 | 检查密钥与 `ssh-add`;第一版不支持密码 | +| `Host key verification failed` | 未知主机或指纹变了 | 先在终端里 ssh 一次确认;指纹变了要小心 | +| 远端退出码 127 / `command not found` | 非交互 shell 的 `PATH` 里没有适配器(`.bashrc` 里设置的 PATH 在非交互登录里不生效) | 把适配器路径放进 `~/.profile` 或 `~/.bash_profile`,或安装到系统 `PATH` | + +**首帧前的噪声**:远端 shell 的启动脚本可能在适配器开始前往 stdout 打印横幅,而 `protocol.rs::parse_messages` 遇到没有 `Content-Length` 的头会报错。第三方适配器我们没法像 `lithe-remote-server` 那样先输出一行标记(见远程开发 Note “流前标记”),所以改为:在**第一个** DAP 帧之前,丢弃最多 64 KiB 的字节,直到出现行首的 `Content-Length:`;超出上限仍没有,则结束会话,把丢弃的输出作为诊断返回。丢弃只发生在首帧之前,一旦进入正常帧流,任何格式错误仍照旧报错,不静默跳过。 + +### 生命周期与清理 + +- ssh 进程归会话所有。会话结束:先发 DAP `disconnect`(`launch` 为 `terminateDebuggee: true`,`attach` 为 `false`,沿用“`attach` 默认只断开”的所有权规则),再关闭 stdin,有界等待适配器退出,仍未退出则终止、再强制结束本机 ssh。 +- 对端消失(网线断了、主机重启)靠传输链已有的 `ServerAliveInterval=15`、`ServerAliveCountMax=3` 在约 45 秒内发现并结束会话,不会悬挂。 +- 远端孤儿进程**无法完全避免**:不分配 PTY 时,ssh 断开后远端适配器会收到 stdin 的 EOF 或 SIGPIPE,常见适配器据此退出,但被它启动的被调试程序是否随之结束取决于适配器。这是已知风险,在对话框说明里提示,不承诺。 +- 所有 Lithe 本机进程(包括测试里的假 ssh)在任务完成后必须关闭,沿用仓库的“测试进程生命周期与清理”规则。 + +### 不做 + +- Lithe 自研的加密或认证协议。这不否决普通 TCP 直连:它可用于用户认可的受保护网络,复用现有 socket;本轮不新增安全通路。 +- 保存私钥、密码、口令;密码和键盘交互认证(等 askpass)。 +- Windows 远端主机(路径风格、命令解析都不同)。Windows 本机作为客户端的宿主接入另议,Core 部分两个平台共用。 +- 插件声明任意 ssh 包装命令或 launcher argv:这会让插件决定本机执行什么。 +- 容器(`docker exec`)作为启动器:`ContainerHop` 已有,以后同一套流程可以接入,不在这一轮。 +- 自动从 `~/.ssh/config` 列出主机建议:是否做、怎么做另议。 +- 模型 A(完整远程工作区):属于远程开发的 M4。 +- 子会话的 TCP(`startDebugging` 反向请求)的远端化。 + +### 待独立方案评审的候选默认值 + +本轮不实施;即使确认以下候选,也不自动把 SSH 加回当前范围: + +1. **使用系统 `ssh` 和用户已有的 ssh 配置**(经 `TransportChain`),不内置 SSH 库、不另做加密传输。 +2. **优先复用上游路径映射**;能力确实不足时再评审 Core 纯函数回退,授权仍在平台。 +3. **新增 `debug.remote` 权限**,并且每次启动都确认、默认不记住。 +4. **第一版只支持密钥和 agent(`BatchMode=yes`)**,不支持密码。 + +### 未来可能的实施阶段 + +仅在独立网络方案批准后细化,不是本轮的任务清单: + +1. Core:清单声明与 schema、declaration 校验与拒绝 fixture、`plan_descriptor.remoteRun`、目的地校验与命令构造、路径翻译函数与会话接入、首帧噪声丢弃、ssh 失败分类、测试。 +2. 契约与文档:`shared/contracts/plugins/v1/` 的 README、schema、fixture;合并后同步 lithe-plugin-docs。 +3. macOS 宿主:对话框、确认弹窗、进程所有权与清理、退出确认、`shared/i18n` 词条、假 ssh 集成测试。 +4. 真机验收:一台 Linux 主机上的 `gdb -i=dap`,覆盖设断点、命中、看栈帧、单步、结束、断线。 +5. Windows 宿主和模型 A 另议。 + +### 正确做法 + +- 远端命令只通过 `TransportChain::command` 构造;目的地和路径映射由用户通过对话框提供,进入 Core 前不拼进任何字符串。 +- 先核对上游路径映射;必要的 Core 回退才使用纯函数,且不与上游重复转换同一对路径空间。 +- 新增一类 ssh 失败原因时,加入分类表和 i18n 词条,同时保留 ssh 原文。 +- 想在远端启动一个新适配器时,只改插件清单,不改宿主。 + +### 不要这样做 + +- 不要在宿主或 Core 里写 `ssh host 'gdb -i=dap'` 这样的字符串,也不要自己做 shell 引用。引用只属于传输链。 +- 不要让用户输入(目的地之外的任何值)进入远端命令行;程序路径、进程号放进 DAP JSON。 +- 不要加 `StrictHostKeyChecking=no` 或 `accept-new` 让“第一次能连上”。静默接受未知主机会让中间人替用户执行命令。 +- 不要在翻译失败时猜测一个本地路径;找不到就显示远端路径或“无源码”。 +- 不要按适配器名字(`gdb`、`lldb-dap`)在宿主里加分支;`remote` 声明与 `remoteRun` 描述符就是为避免这件事。 +- 不要把确认记住为“永远允许”。 + +## 考虑过的备选方案 + +### Lithe 自己的加密 TCP 传输 + +最吸引人的地方是不依赖系统 ssh,Windows 上也一致,可以直连任何监听 TCP 的适配器。不采用的原因是:认证、加密、证书/密钥管理、主机指纹都要重新做,每一项都是安全敏感且已有成熟上游实现的工作;而 gdb 的 `-i=dap` 本来就是 stdio,经 ssh 天然可用。 + +### 用户自己建 SSH 隧道,再用已有的 attach 连 `127.0.0.1` + +最吸引人的地方是不需要 Lithe 管理网络设施,当前普通 TCP 方向允许这种用法,但对应的宿主端点入口仍需已实现。它不能取代所有远端执行场景:stdio-only 的适配器(gdb `-i=dap`)不能直接通过 TCP 端口隧道接入。因此保留它,而不是把 SSH 远端启动当作唯一方案。 + +### 插件声明固定的 ssh 包装命令(`ssh` 作为 `commands[0]`) + +最吸引人的地方是不用改契约,今天的 `systemCommand` 就能写。不采用的原因是:目的地得由插件写死,用户没法选择;没有确认和路径翻译;插件还能借此决定本机执行什么(`ssh -o ProxyCommand=…`),把一个本来受限的声明变成任意命令执行。 + +### 内置 SSH 库 + +已在[远程开发 Note](2026-10-24-remote-development-workspace-host.md) 否决:用户的 ssh 配置、ProxyJump、硬件密钥、各种 agent 和公司的 Kerberos/证书方案要重新实现,“无缝兼容”反而做不到。 + +### 只做模型 A(完整远程工作区) + +最吸引人的地方是路径从头到尾都是远端的,不需要翻译,体验最一致。不采用的原因是:它依赖远程工作区的产品界面和 M4,周期长;而“本地有工程、只想借远端的 gdb”是眼前就有的需求。模型 B 复用同一条传输链,不会和模型 A 冲突。 + +### 路径翻译放在平台,各平台各做一份 + +最吸引人的地方是平台最了解文件授权。不采用的原因是:翻译是纯字符串逻辑,两个平台各写一份会漂移;授权仍留在平台,翻译函数不访问文件系统,边界清楚。 + +## 验收标准 + +- 清单校验:`remote` 与非 `stdio` 传输、与 `packaged`/`runtime`/`runSelectedRuntime`、与 `environment`/`workingDirectory`、与含 `${…}` 或不安全字符的参数同时出现,都被拒绝;拒绝 fixture 覆盖每一种。 +- 命令构造:通过 `TransportChain::command` 得到的 argv 含 `BatchMode=yes`、`ClearAllForwardings=yes`、`--`;目的地为空、以 `-` 开头、含空白或控制字符被拒绝;端口越界被拒绝;用户输入不出现在远端命令参数里。 +- 路径映射:先证明选定适配器的原生映射能否覆盖客户端与远端路径,同一对根只转换一次。确需 Core 回退时,再验证最长前缀、组件边界、往返一致、未映射回退和已解析断点匹配。 +- 首帧噪声:噪声后接正常帧能成功;超过 64 KiB 或没有 `Content-Length:` 时会话结束并返回被丢弃输出;进入帧流之后的格式错误仍然报错。 +- ssh 失败分类:每一类用固定 stderr 样本测试,原文被保留。 +- 平台集成测试使用**假 ssh 可执行文件**(有界等待),验证确认弹窗每次出现、结束后没有残留的 ssh 进程、断开时发送了正确的 `terminateDebuggee`。 +- 契约、README、schema、fixture 更新;`shared/i18n/` 补齐中英词条并通过 `node scripts/generate-i18n.mjs --check`;功能矩阵 `plugin-debug-adapters` 增补说明;合并后同步 lithe-plugin-docs。 +- 真机验收:一台 Linux 主机上的 `gdb -i=dap`(设断点、命中、栈帧对应本地源码、单步、结束、断线)。 + +## 风险 + +- 远端孤儿进程无法完全避免(见生命周期)。缓解:对话框提示、`ServerAlive` 超时、`terminateDebuggee`。 +- `BatchMode=yes` 让依赖密码的用户第一版完全用不了。缓解:错误信息明确,askpass 落地后再接。 +- 远端 `PATH` 问题是最常见的失败原因,却不是 Lithe 能修的。缓解:分类里单独给出提示。 +- 路径映射需要用户手填,填错会得到“无源码”而不是错误。缓解:对话框预览映射;入站无法映射时在调试控制台提示一次,而不是静默。 +- 用户的 ssh 配置被信任,包括 `ProxyCommand`。这与用户在终端里手动 ssh 的信任相同,不额外限制,但文档说明。 +- 草图声明形态在实现前还会调整,契约是未发布的 API 4,允许修改。 + +## 验证 + +本轮只更新范围和设计说明,运行 `./scripts/verify-agent-notes.sh` 与 `git diff --check`;不提供 SSH、隧道或远端调试的运行验收证据。路径映射的核对依据为 [debugpy 1.8.22 的客户端路径转换](https://github.com/microsoft/debugpy/blob/v1.8.22/src/debugpy/_vendored/pydevd/pydevd_file_utils.py#L844-L901)。未来实施时加载 `write-stable-tests`,再按上面的验收标准执行对应平台测试和真机流程。 + +## 适用范围 + +- `rust/lithe-core/src/debug/` +- `rust/lithe-remote-client/src/transport.rs` +- `shared/contracts/plugins/v1/` +- `macos/Sources/Lithe/Platform/MacOS/Debug/` +- `shared/i18n/` diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 642936f9ea..9f19579768 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -24,7 +24,8 @@ use crate::plugins::declarative::{ use crate::plugins::settings::DeclaredSettings; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Map, Value}; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; +use std::net::IpAddr; /// Most adapters one plugin may declare. const MAX_ADAPTERS: usize = 16; @@ -55,6 +56,20 @@ const MAX_EXECUTABLE_CANDIDATE: usize = 1024; /// The `targetBindings.program` source that reads the executable the host /// resolved, instead of the target's own path. const TARGET_EXECUTABLE: &str = "target.executable"; +/// The target kind of a remote `host:port` the debuggee listens on. +const TARGET_CONNECTION: &str = "connection"; +/// Most names an argument path joins, for example `connect.host`. +const MAX_ARGUMENT_PATH_DEPTH: usize = 4; +/// Longest single name in an argument path, in characters. +const MAX_ARGUMENT_NAME: usize = 64; +/// Most path mappings one remote target may carry. +const MAX_PATH_MAPPINGS: usize = 16; +/// Longest local or remote root of a path mapping, in characters. +const MAX_PATH_MAPPING_ROOT: usize = 1024; +/// Longest host name, as DNS limits it. +const MAX_HOST: usize = 253; +/// Longest label of a host name, as DNS limits it. +const MAX_HOST_LABEL: usize = 63; /// Variables an executable candidate may name, and the only ones the host /// can answer: the workspace root and the open source file's location. const EXECUTABLE_VARIABLES: &[&str] = &[ @@ -251,6 +266,220 @@ pub(crate) struct DebugTargetBindings { pub(crate) process_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) cwd: Option, + /// Where a remote `host:port` goes. Attach only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) connection: Option, +} + +/// Where each fact of a remote target lands in the adapter's attach +/// arguments. Adapters name and nest these differently (`hostName` and `port`, +/// `connect.host`, a single `host:port` string), so unlike the fixed names +/// above each value here is the destination path, not the source fact: `host` +/// reads `target.host`, `port` reads `target.port`, `address` reads both. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DebugConnectionBinding { + /// Argument that receives the host name or IP literal. Pairs with `port`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + /// Argument that receives the port, as an integer. Pairs with `host`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) port: Option, + /// Argument that receives `host:port` as one string, with an IPv6 host in + /// brackets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) address: Option, + /// How local and remote source roots reach the adapter. Absent when the + /// adapter has no such argument; the host then does not offer the choice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path_mappings: Option, +} + +/// The ways adapters take source roots: where they go and in what form. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "shape", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum DebugPathMappingBinding { + /// `[{ : …, : … }, …]` at `argument`. + #[serde(rename_all = "camelCase")] + Objects { + argument: String, + local_key: String, + remote_key: String, + }, + /// The first mapping only, as two strings: `local` and `remote` are the + /// destination arguments. + First { local: String, remote: String }, + /// `{ : , … }` at `argument`, keyed by one side. + #[serde(rename_all = "camelCase")] + Dictionary { + argument: String, + keyed_by: MappingRoot, + }, + /// `[[, ], …]` at `argument`, in the declared order. + Pairs { + argument: String, + order: MappingOrder, + }, +} + +/// Which side of a mapping a dictionary is keyed by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MappingRoot { + Local, + Remote, +} + +/// Which root comes first in a pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MappingOrder { + RemoteLocal, + LocalRemote, +} + +impl DebugPathMappingBinding { + /// Destination paths the binding writes, with the field each belongs to. + fn destinations(&self) -> Vec<(&'static str, &str)> { + match self { + Self::Objects { argument, .. } + | Self::Dictionary { argument, .. } + | Self::Pairs { argument, .. } => vec![("pathMappings.argument", argument)], + Self::First { local, remote } => { + vec![ + ("pathMappings.local", local), + ("pathMappings.remote", remote), + ] + } + } + } + + fn key_fields(&self) -> Vec<(&'static str, &str)> { + match self { + Self::Objects { + local_key, + remote_key, + .. + } => vec![ + ("pathMappings.localKey", local_key), + ("pathMappings.remoteKey", remote_key), + ], + Self::First { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => Vec::new(), + } + } + + /// How many mappings the adapter can take. A shape that holds one mapping + /// refuses more rather than keeping the first and dropping the rest. + fn limit(&self) -> usize { + match self { + Self::First { .. } => 1, + Self::Objects { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => { + MAX_PATH_MAPPINGS + } + } + } +} + +impl DebugConnectionBinding { + /// Every destination argument path with the binding field it comes from. + fn destinations(&self) -> Vec<(&'static str, &str)> { + let mut destinations = Vec::new(); + for (name, path) in [ + ("host", &self.host), + ("port", &self.port), + ("address", &self.address), + ] { + if let Some(path) = path { + destinations.push((name, path.as_str())); + } + } + if let Some(mappings) = &self.path_mappings { + destinations.extend(mappings.destinations()); + } + destinations + } + + fn validate( + &self, + field: &str, + defaults: Option<&Map>, + fixed_names: &[&str], + ) -> Validation<()> { + if self.host.is_some() != self.port.is_some() { + return Err(invalid( + format!("{field}.host"), + "host and port are declared together.", + )); + } + if self.host.is_none() && self.address.is_none() { + return Err(invalid( + field, + "A connection binding receives the target through host and port, or address.", + )); + } + let destinations = self.destinations(); + for (name, path) in &destinations { + if !is_argument_path(path) { + return Err(invalid( + format!("{field}.{name}"), + format!( + "An argument path is up to {MAX_ARGUMENT_PATH_DEPTH} names joined by dots, each a letter or underscore followed by letters, digits, underscores, or hyphens." + ), + )); + } + } + if let Some(mappings) = &self.path_mappings { + for (name, key) in mappings.key_fields() { + if !is_argument_name(key) { + return Err(invalid( + format!("{field}.{name}"), + "A key is a letter or underscore followed by letters, digits, underscores, or hyphens.", + )); + } + } + if let DebugPathMappingBinding::Objects { + local_key, + remote_key, + .. + } = mappings + { + if local_key == remote_key { + return Err(invalid( + format!("{field}.pathMappings.remoteKey"), + "The local and remote keys differ.", + )); + } + } + } + // Two facts can never share a destination, and a destination the + // plugin's own defaults already set would make the default win over + // the host the user confirmed: the adapter would connect somewhere the + // confirmation did not name. + for (index, (name, path)) in destinations.iter().enumerate() { + if let Some((other, _)) = destinations[..index] + .iter() + .find(|(_, other)| paths_overlap(path, other)) + { + return Err(invalid( + format!("{field}.{name}"), + format!("The argument path overlaps {other}."), + )); + } + if fixed_names.iter().any(|fixed| paths_overlap(path, fixed)) { + return Err(invalid( + format!("{field}.{name}"), + "The argument path overlaps another binding.", + )); + } + if defaults.is_some_and(|defaults| default_occupies(defaults, path)) { + return Err(invalid( + format!("{field}.{name}"), + "The argument path is already set by the configuration's own arguments.", + )); + } + } + Ok(()) + } } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -527,6 +756,20 @@ impl DebugAdapterDeclaration { configuration.validate(&configuration_field, request, context)?; } } + // A connection target needs somewhere to put `host:port`; without it + // the selector would offer the adapter for a target it cannot start. + if self.selectors.iter().any(|selector| { + selector + .target_kinds + .iter() + .any(|kind| kind == TARGET_CONNECTION) + }) && self.connection_binding().is_none() + { + return Err(invalid( + format!("{field}.selectors"), + "A connection selector needs an attach configuration with a connection binding.", + )); + } if let Some(capabilities) = &mut self.capabilities { capabilities.validate(&format!("{field}.capabilities"))?; } @@ -536,6 +779,16 @@ impl DebugAdapterDeclaration { self.launch.validate(&format!("{field}.launch"), context) } + /// The binding that receives a remote target, when the adapter attaches + /// to one. + pub(crate) fn connection_binding(&self) -> Option<&DebugConnectionBinding> { + self.configurations + .as_ref() + .and_then(|configurations| configurations.attach.as_ref()) + .and_then(|configuration| configuration.target_bindings.as_ref()) + .and_then(|bindings| bindings.connection.as_ref()) + } + fn validate_selectors(&mut self, field: &str) -> Result<(), PluginRejection> { if self.selectors.len() > MAX_SELECTORS { return Err(invalid( @@ -647,7 +900,11 @@ impl DebugConfiguration { return Ok(()); }; let binding_field = format!("{field}.targetBindings"); - if bindings.program.is_none() && bindings.process_id.is_none() && bindings.cwd.is_none() { + if bindings.program.is_none() + && bindings.process_id.is_none() + && bindings.cwd.is_none() + && bindings.connection.is_none() + { return Err(invalid( &binding_field, "A binding list declares a binding.", @@ -672,8 +929,10 @@ impl DebugConfiguration { )); } } - // `processId` names a running process, which only attach has. - if (bindings.process_id.is_some() && request != DebugRequest::Attach) + // `processId` names a running process and `connection` a remote + // endpoint, which only attach has. + if ((bindings.process_id.is_some() || bindings.connection.is_some()) + && request != DebugRequest::Attach) || (bindings.program.is_some() && request != DebugRequest::Launch) { return Err(invalid( @@ -681,6 +940,20 @@ impl DebugConfiguration { "The binding does not apply to this request.", )); } + if let Some(connection) = &bindings.connection { + let fixed_names: Vec<&str> = [ + ("processId", bindings.process_id.is_some()), + ("cwd", bindings.cwd.is_some()), + ] + .into_iter() + .filter_map(|(name, declared)| declared.then_some(name)) + .collect(); + connection.validate( + &format!("{binding_field}.connection"), + self.arguments.as_ref(), + &fixed_names, + )?; + } Ok(()) } } @@ -1196,8 +1469,8 @@ impl Generation { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct TargetFacts { // Selection by kind happens before a start; the host still states it so a - // start names what it was prepared for. - #[allow(dead_code)] + // start names what it was prepared for. A `connection` target is also + // checked against the adapter, see `plan`. pub(crate) kind: String, #[serde(default)] pub(crate) path: Option, @@ -1208,6 +1481,22 @@ pub(crate) struct TargetFacts { /// The program the host found for a source file, an absolute path. #[serde(default)] pub(crate) executable: Option, + /// Where a remote debuggee listens: a host name or IP literal without + /// brackets, port, or scheme. + #[serde(default)] + pub(crate) host: Option, + #[serde(default)] + pub(crate) port: Option, + /// Local source roots and where the remote machine has them. + #[serde(default)] + pub(crate) path_mappings: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PathMapping { + pub(crate) local: String, + pub(crate) remote: String, } /// What a start asks of the adapter: the request and the facts around it. @@ -1218,12 +1507,32 @@ pub(crate) struct StartRequest { pub(crate) facts: StartFacts, } +/// The remote endpoint a start connects to, as the adapter will receive it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StartConnection { + pub(crate) host: String, + pub(crate) port: u16, + /// False only when `host` is `localhost` or a loopback IP literal. A name + /// that resolves to loopback still counts as remote, so a host asks the + /// user about every endpoint Core cannot prove stays on this machine. + pub(crate) remote: bool, +} + +/// What a start gives the host: the DAP arguments and, for a connection +/// target, the endpoint they carry. +#[derive(Debug, Clone)] +pub(crate) struct StartArguments { + pub(crate) arguments: Map, + pub(crate) connection: Option, +} + /// The default DAP arguments of a request with each binding filling its /// argument from the target, unless the defaults already set it. pub(crate) fn start_arguments( adapter: &DebugAdapterDeclaration, start: &StartRequest, -) -> Validation> { +) -> Validation { plan(adapter, start.request, &start.facts)?; let configuration = adapter @@ -1239,7 +1548,10 @@ pub(crate) fn start_arguments( let Some(bindings) = configuration.and_then(|configuration| configuration.target_bindings.as_ref()) else { - return Ok(arguments); + return Ok(StartArguments { + arguments, + connection: None, + }); }; let target = &start.facts.target; // A binding is the fact path that fills the argument of the same name. @@ -1263,7 +1575,269 @@ pub(crate) fn start_arguments( &bindings.cwd, target.working_directory.clone().map(Value::from), ); - Ok(arguments) + let connection = match &bindings.connection { + Some(binding) => Some(bind_connection(binding, target, &mut arguments)?), + None => None, + }; + Ok(StartArguments { + arguments, + connection, + }) +} + +/// Writes the target's endpoint, and its path mappings when the adapter takes +/// them, to the destinations the binding names. +fn bind_connection( + binding: &DebugConnectionBinding, + target: &TargetFacts, + arguments: &mut Map, +) -> Validation { + let endpoint = connection_endpoint(target)?; + if let (Some(host), Some(port)) = (&binding.host, &binding.port) { + set_argument(arguments, host, Value::from(endpoint.host.clone()))?; + set_argument(arguments, port, Value::from(endpoint.port))?; + } + if let Some(address) = &binding.address { + let joined = if endpoint.host.contains(':') { + format!("[{}]:{}", endpoint.host, endpoint.port) + } else { + format!("{}:{}", endpoint.host, endpoint.port) + }; + set_argument(arguments, address, Value::from(joined))?; + } + // `plan` has already checked the mappings against this shape: their number + // fits it, and a dictionary has no key twice. + if let (Some(mappings), false) = (&binding.path_mappings, target.path_mappings.is_empty()) { + let roots = |mapping: &PathMapping, order: MappingOrder| match order { + MappingOrder::RemoteLocal => (mapping.remote.clone(), mapping.local.clone()), + MappingOrder::LocalRemote => (mapping.local.clone(), mapping.remote.clone()), + }; + match mappings { + DebugPathMappingBinding::Objects { + argument, + local_key, + remote_key, + } => { + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let mut entry = Map::new(); + entry.insert(local_key.clone(), Value::from(mapping.local.clone())); + entry.insert(remote_key.clone(), Value::from(mapping.remote.clone())); + Value::Object(entry) + }) + .collect(); + set_argument(arguments, argument, Value::Array(entries))?; + } + DebugPathMappingBinding::First { local, remote } => { + for first in target.path_mappings.iter().take(1) { + set_argument(arguments, local, Value::from(first.local.clone()))?; + set_argument(arguments, remote, Value::from(first.remote.clone()))?; + } + } + DebugPathMappingBinding::Dictionary { argument, keyed_by } => { + let order = match keyed_by { + MappingRoot::Remote => MappingOrder::RemoteLocal, + MappingRoot::Local => MappingOrder::LocalRemote, + }; + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let (key, value) = roots(mapping, order); + (key, Value::from(value)) + }) + .collect(); + set_argument(arguments, argument, Value::Object(entries))?; + } + DebugPathMappingBinding::Pairs { argument, order } => { + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let (first, second) = roots(mapping, *order); + Value::Array(vec![Value::from(first), Value::from(second)]) + }) + .collect(); + set_argument(arguments, argument, Value::Array(entries))?; + } + } + } + Ok(endpoint) +} + +/// Sets the argument at a dotted path, creating the objects on the way. A +/// declaration cannot leave anything at the path (see +/// `DebugConnectionBinding::validate`), so an occupied path is a bug in the +/// declaration that slipped past it and is refused rather than overwritten. +fn set_argument(arguments: &mut Map, path: &str, value: Value) -> Validation<()> { + let occupied = || { + PluginRejection::at( + "debug.arguments.invalid", + path, + "The adapter's arguments already hold something at this path.", + ) + }; + let mut current = arguments; + let mut names = path.split('.').peekable(); + while let Some(name) = names.next() { + if names.peek().is_none() { + return match current.entry(name.to_string()) { + serde_json::map::Entry::Vacant(slot) => { + slot.insert(value); + Ok(()) + } + serde_json::map::Entry::Occupied(_) => Err(occupied()), + }; + } + let next = current + .entry(name.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let Value::Object(next) = next else { + return Err(occupied()); + }; + current = next; + } + Ok(()) +} + +/// The endpoint a connection target names, checked. +fn connection_endpoint(target: &TargetFacts) -> Validation { + let invalid_target = |field: &'static str, message: &str| { + PluginRejection::at("debug.target.invalid", field, message) + }; + let Some(host) = target.host.as_deref() else { + return Err(invalid_target( + "target.host", + "The target has no host, which the adapter's configuration reads.", + )); + }; + if let Some(problem) = host_problem(host) { + return Err(invalid_target("target.host", problem)); + } + let port = target + .port + .ok_or_else(|| { + invalid_target( + "target.port", + "The target has no port, which the adapter's configuration reads.", + ) + }) + .and_then(|port| { + u16::try_from(port) + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| invalid_target("target.port", "A port is 1-65535.")) + })?; + Ok(StartConnection { + host: host.to_string(), + port, + remote: !is_loopback_host(host), + }) +} + +/// Why a string is not a host to connect to, or `None` when it is one: a DNS +/// name or an IP literal, without scheme, credentials, port, path, brackets, +/// or whitespace. Non-ASCII names are written in punycode; an IPv6 zone is not +/// supported. +fn host_problem(host: &str) -> Option<&'static str> { + if host.is_empty() || host.len() > MAX_HOST { + return Some("A host is 1-253 characters."); + } + if host.parse::().is_ok() { + return None; + } + if host.contains(':') { + return Some("A host is a name or an IP address, without a scheme, port, or brackets."); + } + if host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + return Some("A numeric host is a dotted IPv4 address."); + } + let labels_valid = host.split('.').all(|label| { + !label.is_empty() + && label.len() <= MAX_HOST_LABEL + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if labels_valid { + None + } else { + Some("A host name is dot-separated labels of letters, digits, and hyphens.") + } +} + +/// Whether Core can tell from the text alone that the host is this machine. +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +/// Checks the path mappings the host sends along with a remote target against +/// the shape the adapter takes them in. +fn check_path_mappings( + mappings: &[PathMapping], + shape: &DebugPathMappingBinding, +) -> Validation<()> { + let invalid_mapping = |message: String| { + PluginRejection::at("debug.target.invalid", "target.pathMappings", message) + }; + // A shape that holds fewer mappings than the user gave would start a + // session with some source lines silently unmapped. + if mappings.len() > shape.limit() { + return Err(invalid_mapping(match shape.limit() { + 1 => "The adapter takes one path mapping.".to_string(), + limit => format!("A target has up to {limit} path mappings."), + })); + } + for mapping in mappings { + let sound = |root: &str| { + !root.is_empty() + && root.chars().count() <= MAX_PATH_MAPPING_ROOT + && !root.chars().any(char::is_control) + }; + if !sound(&mapping.local) || !sound(&mapping.remote) { + return Err(invalid_mapping(format!( + "A path mapping root is one line of 1-{MAX_PATH_MAPPING_ROOT} characters." + ))); + } + // The local root is on this machine, so it must be a full path; the + // remote root is the other machine's and may follow other conventions. + if !std::path::Path::new(&mapping.local).is_absolute() { + return Err(invalid_mapping( + "A path mapping's local root is an absolute path.".to_string(), + )); + } + } + // A dictionary holds one value per key; two mappings from the same root + // cannot both be sent, and keeping either would drop the other unseen. + if let DebugPathMappingBinding::Dictionary { keyed_by, .. } = shape { + let mut keys = HashSet::new(); + for mapping in mappings { + let key = match keyed_by { + MappingRoot::Local => &mapping.local, + MappingRoot::Remote => &mapping.remote, + }; + if !keys.insert(key.as_str()) { + return Err(invalid_mapping(format!( + "The adapter takes one path mapping per {} root.", + match keyed_by { + MappingRoot::Local => "local", + MappingRoot::Remote => "remote", + } + ))); + } + } + } + Ok(()) } impl DebugAdapterDeclaration { @@ -1281,6 +1855,13 @@ impl DebugAdapterDeclaration { "transport": self.launch.transport, "endpoint": self.launch.endpoint, "disabledCapabilities": self.capabilities.as_ref().map(|c| c.disabled.clone()).unwrap_or_default(), + // Present exactly when the adapter attaches to a remote host and + // port; says whether the host may offer path mappings and how many + // the adapter can take (0 when it takes none). + "remoteAttach": self.connection_binding().map(|binding| json!({ + "pathMappings": binding.path_mappings.is_some(), + "maxPathMappings": binding.path_mappings.as_ref().map_or(0, DebugPathMappingBinding::limit), + })), }) } } @@ -1315,6 +1896,17 @@ pub(crate) fn plan( "The selected runtime changed after this start was prepared.", )); } + // A remote target is only ever attached to, through an adapter that says + // where its host and port go. + if facts.target.kind == TARGET_CONNECTION + && (request != DebugRequest::Attach || adapter.connection_binding().is_none()) + { + return Err(PluginRejection::at( + "debug.target.invalid", + "target.kind", + "The adapter does not attach to a remote host and port.", + )); + } let bindings = adapter .configurations .as_ref() @@ -1362,9 +1954,66 @@ pub(crate) fn plan( { return Err(missing("target.workingDirectory", "working directory")); } + if let Some(connection) = &bindings.connection { + connection_endpoint(target)?; + if let Some(shape) = &connection.path_mappings { + check_path_mappings(&target.path_mappings, shape)?; + } else if !target.path_mappings.is_empty() { + // Dropping a mapping the user typed would let the session start + // with source lines that silently do not match. + return Err(PluginRejection::at( + "debug.target.invalid", + "target.pathMappings", + "The adapter takes no path mappings.", + )); + } + } Ok(()) } +/// A name in an argument path: a DAP argument or a key of a nested object. +fn is_argument_name(name: &str) -> bool { + let mut characters = name.chars(); + name.len() <= MAX_ARGUMENT_NAME + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && characters.all(|character| character.is_ascii_alphanumeric() || "_-".contains(character)) +} + +/// A dotted path to an argument, such as `hostName` or `connect.host`. +fn is_argument_path(path: &str) -> bool { + let mut depth = 0; + path.split('.').all(|name| { + depth += 1; + depth <= MAX_ARGUMENT_PATH_DEPTH && is_argument_name(name) + }) +} + +/// Whether two argument paths are the same or one is inside the other. +fn paths_overlap(left: &str, right: &str) -> bool { + left.split('.') + .zip(right.split('.')) + .all(|(left, right)| left == right) +} + +/// Whether the arguments already hold something at the path, or a value that +/// is not an object on the way to it. +fn default_occupies(defaults: &Map, path: &str) -> bool { + let mut current = defaults; + let mut names = path.split('.').peekable(); + while let Some(name) = names.next() { + let Some(value) = current.get(name) else { + return false; + }; + match value { + Value::Object(next) if names.peek().is_some() => current = next, + _ => return true, + } + } + false +} + fn validate_name(value: &str, field: &str) -> Validation<()> { let trimmed = value.trim(); if trimmed.is_empty() diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 4e94104324..bce3582b44 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -3101,25 +3101,34 @@ pub fn debug_start_arguments_command(payload: Value) -> Result start: crate::debug::adapters::StartRequest, } let payload: Payload = decode_payload(payload, COMMAND)?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - let adapter = manifest - .debug_adapters - .iter() - .find(|adapter| adapter.id == payload.adapter_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!( - "The plugin declares no debug adapter {}.", - payload.adapter_id - ), - ) - })?; - crate::debug::adapters::start_arguments(adapter, &payload.start) - .map(|arguments| ("arguments", Value::Object(arguments))) - }, - ))) + let started = validate_manifest_value(payload.manifest).and_then(|manifest| { + let adapter = manifest + .debug_adapters + .iter() + .find(|adapter| adapter.id == payload.adapter_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!( + "The plugin declares no debug adapter {}.", + payload.adapter_id + ), + ) + })?; + crate::debug::adapters::start_arguments(adapter, &payload.start) + }); + // `connection` is present only for a remote target, so a host that never + // attaches to one can ignore it. + let connection = started + .as_ref() + .ok() + .and_then(|started| started.connection.clone()); + let mut result = + outcome(started.map(|started| ("arguments", Value::Object(started.arguments)))); + if let Some(connection) = connection { + result["connection"] = json!(connection); + } + Ok(result) } /// `debug.adapters.executablePlan`: where a host looks for the program a diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index a255f3b2e7..f3982a282d 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -33,6 +33,8 @@ const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend.plugin.json"); const DEBUG_ADAPTER_FIXTURE: &str = contract_file!("fixtures/debug-adapter.plugin.json"); const DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE: &str = contract_file!("fixtures/debug-adapter-language-server.plugin.json"); +const DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE: &str = + contract_file!("fixtures/debug-adapter-remote-attach.plugin.json"); const DEBUG_ADAPTER_REJECTIONS: &str = contract_file!("fixtures/debug-adapter-rejections.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); @@ -60,6 +62,7 @@ fn fixture(name: &str) -> Value { "backend.plugin.json" => BACKEND_FIXTURE, "debug-adapter.plugin.json" => DEBUG_ADAPTER_FIXTURE, "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, + "debug-adapter-remote-attach.plugin.json" => DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -80,6 +83,7 @@ fn contract_fixtures_and_official_plugins_validate() { NODE_RUNTIME_FIXTURE, BACKEND_FIXTURE, DEBUG_ADAPTER_FIXTURE, + DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); @@ -1149,7 +1153,11 @@ fn debug_rejection(baseline: &Value, case: &Value) -> (String, String) { "target": input["context"]["target"], })) .unwrap(); - plan(&adapter, DebugRequest::Launch, &facts) + let request = match input["context"].get("request") { + Some(request) => serde_json::from_value(request.clone()).unwrap(), + None => DebugRequest::Launch, + }; + plan(&adapter, request, &facts) } "coreInstall" => { let resources: Vec = @@ -1465,6 +1473,363 @@ fn debug_start_arguments_fill_bindings_without_overriding_defaults() { assert_eq!(attach["arguments"], json!({ "processId": 42 })); } +fn remote_start_with(manifest: Value, adapter: &str, request: &str, target: Value) -> Value { + crate::plugins::declarative::debug_start_arguments_command(json!({ + "manifest": manifest, + "adapterID": adapter, + "request": request, + "facts": { + "registration": { "generation": 3, "currentGeneration": 3 }, + "target": target, + }, + })) + .expect("payload is well formed") +} + +fn remote_start(adapter: &str, target: Value) -> Value { + remote_start_with( + fixture("debug-adapter-remote-attach.plugin.json"), + adapter, + "attach", + target, + ) +} + +/// A local source root, absolute on the platform the test runs on. +fn local_root() -> &'static str { + if cfg!(windows) { + r"C:\work\src" + } else { + "/work/src" + } +} + +fn connection_target(host: &str, port: u64) -> Value { + json!({ "kind": "connection", "host": host, "port": port }) +} + +#[test] +fn debug_adapter_remote_attach_fixture_validates_and_keeps_its_bindings() { + let outcome = validate(fixture("debug-adapter-remote-attach.plugin.json")); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let nested = outcome["plugin"]["debugAdapters"] + .as_array() + .unwrap() + .iter() + .find(|adapter| adapter["id"] == "nested") + .expect("the fixture declares the nested adapter"); + assert_eq!( + nested["configurations"]["attach"]["targetBindings"]["connection"], + json!({ + "host": "connect.host", + "port": "connect.port", + "pathMappings": { + "shape": "objects", + "argument": "pathMappings", + "localKey": "localRoot", + "remoteKey": "remoteRoot", + }, + }) + ); +} + +#[test] +fn debug_start_arguments_put_a_remote_endpoint_where_each_adapter_reads_it() { + let flat = remote_start("flat", connection_target("build.example.org", 5005)); + assert_eq!(flat["status"], "ok", "{flat}"); + // Defaults stay and the endpoint joins them. + assert_eq!( + flat["arguments"], + json!({ "timeout": 5000, "hostName": "build.example.org", "port": 5005 }) + ); + assert_eq!( + flat["connection"], + json!({ "host": "build.example.org", "port": 5005, "remote": true }) + ); + let nested = remote_start("nested", connection_target("build.example.org", 5678)); + assert_eq!( + nested["arguments"], + json!({ + "justMyCode": false, + "connect": { "host": "build.example.org", "port": 5678 }, + }) + ); + let joined = remote_start("joined", connection_target("build.example.org", 2345)); + assert_eq!( + joined["arguments"], + json!({ "request": "attach", "target": "build.example.org:2345" }) + ); +} + +#[test] +fn debug_start_arguments_bracket_an_ipv6_host_only_in_a_joined_address() { + let joined = remote_start("joined", connection_target("2001:db8::1", 2345)); + assert_eq!(joined["arguments"]["target"], "[2001:db8::1]:2345"); + let flat = remote_start("flat", connection_target("2001:db8::1", 2345)); + assert_eq!(flat["arguments"]["hostName"], "2001:db8::1"); + assert_eq!(flat["connection"]["host"], "2001:db8::1"); +} + +#[test] +fn debug_start_arguments_map_source_roots_in_the_shape_the_adapter_takes() { + let local = local_root(); + let mut target = connection_target("build.example.org", 5678); + target["pathMappings"] = json!([ + { "local": local, "remote": "/srv/app" }, + { "local": local, "remote": "/opt/lib" }, + ]); + let nested = remote_start("nested", target.clone()); + assert_eq!(nested["status"], "ok", "{nested}"); + assert_eq!( + nested["arguments"]["pathMappings"], + json!([ + { "localRoot": local, "remoteRoot": "/srv/app" }, + { "localRoot": local, "remoteRoot": "/opt/lib" }, + ]) + ); + // An adapter with one local and one remote root takes one mapping. + let mut single = connection_target("build.example.org", 5678); + single["pathMappings"] = json!([{ "local": local, "remote": "/srv/app" }]); + let scalar = remote_start("scalar", single); + assert_eq!( + scalar["arguments"], + json!({ + "address": "build.example.org", + "port": 5678, + "localRoot": local, + "remoteRoot": "/srv/app", + }) + ); + // A dictionary is keyed by the side the binding names; the other root is + // the value. + let keyed = remote_start("keyed", target.clone()); + assert_eq!(keyed["status"], "ok", "{keyed}"); + assert_eq!( + keyed["arguments"]["sourceMap"], + json!({ "/srv/app": local, "/opt/lib": local }) + ); + // Pairs keep the order of the mappings and the roots within each pair. + let paired = remote_start("paired", target); + assert_eq!(paired["status"], "ok", "{paired}"); + assert_eq!( + paired["arguments"]["sourceMap"], + json!([["/srv/app", local], ["/opt/lib", local]]) + ); + // No mapping means no argument, not an empty one. + for adapter in ["nested", "keyed", "paired"] { + let none = remote_start(adapter, connection_target("build.example.org", 5678)); + assert_eq!(none["status"], "ok", "{adapter}: {none}"); + for name in ["pathMappings", "sourceMap"] { + assert!(none["arguments"].get(name).is_none(), "{adapter}: {none}"); + } + } +} + +#[test] +fn debug_start_arguments_tell_the_host_which_endpoints_may_leave_this_machine() { + for (host, remote) in [ + ("localhost", false), + ("LocalHost", false), + ("127.0.0.1", false), + ("127.8.9.10", false), + ("::1", false), + ("10.0.0.5", true), + ("0.0.0.0", true), + ("build.example.org", true), + // A name is never proven local from its text. + ("localhost.example.org", true), + ("::ffff:127.0.0.1", true), + ] { + let started = remote_start("flat", connection_target(host, 5005)); + assert_eq!(started["status"], "ok", "{host}: {started}"); + assert_eq!(started["connection"]["remote"], remote, "{host}"); + } +} + +#[test] +fn debug_start_arguments_refuse_a_remote_target_without_a_usable_endpoint() { + let too_long = format!("{}.example", "a".repeat(250)); + for host in [ + "", + "http://build.example.org", + "build.example.org:5005", + "[::1]", + "build example", + "user@build.example.org", + "build.example.org/path", + "-build.example.org", + "build..example.org", + "1.2.3", + "300.1.1.1.1", + too_long.as_str(), + ] { + let refused = remote_start("flat", connection_target(host, 5005)); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{host}: {refused}" + ); + assert_eq!(refused["error"]["field"], "target.host", "{host}"); + } + let no_host = remote_start("flat", json!({ "kind": "connection", "port": 5005 })); + assert_eq!(no_host["error"]["field"], "target.host", "{no_host}"); + let no_port = remote_start( + "flat", + json!({ "kind": "connection", "host": "build.example.org" }), + ); + assert_eq!(no_port["error"]["field"], "target.port", "{no_port}"); + for port in [0_u64, 65_536, 4_294_967_296] { + let refused = remote_start("flat", connection_target("build.example.org", port)); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{port}: {refused}" + ); + assert_eq!(refused["error"]["field"], "target.port", "{port}"); + } +} + +#[test] +fn debug_start_arguments_refuse_path_mappings_the_adapter_cannot_use() { + let local = local_root(); + let with = |mappings: Value| { + let mut target = connection_target("build.example.org", 5678); + target["pathMappings"] = mappings; + remote_start("nested", target) + }; + let too_many: Vec = (0..17) + .map(|index| json!({ "local": local, "remote": format!("/srv/{index}") })) + .collect(); + for (name, mappings) in [ + ("too many", Value::Array(too_many)), + ("empty remote", json!([{ "local": local, "remote": "" }])), + ("empty local", json!([{ "local": "", "remote": "/srv" }])), + ( + "multi-line remote", + json!([{ "local": local, "remote": "/srv\n/etc" }]), + ), + ( + "relative local", + json!([{ "local": "src", "remote": "/srv" }]), + ), + ] { + let refused = with(mappings); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{name}: {refused}" + ); + assert_eq!(refused["error"]["field"], "target.pathMappings", "{name}"); + } + // A shape that cannot hold everything the user gave is refused, not + // trimmed: `scalar` takes one mapping, and a dictionary keyed by the remote + // root has no room for two mappings from the same remote root. + let mut two = connection_target("build.example.org", 5678); + two["pathMappings"] = json!([ + { "local": local, "remote": "/srv/app" }, + { "local": local, "remote": "/opt/lib" }, + ]); + let refused = remote_start("scalar", two); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{refused}" + ); + assert_eq!( + refused["error"]["field"], "target.pathMappings", + "{refused}" + ); + let mut same_remote = connection_target("build.example.org", 5678); + same_remote["pathMappings"] = json!([ + { "local": local, "remote": "/srv/app" }, + { "local": format!("{local}/other"), "remote": "/srv/app" }, + ]); + let refused = remote_start("keyed", same_remote.clone()); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{refused}" + ); + assert_eq!( + refused["error"]["field"], "target.pathMappings", + "{refused}" + ); + // Pairs and objects hold the same root twice without loss. + for adapter in ["paired", "nested"] { + let kept = remote_start(adapter, same_remote.clone()); + assert_eq!(kept["status"], "ok", "{adapter}: {kept}"); + } + // `flat` declares no mapping argument: dropping the mapping silently would + // start a session whose breakpoints do not line up. + let mut target = connection_target("build.example.org", 5005); + target["pathMappings"] = json!([{ "local": local, "remote": "/srv" }]); + let refused = remote_start("flat", target); + assert_eq!( + refused["error"]["field"], "target.pathMappings", + "{refused}" + ); +} + +#[test] +fn debug_start_arguments_keep_a_remote_target_to_adapters_that_attach_to_one() { + // The fixture's adapters attach only, so a launch is refused as before. + let launch = remote_start_with( + fixture("debug-adapter-remote-attach.plugin.json"), + "flat", + "launch", + connection_target("build.example.org", 5005), + ); + assert_eq!(launch["error"]["code"], "debug.request.unsupported"); + // An adapter that attaches to a process has nowhere to put host and port, + // and one that also launches may not be handed a connection to launch. + for request in ["attach", "launch"] { + let refused = remote_start_with( + fixture("debug-adapter.plugin.json"), + "example", + request, + connection_target("build.example.org", 5005), + ); + assert_eq!( + refused["error"]["code"], "debug.target.invalid", + "{request}: {refused}" + ); + assert_eq!(refused["error"]["field"], "target.kind", "{request}"); + } +} + +#[test] +fn debug_adapter_launch_plan_says_which_adapters_attach_to_a_remote_endpoint() { + let descriptor = |manifest: &str, adapter: &str| { + let plan = language_server_launch_plan_command(json!({ + "manifest": fixture(manifest), + "debugAdapterID": adapter, + "request": { + "pluginDirectory": "/plugins/example", + "stateDirectory": "/state/example", + "architecture": "arm64" + }, + })) + .expect("payload is well formed"); + assert_eq!(plan["status"], "ok", "{plan}"); + plan["plan"]["debugAdapter"]["remoteAttach"].clone() + }; + let remote = "debug-adapter-remote-attach.plugin.json"; + // The host offers as many mapping rows as the adapter can hold. + for (adapter, mappings, limit) in [ + ("flat", false, 0), + ("joined", false, 0), + ("nested", true, 16), + ("scalar", true, 1), + ("keyed", true, 16), + ("paired", true, 16), + ] { + assert_eq!( + descriptor(remote, adapter), + json!({ "pathMappings": mappings, "maxPathMappings": limit }), + "{adapter}" + ); + } + assert_eq!( + descriptor("debug-adapter.plugin.json", "example"), + Value::Null + ); +} + fn debug_start_native(target: Value) -> Value { crate::plugins::declarative::debug_start_arguments_command(json!({ "manifest": fixture("debug-adapter.plugin.json"), diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 67d70ec72e..a172ce3bd8 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -497,7 +497,7 @@ Rules for publishing across levels and capability versions: | `process.build` | `buildSystems` is not empty | | `process.vcs` | `vcsProviders` is not empty | | `debug.launch` | `debugAdapters` is not empty | -| `debug.attach` | an adapter lists `attach` in `requests` (and only then) | +| `debug.attach` | an adapter lists `attach` in `requests` (and only then); it also covers attaching to a remote host and port | | `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | @@ -1030,7 +1030,7 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `requests` | `launch` (default) and/or `attach`. | | `selectors[]` | Optional automatic matching by `targetKinds` and `languageIDs`; absent means explicit selection only. | | `configurationTypes` | Imported `launch.json` `type` values the adapter answers to. | -| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`). A configuration for an undeclared request is refused. | +| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | | `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, `candidates[]` (up to 8) are the paths a host looks for when the user chose nothing. Each candidate starts with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and has no `..`. Declare it together with `targetBindings.program: "target.executable"` and neither without the other; a launch fills `program` with the path the host settled on. The user's setting always wins: a chosen path that is not a runnable file is an error, not a reason to try the candidates. The host shows the path it found in that setting, and the setting's `description` is where the plugin teaches how to produce the program. | | `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent. | | `console.commandPrefix` | Text (1–8 characters, no spaces; for lldb-dap a backtick) the adapter's console reads as "what follows is a debugger command". The host puts it before the typed line only when it asks the adapter to complete, and moves the adapter's answer back, so the user never sees it. An adapter that treats a bare word as an expression (lldb-dap) otherwise mixes language keywords such as `long` into the first word's completions; the prefix limits them to commands. Declare at least this or `console.commands[]` when `console` is present. | @@ -1039,12 +1039,111 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `launch.transport` | `stdio`, `tcpAnnounced` (`endpoint`: `stream`, one-line `prefix`, `value` = `port` or `hostPort`), `tcpPort` (`endpoint.port`), or `tcpLanguageServer` (`endpoint`: `languageID`, `command`). TCP endpoints are loopback only. | | `launch.endpoint` for `tcpLanguageServer` | For an adapter that lives inside a language server and has no process of its own (the Java Debug Server runs in JDT LS). `languageID` names a `languageServers[]` entry of the same plugin; `command` (1–128 characters of letters, digits, `.`, `_`, `:`, `-`) is the `workspace/executeCommand` the host sends to that server for each session. The server answers with the loopback port to connect to, as an integer or a numeric string from 1 to 65535. The host starts the language server for the workspace first, as it does for editing. The command is part of what the user reviews at install and update, and changing it asks again. | +### Remote attach + +An adapter that attaches to a debuggee on another machine (a JVM started with +`-agentlib:jdwp`, a Node inspector, gdbserver) is an +ordinary `attach` adapter whose `targetBindings` carry a `connection`. The +adapter itself still runs on this machine, stdio or loopback as before; only +the debuggee is remote. This is the adapter-to-debuggee endpoint, not the +IDE-to-adapter DAP endpoint. By default, debugpy `--listen` starts a DAP adapter +on the target machine: the IDE must connect to that adapter, not start another +local adapter and merely pass it `connect.host` and `connect.port`. + +`connection` does not declare an external DAP endpoint or remote adapter +execution (for example `ssh host gdb -i=dap`). These require separate host +integration; this contract and its validation do not establish that support. +The current transport scope is ordinary TCP connection and lifecycle, reusing +the existing socket implementation and retaining stdio. It does not add proxy, +tunnel, port-forwarding or SSH execution settings, or relax the loopback rule +for language-server-provided DAP endpoints. Adapter-native source mapping should +be reused where applicable, without translating the same path pair again in Core. + +The following illustrates nested argument binding and mapping shape, not a +complete debugpy `--listen` connection configuration: + +```json +"configurations": { + "attach": { + "arguments": { "timeout": 5000 }, + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { "shape": "objects", "argument": "pathMappings", + "localKey": "localRoot", "remoteKey": "remoteRoot" } + } + } + } +} +``` + +- The values of `connection` are paths into the adapter's `arguments`, because + adapters name and nest them differently: java-debug takes `hostName` and + `port`, nested arguments may use `connect.host` and `connect.port`, js-debug + `address` and `port`, gdb one `host:port` string. A path is 1–4 dot-separated names of + letters, digits, `_`, and `-` (up to 64 characters each) and Core creates the + intermediate objects. +- `host` and `port` are declared together. `address` takes the endpoint as one + `host:port` string, with an IPv6 host in brackets (`[2001:db8::1]:2345`); it + stands in for `host` and `port`, or is a further destination beside them. + At least one of the two forms is required. +- `pathMappings` is optional and has four shapes, one per way adapters take + source roots: + - `objects` fills a list of objects, one per mapping: `argument` names the + list, `localKey` and `remoteKey` the keys of each object + (`[{ "localRoot": …, "remoteRoot": … }]`). + - `first` is for an adapter with a single pair of roots and fills `local` + and `remote` from the one mapping. It takes exactly one: a target with + more is refused rather than truncated. + - `dictionary` fills an object at `argument` whose keys are the roots of one + side and whose values are the other (`"keyedBy": "remote"` gives + `{ "": "" }`). A target with the same root twice on the + keyed side is refused, since an object holds one value per key. + - `pairs` fills a list of two-element arrays at `argument`, in the order + `"order": "remoteLocal"` (`[["", ""]]`) or `"localRemote"`. + An adapter whose mapping is none of these declares no `pathMappings`, and a + start that sends mappings to it is refused. +- A destination may not equal, contain, or sit inside another destination, the + `processId` or `cwd` argument a declared binding of the same configuration + fills, or a name that the configuration's own `arguments` already sets. Defaults win over bindings, so without this rule + the adapter would connect somewhere other than the endpoint the user + confirmed. A manifest that does it is `plugin.manifest.invalid`. +- `connection` is allowed under `attach` only. `selectors[].targetKinds` may + list `connection`, and only for an adapter that declares the binding. + +The target of a start is `{ "kind": "connection", "host", "port", +"pathMappings"?: [{ "local", "remote" }] }`. `host` is a DNS name or an IP +literal of 1–253 characters, with labels of up to 63 letters, digits, and +hyphens, none leading or trailing: no scheme, port, brackets, whitespace, or +user. A dotted run of digits must be a valid IPv4 address, and IPv6 zone IDs and +internationalized names (use punycode) are not accepted. `port` is 1–65535. +There are at most 16 mappings; each root is 1–1024 characters without control +characters, and a `local` root must be an absolute path on the host's +platform. + +`debug.adapters.startArguments` answers with the filled `arguments` and a +`connection: { host, port, remote }`. `remote` is `false` only for what +Core can prove local from the text: `localhost` (any case), `127.0.0.0/8`, and +`::1`. Every other host, including `0.0.0.0`, `::ffff:127.0.0.1`, and +`localhost.example.org`, is `remote: true`. A host asks the user to confirm each +`remote` endpoint it has not been told to trust, and does not remember the +answer by default. `debug.attach` covers remote attach; there is no separate +permission. `plan.debugAdapter.remoteAttach` in the launch plan is +`{ "pathMappings": boolean, "maxPathMappings": number }` for an adapter that +takes a connection (`maxPathMappings` is 0 without mappings, 1 for `first`, and +16 otherwise) and `null` otherwise, so a host knows whether to offer the +host-and-port form and the mapping list, and how many rows the list may hold. + The cases in `fixtures/debug-adapter-rejections.json` state the stable code and field for each refusal and the layer that reports it: `schema`, `coreManifest`, `corePlan` (one start: stale registration or runtime -selection, a target missing a fact a binding reads), and `coreInstall` +selection, a target missing a fact a binding reads; the request defaults to +`launch` and `context.request` sets `attach`), and `coreInstall` (a `parametersSchema` that is missing or links outside the package). -`fixtures/debug-adapter.plugin.json` is a valid example. +`fixtures/debug-adapter.plugin.json` is a valid example, and +`fixtures/debug-adapter-remote-attach.plugin.json` declares six adapters that +differ in how they take a connection. ## Plugin backend diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json b/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json index 89ee530c54..19f09f99d9 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json @@ -344,6 +344,174 @@ "layer": "corePlan", "edits": [{"op": "set", "path": "/context/registration/currentGeneration", "value": 2}], "expected": {"code": "debug.provider.stale", "field": "registration"} + }, + { + "id": "connection-binding-on-launch", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.targetBindings"} + }, + { + "id": "connection-host-without-port", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-without-destination", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection"} + }, + { + "id": "connection-malformed-argument-path", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect..host", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-argument-path-too-deep", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "a.b.c.d.e", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-destinations-overlap", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect", "port": "connect.port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.port"} + }, + { + "id": "connection-overlaps-process-binding", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"processId": "target.processID", "connection": {"host": "processId", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-collides-with-default-argument", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}, "arguments": {"hostName": "localhost"}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-collides-with-default-object", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port"}}, "arguments": {"connect": "tcp"}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-path-mapping-keys-equal", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "root", "remoteKey": "root"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.remoteKey"} + }, + { + "id": "connection-path-mapping-malformed-key", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "local root", "remoteKey": "remoteRoot"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.localKey"} + }, + { + "id": "connection-path-mapping-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "first", "local": "connect.host", "remote": "remoteRoot"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.local"} + }, + { + "id": "connection-path-mapping-unknown-shape", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "triples", "argument": "sourceMap"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-unknown-field", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port", "scheme": "https"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.scheme"} + }, + { + "id": "connection-selector-without-binding", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"processId": "target.processID"}}}}, {"op": "set", "path": "/block/debugAdapters/0/selectors", "value": [{"targetKinds": ["connection"]}]}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].selectors"} + }, + { + "id": "remote-target-missing-host", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.host"} + }, + { + "id": "remote-target-host-with-scheme", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "http://build.example.org", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.host"} + }, + { + "id": "remote-target-missing-port", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org"}}], + "expected": {"code": "debug.target.invalid", "field": "target.port"} + }, + { + "id": "remote-target-port-out-of-range", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 70000}}], + "expected": {"code": "debug.target.invalid", "field": "target.port"} + }, + { + "id": "remote-target-relative-local-root", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "localRoot", "remoteKey": "remoteRoot"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "src", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-mappings-without-binding", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-for-launch", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["launch", "attach"]}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.kind"} + }, + { + "id": "connection-path-mapping-dictionary-unknown-side", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "sourceMap", "keyedBy": "both"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-path-mapping-pairs-unknown-order", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "pairs", "argument": "sourceMap", "order": "sideBySide"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-path-mapping-dictionary-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "connect.host", "keyedBy": "remote"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.argument"} + }, + { + "id": "connection-path-mapping-pairs-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "pairs", "argument": "connect", "order": "localRemote"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.argument"} + }, + { + "id": "remote-target-mappings-share-a-keyed-root", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "sourceMap", "keyedBy": "remote"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}, {"local": "/work/lib", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-more-mappings-than-a-single-root-takes", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "first", "local": "localRoot", "remote": "remoteRoot"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}, {"local": "/work/lib", "remote": "/srv/lib"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} } ] } diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json new file mode 100644 index 0000000000..fa9f8689e5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json @@ -0,0 +1,184 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.debug-adapter-remote-attach", + "displayName": "Debug Adapter Remote Attach Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { "debugAdapters": 1, "serverProcess": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "debug.attach", + "debug.launch" + ], + "activationEvents": [ + "onDebug:flat", + "onDebug:nested", + "onDebug:scalar", + "onDebug:joined", + "onDebug:keyed", + "onDebug:paired" + ], + "languages": [], + "debugAdapters": [ + { + "id": "flat", + "displayName": "Flat Remote Debugger", + "adapterID": "flat-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "timeout": 5000 }, + "targetBindings": { + "connection": { "host": "hostName", "port": "port" } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/flat-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "nested", + "displayName": "Nested Remote Debugger", + "adapterID": "nested-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "justMyCode": false }, + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { + "shape": "objects", + "argument": "pathMappings", + "localKey": "localRoot", + "remoteKey": "remoteRoot" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/nested-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "scalar", + "displayName": "Single Root Remote Debugger", + "adapterID": "scalar-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "address", + "port": "port", + "pathMappings": { + "shape": "first", + "local": "localRoot", + "remote": "remoteRoot" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/scalar-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "joined", + "displayName": "Address Remote Debugger", + "adapterID": "joined-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "request": "attach" }, + "targetBindings": { + "connection": { "address": "target" } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/joined-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "keyed", + "displayName": "Keyed Source Map Remote Debugger", + "adapterID": "keyed-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { + "shape": "dictionary", + "argument": "sourceMap", + "keyedBy": "remote" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/keyed-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "paired", + "displayName": "Paired Source Map Remote Debugger", + "adapterID": "paired-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "hostName", + "port": "port", + "pathMappings": { + "shape": "pairs", + "argument": "sourceMap", + "order": "remoteLocal" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/paired-dap" } + }, + "transport": "stdio" + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 27992ea6ff..5b0c193a1e 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -235,6 +235,92 @@ "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)[.]{1,2}(?:/|$))[^\\\\:\\u0000]+$" }, + "debugArgumentName": { + "description": "One DAP argument name or object key.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}$" + }, + "debugArgumentPath": { + "description": "Dotted path to a DAP argument, up to four names, for example `hostName` or `connect.host`. Objects on the way are created; the path must not overlap another binding or an argument the configuration sets itself.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}(\\.[A-Za-z_][A-Za-z0-9_-]{0,63}){0,3}$" + }, + "debugConnectionBinding": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "description": "Receives `target.host`, a string. Declared together with `port`.", + "$ref": "#/$defs/debugArgumentPath" + }, + "port": { + "description": "Receives `target.port`, an integer 1-65535. Declared together with `host`.", + "$ref": "#/$defs/debugArgumentPath" + }, + "address": { + "description": "Receives `host:port` as one string; an IPv6 host is written in brackets.", + "$ref": "#/$defs/debugArgumentPath" + }, + "pathMappings": { + "description": "How local and remote source roots reach the adapter. Omit when the adapter takes none; the host then does not offer the choice.", + "oneOf": [ + { + "description": "A list of objects at `argument`, each with the local root under `localKey` and the remote root under `remoteKey`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "localKey", "remoteKey"], + "properties": { + "shape": { "const": "objects" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "localKey": { "$ref": "#/$defs/debugArgumentName" }, + "remoteKey": { "$ref": "#/$defs/debugArgumentName" } + } + }, + { + "description": "The first mapping only, as two strings at `local` and `remote`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "local", "remote"], + "properties": { + "shape": { "const": "first" }, + "local": { "$ref": "#/$defs/debugArgumentPath" }, + "remote": { "$ref": "#/$defs/debugArgumentPath" } + } + }, + { + "description": "An object at `argument` with one root as the key and the other as the value, keyed by the `keyedBy` side. A target with the same root twice on that side is refused.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "keyedBy"], + "properties": { + "shape": { "const": "dictionary" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "keyedBy": { "enum": ["local", "remote"] } + } + }, + { + "description": "A list of two-element arrays at `argument`, the roots in the given `order`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "order"], + "properties": { + "shape": { "const": "pairs" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "order": { "enum": ["remoteLocal", "localRemote"] } + } + } + ] + } + }, + "dependentRequired": { + "host": ["port"], + "port": ["host"] + }, + "anyOf": [ + { "required": ["host"] }, + { "required": ["address"] } + ] + }, "debugServerProcessV1": { "$ref": "#/$defs/serverProcess", "description": "Reuse existing launch fields and validation. LSP initializationOptions have no meaning for a DAP transport and are excluded.", @@ -482,6 +568,10 @@ }, "cwd": { "const": "target.workingDirectory" + }, + "connection": { + "description": "Attach only. Where the remote target's host and port go. Unlike the other bindings, each value is the destination argument path, because adapters name and nest these differently.", + "$ref": "#/$defs/debugConnectionBinding" } } }, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index fd40fef939..2e7b2ee49a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3313,8 +3313,8 @@ process, with `plugin.launch.unsupportedTransport`. `debug.adapters.startArguments` accepts `{ manifest, adapterID, request: "launch" | "attach", facts: { registration: { generation, currentGeneration }, runtimeSelection?, target: { kind, path?, processID?, workingDirectory?, -executable? } } }` -and returns `{ status: "ok", arguments }`: the request's configured +executable?, host?, port?, pathMappings? } } }` +and returns `{ status: "ok", arguments, connection? }`: the request's configured `arguments` with each `targetBindings` name filled from the target unless the configuration already sets it. `program` is filled from `target.path`, or from `target.executable` (an absolute path the host found) when the configuration @@ -3322,6 +3322,29 @@ binds it to `target.executable`. Refusals use the `rejected` shape with `debug.provider.stale`, `debug.request.unsupported`, `debug.runtime.stale`, or `debug.target.invalid`. +A target of `kind: "connection"` attaches to a debuggee on a host and port: +`host` is a DNS name or IP literal, `port` is 1–65535, and `pathMappings` is up +to 16 `{ local, remote }` source-root pairs whose `local` is an absolute path on +the host's platform. It is accepted only for `attach` and only by an adapter +whose attach configuration declares a `connection` binding; the binding names +the argument paths that receive each fact, so Core writes `hostName`, +`connect.host`, or a joined `host:port` string as that adapter reads it. A bad +`host`, `port`, or `pathMappings` entry is `debug.target.invalid` with the field +`target.host`, `target.port`, or `target.pathMappings`; so is +`pathMappings` sent to an adapter that declares no mapping argument, and a +`connection` target for a `launch` or an adapter without the binding +(`target.kind`). The response then carries `connection: { host, port, remote }`. +`remote` is `false` only for `localhost`, `127.0.0.0/8`, and `::1` read from the +text; every other host is `true`, including `0.0.0.0` and `::ffff:127.0.0.1`. +Core does not resolve names and opens no socket; the host asks the user before it +connects to a `remote` endpoint and does not remember the answer by default. The +launch plan's `debugAdapter.remoteAttach` is `{ pathMappings, maxPathMappings }` +for an adapter that takes a connection and `null` otherwise. The mappings reach +the adapter in the shape its binding declares (`objects`, `first`, `dictionary`, +or `pairs`); a shape that cannot hold them all (`first` takes one, a +`dictionary` takes each root once on its keyed side) refuses the target with +`target.pathMappings` instead of dropping some. + `debug.adapters.executablePlan` accepts `{ manifest, adapterID, settings?, projectSettings?, workspace?, targetPath? }` and returns `{ status: "ok", plan }`. `plan` is `null` when the adapter's launch configuration declares no diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index f681dfb7bb..beaaabda91 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -7,6 +7,7 @@ "evidence": [ "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", "shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json", + "shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json", "shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json", "rust/lithe-core/src/debug/adapters.rs", "macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift", @@ -34,11 +35,12 @@ "evidence": [ "rust/lithe-core/src/debug/adapters.rs", "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", - "shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json" + "shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json", + "shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。" + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。" } diff --git a/shared/proposals/debug-adapters-1210/README.md b/shared/proposals/debug-adapters-1210/README.md index a37ef0849e..43be2fb6c8 100644 --- a/shared/proposals/debug-adapters-1210/README.md +++ b/shared/proposals/debug-adapters-1210/README.md @@ -78,7 +78,11 @@ debugpy、lldb-dap 可以走 stdio(宿主已不再内置 Python、Rust 调试 保留现有 `DebugLaunchConfiguration`、Run 配置 ID、三层覆盖和导入参数。处理顺序是:适配器默认参数 → 现有 Run/导入/用户覆盖链 → 只补缺失的目标字段 → 可选参数 schema → Core 规范化。schema 的 `default` 是文档提示,实际默认值由 Core 实现。 -`targetBindings` 限定为 `program ← target.path`、`processId ← target.processID`、`cwd ← target.workingDirectory`;不允许把进程号当路径,也不引入任意表达式。如果所选目标缺少必需事实且显式参数也未提供,准备阶段失败,不启动进程。文件目标不会自动产生 `program`,插件需要选择映射或由现有 Run 配置提供参数;不能仅声明 selector 就声称能启动。 +`targetBindings` 限定为 `program ← target.path`、`processId ← target.processID`、`cwd ← target.workingDirectory`,以及只用于 `attach` 的 `connection`;不允许把进程号当路径,也不引入任意表达式。如果所选目标缺少必需事实且显式参数也未提供,准备阶段失败,不启动进程。 + +`connection` 让适配器附加到另一台机器上的被调试程序(适配器仍在本机)。与前三项不同,它的值是**适配器参数里的目的地路径**,因为各适配器给主机和端口起的名字、嵌套都不同(`hostName`/`port`、`connect.host`/`connect.port`、一个 `host:port` 字符串):`host` 与 `port` 成对,或 `address` 单个字符串;可选 `pathMappings` 有四种形状:`objects`(对象列表)、`first`(唯一一条映射写成两个字符串)、`dictionary`(以其中一侧为键的对象)、`pairs`(二元数组列表);形状装不下用户给的全部映射时拒绝而不是丢弃。事实来源仍然封闭:`target.host`、`target.port`、`target.pathMappings`。目的地与其他目的地、`processId`/`cwd` 或同一配置的默认 `arguments` 冲突的清单,在声明期被拒绝,否则“默认值优先”会让适配器连到用户没有确认的地方。Core 只按字面量把 `localhost`、`127.0.0.0/8`、`::1` 判为本机,其余都返回 `remote: true` 交宿主确认。不新增权限,`debug.attach` 已覆盖。决策原因、被否方案和不做的内容见主 Note 的“远程 attach”。文件目标不会自动产生 `program`,插件需要选择映射或由现有 Run 配置提供参数;不能仅声明 selector 就声称能启动。 + +这里绑定的是适配器 → 被调试程序的端点,不是 IDE → 适配器的 DAP 地址。默认 debugpy `--listen` 启动的是远端 DAP 适配器,需要独立的 DAP 直连入口,不能只填嵌套参数就算支持。当前只推进复用 #1520 的普通 TCP 连接与生命周期;代理、隧道、端口转发和 SSH 远端执行另做方案,不新增相关清单字段,见[网络边界 Note](../../../.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md)。 路径身份和执行路径分开:工作区内源码的持久化身份仍为相对路径;Build/Run 可以提供用户已选择或授权的外部产物引用,平台在执行时解析为绝对路径。源码外构建和工作区外可执行文件无需伪造相对路径。本提案不新增 `workspacePath`、`configuration.id` 或 `target.configurationID` 的重复结构。