From 979ba5ae58b45ccddbeb81f9c26622c4d611b12a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 10:11:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(debug):=20#1210=20tcpLanguageServer=20?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E4=BC=A0=E8=BE=93=EF=BC=9A=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E9=9A=8F=E6=8F=92=E4=BB=B6=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=BF=90=E8=A1=8C=EF=BC=88?= =?UTF-8?q?Java=20=E8=BF=81=E5=85=A5=E6=8F=92=E4=BB=B6=E7=9A=84=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debugAdapters 新增 tcpLanguageServer 传输,用于调试服务寄居在同一插件语言服务器里的适配器(Java Debug Server 之于 JDT LS)。该传输没有 launch.process,endpoint 只有 {languageID, command}:languageID 必须是同一插件 languageServers[] 里声明过的,command 限 [A-Za-z0-9._:-]{1,128}。宿主不拉起进程,而是启动该插件的语言服务器,用 workspace/executeCommand 执行 command,把返回值解析成 1…65535 的端口后连接 127.0.0.1。 Rust Core:DebugLaunch.process 改为可选(其他传输仍必填),新增 DebugTransport::TcpLanguageServer 与 DebugEndpoint 校验;programs() 产出 languageServerDebugAdapter,命令变化需要重新批准;debug_adapter_launch_plan 对无进程适配器返回插件语言服务器的启动计划和 debugAdapter 描述符。 macOS:DebugAdapterProcess 换成 DebugAdapterLaunch(host 为 .process 或 .languageServer);MacJavaDebugAdapterTransport 改名 MacHostedPortDebugAdapterTransport,端口来源由宿主注入,不再知道 Java;LanguageToolingSessionManager 新增 startHostedDebugAdapter,校验语言服务器确由该插件拥有,内置 Java 语言服务被拒绝;端口解析抽成 debugAdapterPort(from:)。tcpPort、tcpAnnounced、attach 和 runSelectedRuntime 仍被拒绝,拒绝文案改为只支持 stdio 与语言服务器承载的适配器。 契约与文档:plugin-manifest.schema.json、契约 README、rust-core-api.md、声明草案和 #1210 Agent Note 同步,新增 debug-adapter-language-server 契约 fixture;功能矩阵补充证据与验证说明。i18n:3 条 Java 专属文案改为通用文案,新增 3 条,调整传输拒绝文案,并重新生成产物。 测试:Rust 新增 3 个 Core 测试;Swift 新增解析器、传输、端口解析(16 个用例)与语言服务器归属测试。 --- ...ified-debug-engine-and-adapter-registry.md | 21 +- macos/Resources/en.lproj/Localizable.strings | 20 +- .../zh-Hans.lproj/Localizable.strings | 20 +- .../RustDeclarativePluginOperations.swift | 22 +- ... MacHostedPortDebugAdapterTransport.swift} | 15 +- .../MacPluginDebugAdapterTransport.swift | 64 +++-- .../Platform/MacOS/MacServiceContainer.swift | 18 +- ...acPluginLanguageServerLaunchResolver.swift | 49 +++- .../Plugins/DeclarativePluginContracts.swift | 27 +- .../LanguageToolingSessionManager.swift | 60 +++- .../LanguageIntelligenceModuleTests.swift | 48 ++++ .../LitheTests/PluginDebugAdapterTests.swift | 261 +++++++++++++++++- .../RealJavaDebugIntegrationTests.swift | 4 +- .../RunConfigurationIntegrationTests.swift | 4 +- rust/lithe-core/src/debug/adapters.rs | 117 +++++++- rust/lithe-core/src/plugins/declarative.rs | 35 ++- .../src/tests/declarative_plugins.rs | 151 ++++++++++ shared/contracts/plugins/v1/README.md | 8 +- .../debug-adapter-language-server.plugin.json | 51 ++++ .../plugins/v1/plugin-manifest.schema.json | 46 ++- shared/contracts/rust-core-api.md | 15 +- shared/i18n/locales/en-US.json | 12 +- shared/i18n/locales/zh-CN.json | 12 +- shared/i18n/messages.json | 56 +++- .../features/plugin-debug-adapters.json | 9 +- .../proposals/debug-adapters-1210/README.md | 4 +- 26 files changed, 1011 insertions(+), 138 deletions(-) rename macos/Sources/Lithe/Platform/MacOS/Debug/{MacJavaDebugAdapterTransport.swift => MacHostedPortDebugAdapterTransport.swift} (87%) create mode 100644 shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json 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 abf9053818..4d86ef4f55 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 @@ -99,7 +99,7 @@ Registry 注册时不启动程序做验证。一个适配器在接入验收中 完整字段和拒绝规则见[声明草案](../../../../shared/proposals/debug-adapters-1210/README.md)(正式契约见 `shared/contracts/plugins/v1/README.md`),这里只记录决策: -- 插件只写一个 `plugin.json.debugAdapters` 块,必填 `id`、`displayName`、`launch`。`launch` 由两部分组成:复用 `serverProcess` 的进程,加上 `stdio`、`tcpAnnounced` 或 `tcpPort` 三种传输之一。 +- 插件只写一个 `plugin.json.debugAdapters` 块,必填 `id`、`displayName`、`launch`。`launch` 由两部分组成:复用 `serverProcess` 的进程,加上 `stdio`、`tcpAnnounced` 或 `tcpPort` 三种传输之一;调试服务寄居在同一插件的语言服务器里时(Java Debug Server 之于 JDT LS),改用 `tcpLanguageServer`,此时没有 `process`,只有 `endpoint`。 - 拟定 API 4、`requires.debugAdapters: 1`、`debug.launch` / `debug.attach` 权限。需要 Run 选中的运行时时,使用 `serverProcess: 2` 的 `runSelectedRuntime`。 - 标准能力跟随 DAP 协商,清单只能禁用。没有依赖、导出、工作流、resolver 或 `hostIntegration` 字段;插件依赖另见[独立平台提案](2026-10-25-plugin-dependencies-and-exports.md)。 - 内置 Provider(Java、Go、Node、Python、LLDB)和第三方声明转成同一种描述后进入 Registry。身份由插件 ID 加局部 ID 组成,内置 Provider 使用保留空间。 @@ -139,7 +139,7 @@ macOS 声明式 Provider: - Rust Core 新增 `languageServerLaunchPlan` 的 `debugAdapterID` 选择器和 `debug.adapters.startArguments` 命令;后者按 manifest、adapterID、request 与事实(注册代次、目标、运行时选择)返回启动参数,拒绝码为 `debug.provider.stale`、`debug.request.unsupported`、`debug.runtime.stale`、`debug.target.invalid`、`plugin.launch.unavailable`。 - 宿主启用模块 `.debug-adapters`(只承载启用、隔离与安全模式状态)。容器按语言 ID 建立插件适配器会话;Java 等内置语言优先,宿主不按插件名字分支。 - 启动计划在会话 `start(rootURL:)` 时才解析,因为启动依赖工作区;被拒绝的计划就是启动错误。`adapterID` 声明时默认等于 `id`,会话创建时即需要(DAP `initialize.adapterId`),所以随描述符提前提供。 -- 适配器可执行文件只允许来自插件包或 `systemCommand`;工具发现覆盖 Xcode 工具链(`xcrun` 等)。TCP、attach 和 `runSelectedRuntime` 还没有接入宿主,之后再做。 +- 适配器可执行文件只允许来自插件包或 `systemCommand`;工具发现覆盖 Xcode 工具链(`xcrun` 等)。`tcpPort`、`tcpAnnounced`、attach 和 `runSelectedRuntime` 还没有接入宿主,之后再做;`tcpLanguageServer` 已接入,见下一节。 - 生命周期与所有权:适配器进程归会话,随会话停止。适配器状态目录是 Application Support 下的 `<插件状态目录>/debug-adapters/`,不写 app bundle 或插件包,不影响代码签名和 Sparkle 增量更新;`${workspaceStateDir}` 按工作区摘要隔离。被调试程序的启动配置由 `startArguments` 生成,宿主只在没有内置 provider 处理时回落到它。 macOS 的 P4 纵向验收用 `lldb-dap` 推进:官方 LLDB 插件(`io.github.lithe-idea.lldb-debug`,仓库 [lithe-plugin-lldb](https://github.com/Lithe-IDEA/lithe-plugin-lldb))已作为预发布收录在 Lithe Store,需要支持 API 4 的 Lithe 才能安装;必选层全流程的验收结果还没有记录,不算 P4 完成。这一轮在契约里补了三件事,全部在尚未发布的 API 4 / `debugAdapters` v1 内,没有已发布的契约被改动: @@ -149,6 +149,23 @@ macOS 的 P4 纵向验收用 `lldb-dap` 推进:官方 LLDB 插件(`io.github 只贡献调试适配器的插件在 macOS 上安装、更新、卸载免重启,见[插件热激活](../../implemented/architecture/2026-10-26-plugin-live-activation.md)。这是宿主后端能力,不是清单字段,插件契约不变。Windows 的 P2–P4 还没有开始,不属于这一轮。 +#### Java 迁入 Java Support 插件(P5 的最后一块内置路径) + +Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJavaDebugAdapterTransport`、Core 里 `adapter_id == "java"` 的分支)。迁移拆成三个 PR,每个都能单独合并、单独回退: + +| PR | 内容 | 状态 | +| --- | --- | --- | +| PR-A 传输 | 新增 `tcpLanguageServer` 传输:`launch.process` 必须缺省,`endpoint` 只有 `{languageID, command}`;`languageID` 必须是同一插件 `languageServers[]` 里声明过的,`command` 限 `[A-Za-z0-9._:-]{1,128}`。宿主不拉起进程,而是启动该插件的语言服务器,用 `workspace/executeCommand` 执行 `command`(Java 是 `vscode.java.startDebugSession`),把返回值解析成 1…65535 的端口再连 `127.0.0.1`。`MacJavaDebugAdapterTransport` 改名 `MacHostedPortDebugAdapterTransport`,端口来源由宿主注入,不再知道 Java | 本 PR | +| PR-B 语言专属扩展 | 把 Java 独有的三件事从“按适配器名字”改成“按描述声明”:`redefineClasses` 热替换、`stepFilters` 默认值(`java_step_filters`、`defaults_for_adapter("java")`)、`JAVA_DEBUG_DISABLE_DEVTOOLS_RESTART` 追加到 `vmArgs` | 未开始 | +| PR-C 切换与删除 | Java Support 清单升到 API 4 并声明 `debugAdapters`;删除 `debugFactories["java"]`、Core 里的 Java 分支和 `MacJavaDebugAdapterTransport` 的残留引用 | 依赖 #1495(J7/C2):启动目标(`JavaDebugLaunchTarget`)和测试调试(`JavaTestDebugLaunchService`、`JavaTestResultServer`)还在宿主里,要先随 J7/C2 迁出 | + +关键取舍: +- **为什么是新传输,而不是 `tcpPort` 加一个“先执行命令”的钩子。** Java Debug Server 的端口每次会话都由语言服务器分配,且只能通过它自己的会话问到。`tcpPort` 的端口是清单里写死或进程公布的,扩展它等于在 `launch.process` 之外再开一条“向某个服务要端口”的通道;单独的传输让校验规则(无 `process`、`endpoint` 只有两个字段、语言服务器归属)一目了然,也与构建 provider 的 `{languageServer: ""}` 形态一致。 +- **`endpoint.command` 只允许一个命令名,不允许参数。** `vscode.java.startDebugSession` 本身不带参数(旧实现传的就是空数组),主类和 classpath 在随后的 `launch` 请求里给,所以清单只需要命令名;不开放参数,是为了插件不能借这个端点让宿主向语言服务器发送任意负载。`programs()` 把它列入审批内容(`languageServerDebugAdapter`),命令名变了要重新批准。 +- **语言服务器归属由宿主校验,而不是信任清单。** 清单声明的 `languageID` 必须是同一插件的 `languageServers[]`,运行时 `startHostedDebugAdapter` 还要核对该语言的运行时来自同一个插件;内置的 Java 语言服务(不属于插件)被拒绝,避免插件借内置语言服务器执行命令。 +- **停止调试不停止语言服务器。** 传输 `stop()` 只关闭 TCP 连接;语言服务器是共享资源,沿用“结束 Java 调试不终止 JDT LS”的所有权规则。 +- 被否方案:让宿主按 `adapterID == "java"` 特判端口获取。这正是要删除的东西。 + P0–P5 完成后关闭 #1210。可选扩展、插件依赖和私有 resolver 都不是关闭条件。 ### 正确做法 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6002dada2a..3df02b6ab3 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -930,7 +930,6 @@ "Could not compare the local adapter with the verified version." = "Could not compare the local adapter with the verified version."; "Could not confirm" = "Could not confirm"; "Could not connect to the Debug Adapter: %@" = "Could not connect to the Debug Adapter: %@"; -"Could not connect to the Java Debug Server: %@" = "Could not connect to the Java Debug Server: %@"; "Could not copy your Stable settings" = "Could not copy your Stable settings"; "Could not find the Java source for %@" = "Could not find the Java source for %@"; "Could not import MCP JSON. Check the mcpServers object and try again." = "Could not import MCP JSON. Check the mcpServers object and try again."; @@ -3619,6 +3618,8 @@ "The %@ debug provider does not support %@." = "The %@ debug provider does not support %@."; "The %@ language provider is not installed." = "The %@ language provider is not installed."; "The %@ language server is disabled in this workspace. Enable it in Settings > LSP." = "The %@ language server is disabled in this workspace. Enable it in Settings > LSP."; +"The %@ language server of this plugin is not available." = "The %@ language server of this plugin is not available."; +"The %@ language server returned an invalid TCP port for its debug adapter." = "The %@ language server returned an invalid TCP port for its debug adapter."; "The %@ provider does not support %@." = "The %@ provider does not support %@."; "The AI provider returned an HTTP error." = "The AI provider returned an HTTP error."; "The AI provider returned an empty commit message." = "The AI provider returned an empty commit message."; @@ -3666,14 +3667,12 @@ "The Hook library response was not recognised." = "The Hook library response was not recognised."; "The JDK the new project uses. It must be at least the Java version below." = "The JDK the new project uses. It must be at least the Java version below."; "The JVM does not expose supported heap counters through jstat." = "The JVM does not expose supported heap counters through jstat."; -"The Java Debug Server connection is stopped." = "The Java Debug Server connection is stopped."; "The Java Debug Server returned an invalid TCP port." = "The Java Debug Server returned an invalid TCP port."; "The Java Support plugin does not provide %@. Reinstall the plugin." = "The Java Support plugin does not provide %@. Reinstall the plugin."; "The Java argument file could not be encoded as UTF-8." = "The Java argument file could not be encoded as UTF-8."; "The Java language server did not take the Maven configuration: %@" = "The Java language server did not take the Maven configuration: %@"; "The Java language service could not resolve a main class for this file." = "The Java language service could not resolve a main class for this file."; "The Java language service failed to prepare the project." = "The Java language service failed to prepare the project."; -"The Java language service is unavailable." = "The Java language service is unavailable."; "The Java source for %@ could not be resolved." = "The Java source for %@ could not be resolved."; "The Java test result listener did not become ready in time." = "The Java test result listener did not become ready in time."; "The Lithe server was installed on this host." = "The Lithe server was installed on this host."; @@ -3726,8 +3725,10 @@ "The current worktree cannot be removed here." = "The current worktree cannot be removed here."; "The debug adapter %@ must launch a packaged executable or a system command." = "The debug adapter %@ must launch a packaged executable or a system command."; "The debug adapter %@ must launch an executable from its plugin package." = "The debug adapter %@ must launch an executable from its plugin package."; +"The debug adapter %@ names no language server to host it." = "The debug adapter %@ names no language server to host it."; "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "The debug adapter %@ needs %@, which was not found. Install it, then try again."; -"The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio adapters are supported." = "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio adapters are supported."; +"The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported."; +"The debug adapter connection is stopped." = "The debug adapter connection is stopped."; "The debug adapter did not provide a terminal executable." = "The debug adapter did not provide a terminal executable."; "The debug adapter provided an invalid terminal working directory." = "The debug adapter provided an invalid terminal working directory."; "The debugger did not return object fields in time." = "The debugger did not return object fields in time."; @@ -3766,6 +3767,7 @@ "The interface language changes immediately. English is the default." = "The interface language changes immediately. English is the default."; "The item can be recovered from the macOS Trash." = "The item can be recovered from the macOS Trash."; "The language server could not apply this setting. Restart it to use the new value." = "The language server could not apply this setting. Restart it to use the new value."; +"The language service that hosts the debug adapter is unavailable." = "The language service that hosts the debug adapter is unavailable."; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; "The local Codex account changed. Reconnect to refresh quota." = "The local Codex account changed. Reconnect to refresh quota."; @@ -5216,7 +5218,6 @@ "native.couldNotCompareTheLocalAdapterWithTheVerifiedVersion" = "Could not compare the local adapter with the verified version."; "native.couldNotConfirm" = "Could not confirm"; "native.couldNotConnectToTheDebugAdapter" = "Could not connect to the Debug Adapter: %1$@"; -"native.couldNotConnectToTheJavaDebugServer" = "Could not connect to the Java Debug Server: %1$@"; "native.couldNotCopyYourStableSettings" = "Could not copy your Stable settings"; "native.couldNotFindTheJavaSourceFor" = "Could not find the Java source for %1$@"; "native.couldNotImportMcpJsonCheckTheMcpserversObjectAndTryAgain" = "Could not import MCP JSON. Check the mcpServers object and try again."; @@ -8148,17 +8149,19 @@ "native.theCurrentFileIsOutsideTheWorkspace" = "The current file is outside the workspace: %1$@"; "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "The current file will be saved to Local History before it is replaced."; "native.theCurrentWorktreeCannotBeRemovedHere" = "The current worktree cannot be removed here."; +"native.theDebugAdapterConnectionIsStopped" = "The debug adapter connection is stopped."; "native.theDebugAdapterDidNotProvideATerminal" = "The debug adapter did not provide a terminal executable."; "native.theDebugAdapterIsNotInstalledYet" = "The %1$@ Debug Adapter is not installed yet."; "native.theDebugAdapterIsNotReady" = "The Debug Adapter is not ready."; "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand" = "The debug adapter %1$@ must launch a packaged executable or a system command."; "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage" = "The debug adapter %1$@ must launch an executable from its plugin package."; +"native.theDebugAdapterNamesNoLanguageServerToHostIt" = "The debug adapter %1$@ names no language server to host it."; "native.theDebugAdapterNeedsWhichWasNotFoundInstall" = "The debug adapter %1$@ needs %2$@, which was not found. Install it, then try again."; "native.theDebugAdapterProvidedAnInvalidTerminalWorking" = "The debug adapter provided an invalid terminal working directory."; "native.theDebugAdapterRejectedTheRequest" = "The Debug Adapter rejected the request."; "native.theDebugAdapterReturnedAnInvalidResponse" = "The Debug Adapter returned an invalid %1$@ response."; "native.theDebugAdapterStopped" = "The Debug Adapter stopped."; -"native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "The debug adapter %1$@ uses the %2$@ transport, which Lithe cannot start yet. Only stdio adapters are supported."; +"native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "The debug adapter %1$@ uses the %2$@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported."; "native.theDebugProviderDoesNotSupport" = "The %1$@ debug provider does not support %2$@."; "native.theDebuggerDidNotReturnObjectFieldsIn" = "The debugger did not return object fields in time."; "native.theDefaultChangeListCannotBeRenamed" = "The default ChangeList cannot be renamed."; @@ -8206,18 +8209,19 @@ "native.theJDKTheNewProjectUsesItMustBeAtLeastTheJavaVersionBelow" = "The JDK the new project uses. It must be at least the Java version below."; "native.theJVMDoesNotExposeSupportedHeapCountersThroughJstat" = "The JVM does not expose supported heap counters through jstat."; "native.theJavaArgumentFileCouldNotBeEncoded" = "The Java argument file could not be encoded as UTF-8."; -"native.theJavaDebugServerConnectionIsStopped" = "The Java Debug Server connection is stopped."; "native.theJavaDebugServerReturnedAnInvalidTcp" = "The Java Debug Server returned an invalid TCP port."; "native.theJavaLanguageServerDidNotTakeTheMavenConfiguration" = "The Java language server did not take the Maven configuration: %1$@"; "native.theJavaLanguageServiceCouldNotResolveA" = "The Java language service could not resolve a main class for this file."; "native.theJavaLanguageServiceFailedToPrepareTheProject" = "The Java language service failed to prepare the project."; -"native.theJavaLanguageServiceIsUnavailable" = "The Java language service is unavailable."; "native.theJavaSourceForCouldNotBeResolved" = "The Java source for %1$@ could not be resolved."; "native.theJavaSupportPluginDoesNotProvideReinstall" = "The Java Support plugin does not provide %1$@. Reinstall the plugin."; "native.theJavaTestResultListenerDidNotBecome" = "The Java test result listener did not become ready in time."; "native.theLanguageProviderIsNotInstalled" = "The %1$@ language provider is not installed."; "native.theLanguageServerCouldNotApplyThisSetting" = "The language server could not apply this setting. Restart it to use the new value."; "native.theLanguageServerIsDisabledInThisWorkspace" = "The %1$@ language server is disabled in this workspace. Enable it in Settings > LSP."; +"native.theLanguageServerOfThisPluginIsNotAvailable" = "The %1$@ language server of this plugin is not available."; +"native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter" = "The %1$@ language server returned an invalid TCP port for its debug adapter."; +"native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable" = "The language service that hosts the debug adapter is unavailable."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; "native.theLitheServerWasInstalledOnThisHost" = "The Lithe server was installed on this host."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5dc98fccd4..246161ab78 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -930,7 +930,6 @@ "Could not compare the local adapter with the verified version." = "无法比较本地适配器与已核验版本。"; "Could not confirm" = "无法确认"; "Could not connect to the Debug Adapter: %@" = "无法连接调试适配器:%@"; -"Could not connect to the Java Debug Server: %@" = "无法连接 Java Debug Server:%@"; "Could not copy your Stable settings" = "无法复制正式版设置"; "Could not find the Java source for %@" = "找不到 %@ 对应的 Java 源码"; "Could not import MCP JSON. Check the mcpServers object and try again." = "无法导入 MCP JSON。请检查 mcpServers 对象后重试。"; @@ -3619,6 +3618,8 @@ "The %@ debug provider does not support %@." = "%@ 调试 Provider 不支持 %@。"; "The %@ language provider is not installed." = "%@ 语言 Provider 尚未安装。"; "The %@ language server is disabled in this workspace. Enable it in Settings > LSP." = "此工作区已停用 %@ 语言服务器。请在“设置 > LSP”中启用它。"; +"The %@ language server of this plugin is not available." = "此插件的 %@ 语言服务器不可用。"; +"The %@ language server returned an invalid TCP port for its debug adapter." = "%@ 语言服务器为其调试适配器返回了无效的 TCP 端口。"; "The %@ provider does not support %@." = "%@ Provider 不支持 %@。"; "The AI provider returned an HTTP error." = "AI 服务商返回了 HTTP 错误。"; "The AI provider returned an empty commit message." = "AI 服务商返回了空的提交信息。"; @@ -3666,14 +3667,12 @@ "The Hook library response was not recognised." = "无法识别 Hook 资源库响应。"; "The JDK the new project uses. It must be at least the Java version below." = "新项目使用的 JDK,版本不能低于下方的 Java 版本。"; "The JVM does not expose supported heap counters through jstat." = "当前 JVM 未通过 jstat 提供受支持的堆计数器。"; -"The Java Debug Server connection is stopped." = "Java Debug Server 的连接已停止。"; "The Java Debug Server returned an invalid TCP port." = "Java Debug Server 返回了无效的 TCP 端口。"; "The Java Support plugin does not provide %@. Reinstall the plugin." = "Java 支持插件没有提供 %@。请重新安装该插件。"; "The Java argument file could not be encoded as UTF-8." = "Java 参数文件无法编码为 UTF-8。"; "The Java language server did not take the Maven configuration: %@" = "Java 语言服务器未能应用 Maven 配置:%@"; "The Java language service could not resolve a main class for this file." = "Java 语言服务无法为此文件解析主类。"; "The Java language service failed to prepare the project." = "Java 语言服务未能完成项目准备。"; -"The Java language service is unavailable." = "Java 语言服务不可用。"; "The Java source for %@ could not be resolved." = "无法解析 %@ 的 Java 源码。"; "The Java test result listener did not become ready in time." = "Java 测试结果监听器未能及时就绪。"; "The Lithe server was installed on this host." = "已在这台主机上安装 Lithe 远端服务。"; @@ -3726,8 +3725,10 @@ "The current worktree cannot be removed here." = "不能在这里删除当前工作树。"; "The debug adapter %@ must launch a packaged executable or a system command." = "调试适配器 %@ 必须启动插件包内的可执行文件或系统命令。"; "The debug adapter %@ must launch an executable from its plugin package." = "调试适配器 %@ 必须启动其插件包内的可执行文件。"; +"The debug adapter %@ names no language server to host it." = "调试适配器 %@ 没有指定承载它的语言服务器。"; "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "调试适配器 %@ 需要 %@,但未找到。请先安装,然后重试。"; -"The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio adapters are supported." = "调试适配器 %@ 使用 %@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 适配器。"; +"The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "调试适配器 %@ 使用 %@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。"; +"The debug adapter connection is stopped." = "调试适配器的连接已停止。"; "The debug adapter did not provide a terminal executable." = "调试适配器没有提供终端可执行文件。"; "The debug adapter provided an invalid terminal working directory." = "调试适配器提供的终端工作目录无效。"; "The debugger did not return object fields in time." = "调试器未能及时返回对象字段。"; @@ -3766,6 +3767,7 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "The item can be recovered from the macOS Trash." = "可以从 macOS 废纸篓中恢复此项目。"; "The language server could not apply this setting. Restart it to use the new value." = "语言服务器无法应用此设置。请重启它以使用新值。"; +"The language service that hosts the debug adapter is unavailable." = "承载调试适配器的语言服务不可用。"; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; "The local Codex account changed. Reconnect to refresh quota." = "本机 Codex 账号已变更,请重新连接以刷新额度。"; @@ -5216,7 +5218,6 @@ "native.couldNotCompareTheLocalAdapterWithTheVerifiedVersion" = "无法比较本地适配器与已核验版本。"; "native.couldNotConfirm" = "无法确认"; "native.couldNotConnectToTheDebugAdapter" = "无法连接调试适配器:%1$@"; -"native.couldNotConnectToTheJavaDebugServer" = "无法连接 Java Debug Server:%1$@"; "native.couldNotCopyYourStableSettings" = "无法复制正式版设置"; "native.couldNotFindTheJavaSourceFor" = "找不到 %1$@ 对应的 Java 源码"; "native.couldNotImportMcpJsonCheckTheMcpserversObjectAndTryAgain" = "无法导入 MCP JSON。请检查 mcpServers 对象后重试。"; @@ -8148,17 +8149,19 @@ "native.theCurrentFileIsOutsideTheWorkspace" = "当前文件在工作区之外:%1$@"; "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "替换当前文件前,会将其保存到本地历史。"; "native.theCurrentWorktreeCannotBeRemovedHere" = "不能在这里删除当前工作树。"; +"native.theDebugAdapterConnectionIsStopped" = "调试适配器的连接已停止。"; "native.theDebugAdapterDidNotProvideATerminal" = "调试适配器没有提供终端可执行文件。"; "native.theDebugAdapterIsNotInstalledYet" = "%1$@ 调试适配器尚未安装。"; "native.theDebugAdapterIsNotReady" = "调试适配器未就绪。"; "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand" = "调试适配器 %1$@ 必须启动插件包内的可执行文件或系统命令。"; "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage" = "调试适配器 %1$@ 必须启动其插件包内的可执行文件。"; +"native.theDebugAdapterNamesNoLanguageServerToHostIt" = "调试适配器 %1$@ 没有指定承载它的语言服务器。"; "native.theDebugAdapterNeedsWhichWasNotFoundInstall" = "调试适配器 %1$@ 需要 %2$@,但未找到。请先安装,然后重试。"; "native.theDebugAdapterProvidedAnInvalidTerminalWorking" = "调试适配器提供的终端工作目录无效。"; "native.theDebugAdapterRejectedTheRequest" = "调试适配器拒绝了该请求。"; "native.theDebugAdapterReturnedAnInvalidResponse" = "调试适配器返回了无效的 %1$@ 响应。"; "native.theDebugAdapterStopped" = "调试适配器已停止。"; -"native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "调试适配器 %1$@ 使用 %2$@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 适配器。"; +"native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "调试适配器 %1$@ 使用 %2$@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。"; "native.theDebugProviderDoesNotSupport" = "%1$@ 调试 Provider 不支持 %2$@。"; "native.theDebuggerDidNotReturnObjectFieldsIn" = "调试器未能及时返回对象字段。"; "native.theDefaultChangeListCannotBeRenamed" = "默认更改列表不能重命名。"; @@ -8206,18 +8209,19 @@ "native.theJDKTheNewProjectUsesItMustBeAtLeastTheJavaVersionBelow" = "新项目使用的 JDK,版本不能低于下方的 Java 版本。"; "native.theJVMDoesNotExposeSupportedHeapCountersThroughJstat" = "当前 JVM 未通过 jstat 提供受支持的堆计数器。"; "native.theJavaArgumentFileCouldNotBeEncoded" = "Java 参数文件无法编码为 UTF-8。"; -"native.theJavaDebugServerConnectionIsStopped" = "Java Debug Server 的连接已停止。"; "native.theJavaDebugServerReturnedAnInvalidTcp" = "Java Debug Server 返回了无效的 TCP 端口。"; "native.theJavaLanguageServerDidNotTakeTheMavenConfiguration" = "Java 语言服务器未能应用 Maven 配置:%1$@"; "native.theJavaLanguageServiceCouldNotResolveA" = "Java 语言服务无法为此文件解析主类。"; "native.theJavaLanguageServiceFailedToPrepareTheProject" = "Java 语言服务未能完成项目准备。"; -"native.theJavaLanguageServiceIsUnavailable" = "Java 语言服务不可用。"; "native.theJavaSourceForCouldNotBeResolved" = "无法解析 %1$@ 的 Java 源码。"; "native.theJavaSupportPluginDoesNotProvideReinstall" = "Java 支持插件没有提供 %1$@。请重新安装该插件。"; "native.theJavaTestResultListenerDidNotBecome" = "Java 测试结果监听器未能及时就绪。"; "native.theLanguageProviderIsNotInstalled" = "%1$@ 语言 Provider 尚未安装。"; "native.theLanguageServerCouldNotApplyThisSetting" = "语言服务器无法应用此设置。请重启它以使用新值。"; "native.theLanguageServerIsDisabledInThisWorkspace" = "此工作区已停用 %1$@ 语言服务器。请在“设置 > LSP”中启用它。"; +"native.theLanguageServerOfThisPluginIsNotAvailable" = "此插件的 %1$@ 语言服务器不可用。"; +"native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter" = "%1$@ 语言服务器为其调试适配器返回了无效的 TCP 端口。"; +"native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable" = "承载调试适配器的语言服务不可用。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; "native.theLitheServerWasInstalledOnThisHost" = "已在这台主机上安装 Lithe 远端服务。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 3ff4e0d2a4..61fda844c5 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -285,7 +285,13 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec adapterID: $0.adapterID, requests: $0.requests, languageIDs: $0.selectors.flatMap { $0.languageIDs ?? [] }, - transport: $0.transport + transport: $0.transport, + languageServerEndpoint: $0.endpoint.flatMap { endpoint in + guard let languageID = endpoint.languageID, let command = endpoint.command else { + return nil + } + return .init(languageID: languageID, command: command) + } ) } return result @@ -513,6 +519,14 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let requests: [String] let selectors: [Selector] let transport: String + let endpoint: Endpoint? + + /// Only the fields of `tcpLanguageServer` are read; the other + /// transports' endpoint fields stay with the Core. + struct Endpoint: Decodable { + let languageID: String? + let command: String? + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -522,13 +536,13 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec adapterID = try values.decode(String.self, forKey: .adapterID) requests = try values.decode([String].self, forKey: .requests) selectors = try values.decodeIfPresent([Selector].self, forKey: .selectors) ?? [] - // Stdio when a plan carries none, which is the only transport - // the host speaks. + // Stdio when a plan carries none. transport = try values.decodeIfPresent(String.self, forKey: .transport) ?? "stdio" + endpoint = try values.decodeIfPresent(Endpoint.self, forKey: .endpoint) } private enum CodingKeys: String, CodingKey { - case id, providerID, displayName, adapterID, requests, selectors, transport + case id, providerID, displayName, adapterID, requests, selectors, transport, endpoint } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift similarity index 87% rename from macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift rename to macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift index b5b3a2fbbf..13dc117b35 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift @@ -2,11 +2,12 @@ import LitheModuleAPI import Foundation import LitheCoreContracts -/// Connects the macOS product to the Java Debug Server hosted inside JDT LS. -/// JDT LS activation stays in the language module and DAP state stays in Core; -/// this adapter owns only asynchronous port discovery and the native TCP socket. +/// Connects the macOS product to a debug adapter hosted inside a language +/// server, such as the Java Debug Server inside JDT LS. Language server +/// activation stays in the language module and DAP state stays in Core; this +/// transport owns only asynchronous port discovery and the native TCP socket. @MainActor -final class MacJavaDebugAdapterTransport: DebugAdapterTransport { +final class MacHostedPortDebugAdapterTransport: DebugAdapterTransport { enum TransportError: LocalizedError { case languageIntelligenceUnavailable case connectionFailed(String) @@ -15,11 +16,11 @@ final class MacJavaDebugAdapterTransport: DebugAdapterTransport { var errorDescription: String? { switch self { case .languageIntelligenceUnavailable: - return L10n.string("The Java language service is unavailable.") + return L10n.string("The language service that hosts the debug adapter is unavailable.") case .connectionFailed(let message): - return L10n.string("Could not connect to the Java Debug Server: \(message)") + return L10n.string("Could not connect to the Debug Adapter: \(message)") case .stopped: - return L10n.string("The Java Debug Server connection is stopped.") + return L10n.string("The debug adapter connection is stopped.") } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift index feb146304d..d04d4851de 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift @@ -2,9 +2,11 @@ import Foundation import LitheCoreContracts import LitheModuleAPI -/// The stdio transport of a plugin's debug adapter. The launch depends on the -/// workspace, so Rust Core plans it when the session starts, not when the -/// provider is registered; a rejected plan surfaces as the start error. +/// The transport of a plugin's debug adapter: stdio to a process of its own, +/// or a loopback socket to an adapter the plugin's language server hosts. The +/// launch depends on the workspace, so Rust Core plans it when the session +/// starts, not when the provider is registered; a rejected plan surfaces as the +/// start error. @MainActor final class MacPluginDebugAdapterTransport: DebugAdapterTransport { enum TransportError: LocalizedError, Equatable { @@ -21,12 +23,19 @@ final class MacPluginDebugAdapterTransport: DebugAdapterTransport { } } - private let resolve: @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterProcess + /// Starts the plugin's language server for the workspace, runs the + /// declared debug-start command on it and returns the adapter's loopback + /// port. The language module owns the server; this transport owns the socket. + typealias HostedPortStarter = @MainActor (_ languageID: String, _ command: String, _ rootURL: URL) async throws -> UInt16 + + private let resolve: @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch private let adapterID: String private let makeProcess: @MainActor () -> any RawProcessSession /// Finds a system command by tool discovery. Never a project-local copy. private let findCommand: @MainActor ([String]) -> URL? - private var transport: MacProcessDebugAdapterTransport? + private let startHostedPort: HostedPortStarter + private let socketFactory: @MainActor (String, UInt16) -> any DebugAdapterSocketConnection + private var transport: (any DebugAdapterTransport)? var onData: ((Data) -> Void)? var onErrorOutput: ((Data) -> Void)? @@ -34,22 +43,50 @@ final class MacPluginDebugAdapterTransport: DebugAdapterTransport { init( adapterID: String, - resolve: @escaping @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterProcess, + resolve: @escaping @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch, makeProcess: @escaping @MainActor () -> any RawProcessSession, - findCommand: @escaping @MainActor ([String]) -> URL? = { _ in nil } + findCommand: @escaping @MainActor ([String]) -> URL? = { _ in nil }, + startHostedPort: @escaping HostedPortStarter = { _, _, _ in + throw MacHostedPortDebugAdapterTransport.TransportError.languageIntelligenceUnavailable + }, + socketFactory: @escaping @MainActor (String, UInt16) -> any DebugAdapterSocketConnection = { + NetworkDebugAdapterSocketConnection(host: $0, port: $1) + } ) { self.adapterID = adapterID self.findCommand = findCommand self.resolve = resolve self.makeProcess = makeProcess + self.startHostedPort = startHostedPort + self.socketFactory = socketFactory } var isRunning: Bool { transport?.isRunning ?? false } func start(rootURL: URL) throws { - let launch = try resolve(rootURL) + let transport: any DebugAdapterTransport + switch try resolve(rootURL).host { + case .process(let process): + transport = try processTransport(for: process) + case .languageServer(let languageID, let command): + let startHostedPort = startHostedPort + transport = MacHostedPortDebugAdapterTransport( + portResolver: { rootURL in try await startHostedPort(languageID, command, rootURL) }, + socketFactory: socketFactory + ) + } + transport.onData = { [weak self] in self?.onData?($0) } + transport.onErrorOutput = { [weak self] in self?.onErrorOutput?($0) } + transport.onTermination = { [weak self] in self?.onTermination?($0) } + self.transport = transport + try transport.start(rootURL: rootURL) + } + + private func processTransport( + for process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess + ) throws -> MacProcessDebugAdapterTransport { let executableURL: URL - switch launch.process.executable { + switch process.executable { case .file(let url): executableURL = url case .commands(let commands): @@ -60,17 +97,12 @@ final class MacPluginDebugAdapterTransport: DebugAdapterTransport { case .runtime: throw TransportError.unsupportedExecutable(adapterID: adapterID) } - let transport = MacProcessDebugAdapterTransport( + return MacProcessDebugAdapterTransport( executableURL: executableURL, - arguments: launch.process.arguments, + arguments: process.arguments, environment: ProcessInfo.processInfo.environment, process: makeProcess() ) - transport.onData = { [weak self] in self?.onData?($0) } - transport.onErrorOutput = { [weak self] in self?.onErrorOutput?($0) } - transport.onTermination = { [weak self] in self?.onTermination?($0) } - self.transport = transport - try transport.start(rootURL: rootURL) } func send(_ data: Data) throws { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 752fcdec28..c9ab3a45ee 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -611,12 +611,12 @@ final class MacServiceContainer { "java": { CoreDebugAdapterProtocolSession( adapterID: "java", - transport: MacJavaDebugAdapterTransport( + transport: MacHostedPortDebugAdapterTransport( portResolver: { rootURL in guard let capability = try await moduleRuntime .activateCapability(.languageIntelligence) as? LanguageIntelligenceCapability else { - throw MacJavaDebugAdapterTransport.TransportError + throw MacHostedPortDebugAdapterTransport.TransportError .languageIntelligenceUnavailable } return try await capability.sessions.startJavaDebugServer( @@ -657,6 +657,20 @@ final class MacServiceContainer { runtimeService.executableCandidates(command) .first { $0.source != .project }?.executableURL }.first + }, + startHostedPort: { languageID, command, rootURL in + guard let capability = try await moduleRuntime + .activateCapability(.languageIntelligence) + as? LanguageIntelligenceCapability else { + throw MacHostedPortDebugAdapterTransport.TransportError + .languageIntelligenceUnavailable + } + return try await capability.sessions.startHostedDebugAdapter( + languageID: languageID, + pluginID: pluginID, + command: command, + rootURL: rootURL + ) } ), core: rustCore, diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index 83020f7bde..3940bae627 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -333,24 +333,33 @@ struct MacPluginLanguageServerLaunchResolver { return BackendProcess(process: process, hostServices: backend.hostServices, stateDirectory: stateDirectory) } - /// A debug adapter process with every package path verified and its - /// state directories created. The DAP `adapterID` and the supported - /// requests come from Rust Core's descriptor. - struct DebugAdapterProcess: Equatable { - let process: BuildProviderProcess + /// How a debug adapter is reached once Core expanded its launch. The DAP + /// `adapterID` and the supported requests come from Rust Core's descriptor. + struct DebugAdapterLaunch: Equatable { + enum Host: Equatable { + /// A process of its own, with every package path verified and its + /// state directories created. + case process(BuildProviderProcess) + /// Hosted by the plugin's own language server: no process starts + /// here, the server's session does, and `command` makes it listen. + case languageServer(languageID: String, command: String) + } + + let host: Host let adapter: DeclarativeLanguageServerLaunchPlan.DebugAdapter } /// Expands the launch of one debug adapter for one workspace. A packaged - /// executable or a system command over stdio is supported; the adapter - /// never writes into the package. + /// executable or a system command over stdio, or an adapter hosted by the + /// plugin's language server, is supported; the adapter never writes into + /// the package. func resolveDebugAdapter( pluginID: String, adapterID: String, manifest: Data, packageURL: URL, workspaceRootURL: URL - ) throws -> DebugAdapterProcess { + ) throws -> DebugAdapterLaunch { let launchSettings = self.settingValues(forPlugin: pluginID, workspaceRootURL: workspaceRootURL) let stateDirectory = directories.debugAdapterStateDirectory(pluginID: pluginID, adapterID: adapterID) let request = DeclarativeLanguageServerLaunchRequest( @@ -370,13 +379,29 @@ struct MacPluginLanguageServerLaunchResolver { message: L10n.string("The plugin does not provide the debug adapter \(adapterID).") ) } - // The transport speaks stdio only. A TCP adapter would be started and - // then never reached, so refuse before the process exists. + // The adapter lives inside the plugin's language server: the plan is + // that server's launch, which its own session performs, so nothing + // starts or is created here. + if adapter.transport == "tcpLanguageServer" { + guard let endpoint = adapter.languageServerEndpoint else { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: L10n.string("The debug adapter \(adapterID) names no language server to host it."), + field: "debugAdapters.launch.endpoint" + ) + } + return DebugAdapterLaunch( + host: .languageServer(languageID: endpoint.languageID, command: endpoint.command), + adapter: adapter + ) + } + // Any other TCP adapter would be started and then never reached, so + // refuse before the process exists. guard adapter.transport == "stdio" else { throw DeclarativePluginRejection( code: "plugin.launch.unsupportedTransport", message: L10n.string( - "The debug adapter \(adapterID) uses the \(adapter.transport) transport, which Lithe cannot start yet. Only stdio adapters are supported." + "The debug adapter \(adapterID) uses the \(adapter.transport) transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." ), field: "debugAdapters.launch.transport" ) @@ -393,7 +418,7 @@ struct MacPluginLanguageServerLaunchResolver { plan, request: request, packageURL: packageURL, executesProjectCode: true, mayUseNetwork: false, settings: launchSettings ) - return DebugAdapterProcess(process: process, adapter: adapter) + return DebugAdapterLaunch(host: .process(process), adapter: adapter) } /// The executable of a plan's own process, with its package paths diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index f3f88e5cf2..d16dfe17ee 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -462,14 +462,32 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { public let adapterID: String public let requests: [String] public let languageIDs: [String] - /// How the host reaches the adapter process: `stdio`, `tcpAnnounced` or - /// `tcpPort`. A host that only speaks stdio refuses the others before it - /// starts the process. + /// How the host reaches the adapter: `stdio`, `tcpAnnounced`, `tcpPort` + /// or `tcpLanguageServer`. A host refuses a transport it does not speak + /// before it starts any process. public let transport: String + /// Set for `tcpLanguageServer`: the plugin language server that hosts + /// the adapter and the command that makes it listen. The plan's process + /// is then that language server, not an adapter process. + public let languageServerEndpoint: LanguageServerEndpoint? + + /// Where a language server announces the adapter it hosts. + public struct LanguageServerEndpoint: Equatable, Sendable { + public let languageID: String + /// The `workspace/executeCommand` command whose result is the + /// loopback port of the adapter. + public let command: String + + public init(languageID: String, command: String) { + self.languageID = languageID + self.command = command + } + } public init( id: String, providerID: String, displayName: String, adapterID: String, - requests: [String], languageIDs: [String], transport: String = "stdio" + requests: [String], languageIDs: [String], transport: String = "stdio", + languageServerEndpoint: LanguageServerEndpoint? = nil ) { self.id = id self.providerID = providerID @@ -478,6 +496,7 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { self.requests = requests self.languageIDs = languageIDs self.transport = transport + self.languageServerEndpoint = languageServerEndpoint } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index cdb291db3e..b01d915235 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -10,6 +10,8 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { case toolingUnavailable(String) case capabilityUnavailable(provider: String, capability: String) case invalidJavaDebugServerPort + case invalidDebugAdapterPort(provider: String) + case languageServerNotOwnedByPlugin(languageID: String) package var errorDescription: String? { switch self { @@ -25,6 +27,10 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { return L10n.string("The \(provider) provider does not support \(capability).") case .invalidJavaDebugServerPort: return L10n.string("The Java Debug Server returned an invalid TCP port.") + case .invalidDebugAdapterPort(let provider): + return L10n.string("The \(provider) language server returned an invalid TCP port for its debug adapter.") + case .languageServerNotOwnedByPlugin(let languageID): + return L10n.string("The \(languageID) language server of this plugin is not available.") } } } @@ -531,18 +537,66 @@ package final class LanguageToolingSessionManager: ObservableObject, arguments: [], rootURL: normalizedRoot ) + guard let port = Self.debugAdapterPort(from: value) else { + throw LanguageToolingSessionError.invalidJavaDebugServerPort + } + return port + } + + /// The loopback port a language server answers a debug-start command + /// with: an integer or a numeric string, 1...65535. + package static func debugAdapterPort(from value: ToolingJSONValue) -> UInt16? { let portValue: Int? switch value { case .integer(let value): portValue = value case .string(let value): portValue = Int(value) default: portValue = nil } - guard let portValue, (1...Int(UInt16.max)).contains(portValue) else { - throw LanguageToolingSessionError.invalidJavaDebugServerPort - } + guard let portValue, (1...Int(UInt16.max)).contains(portValue) else { return nil } return UInt16(portValue) } + /// Starts or reuses the language server of `pluginID` for `languageID`, + /// runs the plugin's declared debug-start `command` on it, and returns the + /// loopback port the adapter it hosts listens on. Only the server of the + /// same plugin qualifies, as for build requests: another plugin may answer + /// for the same language, and must never receive this plugin's command. + package func startHostedDebugAdapter( + languageID: String, + pluginID: String, + command: String, + rootURL: URL + ) async throws -> UInt16 { + guard extensionRuntimeIDs.contains(languageID), + runtimesByID[languageID]?.descriptor.languageServerLaunch?.launchSource?.pluginID == pluginID else { + throw LanguageToolingSessionError.languageServerNotOwnedByPlugin(languageID: languageID) + } + let root = rootURL.standardizedFileURL + _ = try startLanguageServer(providerID: languageID, rootURL: root) + try await waitUntilLanguageServerReady(providerID: languageID, rootURL: root) + guard let session = languageServers[languageID], session.isRunning else { + throw LanguageToolingSessionError.languageServerNotOwnedByPlugin(languageID: languageID) + } + let commandRequest = LanguageServerCommand(title: command, command: command, arguments: []) + let value: ToolingJSONValue = try await withCheckedThrowingContinuation { continuation in + do { + // The session sends a workspace command without a document, so + // the file only names the root. + try session.executeReturningValue( + commandRequest, + fileURL: root.appendingPathComponent(".lithe-debug"), + completion: { continuation.resume(with: $0) } + ) + } catch { + continuation.resume(throwing: error) + } + } + guard let port = Self.debugAdapterPort(from: value) else { + throw LanguageToolingSessionError.invalidDebugAdapterPort(provider: languageID) + } + return port + } + /// Resolves the current Java source through JDT LS project metadata instead /// of deriving package or module names from its filesystem path. package func resolveJavaDebugLaunchTarget( diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index d588cabe5a..2f8c354b7b 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -1507,6 +1507,54 @@ struct LanguageIntelligenceModuleTests { EmptyWorkspaceModule() } } + + // MARK: - Debug adapters hosted by a language server + + /// The command's result is the loopback port: an integer, or the digits + /// of one in a string, within 1...65535. Anything else is no port. + @Test(arguments: [ + (ToolingJSONValue.integer(5005), UInt16?.some(5005)), + (.integer(1), 1), + (.integer(65535), 65535), + (.string("5005"), 5005), + (.integer(0), nil), + (.integer(-1), nil), + (.integer(65536), nil), + (.string("65536"), nil), + (.string("port"), nil), + (.string(""), nil), + (.string("5005 "), nil), + (.number(5005.5), nil), + (.bool(true), nil), + (.null, nil), + (.array([.integer(5005)]), nil), + (.object(["port": .integer(5005)]), nil), + ]) + func aDebugAdapterPortIsOnlyAnIntegerWithinTheTCPRange( + value: ToolingJSONValue, + expected: UInt16? + ) { + #expect(LanguageToolingSessionManager.debugAdapterPort(from: value) == expected) + } + + /// Only the language server of the plugin that declared the adapter may + /// receive its command; a server the plugin did not register, here a + /// built-in one, is refused before anything starts. + @Test + func aHostedDebugAdapterIsRefusedForALanguageServerThePluginDoesNotOwn() async throws { + let root = URL(fileURLWithPath: "/workspace/hosted-adapter", isDirectory: true) + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider(for: root.appendingPathComponent("Main.java"))) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager(runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)]) + defer { manager.stopAllLanguageServers() } + + await #expect(throws: LanguageToolingSessionError.languageServerNotOwnedByPlugin(languageID: "java")) { + _ = try await manager.startHostedDebugAdapter( + languageID: "java", pluginID: "dev.lithe.fixture", command: "java.startDebugSession", rootURL: root + ) + } + #expect(session.startCallCount == 0) + } } @MainActor diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index f0bf849a76..8b04b8d542 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -10,13 +10,13 @@ struct PluginDebugAdapterTests { private struct ResolveFailure: Error, Equatable {} private static func process(_ executable: MacPluginLanguageServerLaunchResolver.BuildProviderProcess.Executable) - -> MacPluginLanguageServerLaunchResolver.DebugAdapterProcess + -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch { .init( - process: .init( + host: .process(.init( executable: executable, arguments: [], initializationOptions: nil, runtimeTools: [], executesProjectCode: true, mayUseNetwork: false, settings: [:] - ), + )), adapter: .init( id: "lldb", providerID: "acme/lldb", displayName: "LLDB", adapterID: "lldb-dap", requests: ["launch"], languageIDs: ["rust"] @@ -24,6 +24,20 @@ struct PluginDebugAdapterTests { ) } + private static func hostedLaunch( + languageID: String = "example", command: String = "example.startDebugSession" + ) -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch { + .init( + host: .languageServer(languageID: languageID, command: command), + adapter: .init( + id: "example", providerID: "acme/example", displayName: "Example Debugger", + adapterID: "example-dap", requests: ["launch"], languageIDs: [languageID], + transport: "tcpLanguageServer", + languageServerEndpoint: .init(languageID: languageID, command: command) + ) + ) + } + @Test func aMissingSystemCommandFailsTheStartWithInstallGuidance() { let transport = MacPluginDebugAdapterTransport( @@ -56,17 +70,29 @@ struct PluginDebugAdapterTests { private func resolveNativeAdapter( transport: String, endpoint: [String: Any]? = nil - ) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterProcess { + ) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch { + try resolveFixtureAdapter(fixture: "debug-adapter", adapterID: "native") { launch in + launch["transport"] = transport + launch["endpoint"] = endpoint + } + } + + /// Resolves one adapter of a contract fixture, after `editLaunch` changed + /// the `launch` object the fixture declares. + private func resolveFixtureAdapter( + fixture: String, + adapterID: String, + editLaunch: (inout [String: Any]) -> Void = { _ in } + ) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch { let url = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(fixture).plugin.json") var manifest = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] ) var adapters = try #require(manifest["debugAdapters"] as? [[String: Any]]) - let index = try #require(adapters.firstIndex { $0["id"] as? String == "native" }) + let index = try #require(adapters.firstIndex { $0["id"] as? String == adapterID }) var launch = try #require(adapters[index]["launch"] as? [String: Any]) - launch["transport"] = transport - launch["endpoint"] = endpoint + editLaunch(&launch) adapters[index]["launch"] = launch manifest["debugAdapters"] = adapters @@ -82,7 +108,7 @@ struct PluginDebugAdapterTests { ) return try resolver.resolveDebugAdapter( pluginID: descriptor.id.rawValue, - adapterID: "native", + adapterID: adapterID, manifest: descriptor.normalizedManifest, packageURL: root.appendingPathComponent("package", isDirectory: true), workspaceRootURL: root.appendingPathComponent("workspace", isDirectory: true) @@ -90,13 +116,43 @@ struct PluginDebugAdapterTests { } @Test - func aStdioAdapterResolvesToItsLaunch() throws { + func aStdioAdapterResolvesToItsOwnProcess() throws { let resolved = try resolveNativeAdapter(transport: "stdio") #expect(resolved.adapter.transport == "stdio") + guard case .process = resolved.host else { + Issue.record("A stdio adapter runs as a process of its own, got \(resolved.host).") + return + } + } + + /// The adapter lives inside the plugin's language server: nothing is + /// started for it here, and the host learns which server and which command. + @Test + func aLanguageServerHostedAdapterResolvesToItsServerAndCommand() throws { + let resolved = try resolveFixtureAdapter(fixture: "debug-adapter-language-server", adapterID: "example") + #expect(resolved.adapter.transport == "tcpLanguageServer") + #expect(resolved.adapter.languageServerEndpoint == .init(languageID: "example", command: "example.startDebugSession")) + #expect(resolved.host == .languageServer(languageID: "example", command: "example.startDebugSession")) + } + + /// Without an endpoint there is no server to ask for a port, so Core + /// refuses the declaration before the host plans anything. + @Test + func aLanguageServerHostedAdapterWithoutAnEndpointIsRefused() { + do { + _ = try resolveFixtureAdapter(fixture: "debug-adapter-language-server", adapterID: "example") { launch in + launch["endpoint"] = nil + } + Issue.record("A tcpLanguageServer adapter without an endpoint must not resolve.") + } catch let rejection as DeclarativePluginRejection { + #expect(rejection.field?.hasSuffix(".endpoint") == true) + } catch { + Issue.record("Unexpected error: \(error)") + } } - /// The host only speaks stdio, so a TCP adapter would be started and never - /// reached; it is refused before any process exists. + /// A TCP adapter a process would have to announce is not reachable from + /// the host, so it is refused before any process exists. @Test(arguments: ["tcpPort", "tcpAnnounced"]) func aTCPAdapterIsRefusedBeforeItsProcessIsStarted(_ transport: String) { let endpoint: [String: Any] = transport == "tcpPort" @@ -144,4 +200,185 @@ struct PluginDebugAdapterTests { ) } } + + // MARK: - Language server hosted transport + + /// The event order is: the transport asks the language module for the port + /// of the declared command, opens a loopback socket to it, and releases + /// the bytes queued meanwhile only once that socket is ready. + @Test + func aLanguageServerHostedAdapterConnectsToThePortItsServerReports() async throws { + let probe = HostedAdapterProbe() + let transport = MacPluginDebugAdapterTransport( + adapterID: "example", + resolve: { _ in Self.hostedLaunch() }, + makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() }, + startHostedPort: { languageID, command, rootURL in + probe.recordPortRequest(languageID: languageID, command: command, rootURL: rootURL) + return 4711 + }, + socketFactory: { host, port in probe.makeSocket(host: host, port: port) } + ) + defer { transport.stop() } + let root = URL(fileURLWithPath: "/tmp/hosted-adapter", isDirectory: true) + let initializeFrame = Data("Content-Length: 2\r\n\r\n{}".utf8) + + try transport.start(rootURL: root) + try transport.send(initializeFrame) + #expect(await probe.waitUntilConnected(), "The socket was not opened before the local deadline") + + #expect(probe.portRequests == [ + .init(languageID: "example", command: "example.startDebugSession", rootURL: root.standardizedFileURL) + ]) + #expect(probe.endpoints == [.init(host: "127.0.0.1", port: 4711)]) + #expect(probe.socket.startCount == 1) + #expect(probe.socket.sent.isEmpty) + + probe.socket.onReady?() + #expect(probe.socket.sent == [initializeFrame]) + transport.stop() + #expect(probe.socket.stopCount == 1) + #expect(!transport.isRunning) + } + + /// A server that cannot start the adapter ends the session with its own + /// reason; no socket is opened without a port. + @Test + func aLanguageServerThatCannotHostTheAdapterEndsTheSessionWithItsReason() async throws { + struct Unavailable: LocalizedError { var errorDescription: String? { "The Example language server did not start." } } + let probe = HostedAdapterProbe() + let transport = MacPluginDebugAdapterTransport( + adapterID: "example", + resolve: { _ in Self.hostedLaunch() }, + makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() }, + startHostedPort: { _, _, _ in throw Unavailable() }, + socketFactory: { host, port in + Issue.record("No socket opens without a port.") + return probe.makeSocket(host: host, port: port) + } + ) + defer { transport.stop() } + transport.onErrorOutput = { probe.recordErrorOutput($0) } + transport.onTermination = { probe.recordTermination($0) } + + try transport.start(rootURL: URL(fileURLWithPath: "/tmp/hosted-adapter-failure", isDirectory: true)) + #expect(await probe.waitUntilTerminated(), "The session did not end before the local deadline") + + #expect(probe.terminationCodes == [1]) + #expect(probe.errorOutput == "The Example language server did not start.\n") + #expect(probe.endpoints.isEmpty) + #expect(!transport.isRunning) + } + + /// Without the language module the default starter refuses the adapter + /// instead of leaving the session waiting for a port. + @Test + func aLanguageServerHostedAdapterWithoutALanguageServiceEndsTheSession() async throws { + let probe = HostedAdapterProbe() + let transport = MacPluginDebugAdapterTransport( + adapterID: "example", + resolve: { _ in Self.hostedLaunch() }, + makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() } + ) + defer { transport.stop() } + transport.onErrorOutput = { probe.recordErrorOutput($0) } + transport.onTermination = { probe.recordTermination($0) } + + try transport.start(rootURL: URL(fileURLWithPath: "/tmp/hosted-adapter-unwired", isDirectory: true)) + #expect(await probe.waitUntilTerminated(), "The session did not end before the local deadline") + + #expect(probe.terminationCodes == [1]) + let unavailable = MacHostedPortDebugAdapterTransport.TransportError.languageIntelligenceUnavailable + #expect(probe.errorOutput == unavailable.localizedDescription + "\n") + } +} + +/// What a language server hosted adapter did, in the order it happened. +@MainActor +private final class HostedAdapterProbe { + struct PortRequest: Equatable { + let languageID: String + let command: String + let rootURL: URL + } + + struct Endpoint: Equatable { + let host: String + let port: UInt16 + } + + let socket = HostedAdapterSocket() + private(set) var portRequests: [PortRequest] = [] + private(set) var endpoints: [Endpoint] = [] + private(set) var errorOutput = "" + private(set) var terminationCodes: [Int] = [] + private let connected = EventLatch() + private let terminated = EventLatch() + + func recordPortRequest(languageID: String, command: String, rootURL: URL) { + portRequests.append(PortRequest(languageID: languageID, command: command, rootURL: rootURL)) + } + + func makeSocket(host: String, port: UInt16) -> any DebugAdapterSocketConnection { + endpoints.append(Endpoint(host: host, port: port)) + connected.fire() + return socket + } + + func recordErrorOutput(_ data: Data) { + errorOutput += String(decoding: data, as: UTF8.self) + } + + func recordTermination(_ code: Int) { + terminationCodes.append(code) + terminated.fire() + } + + func waitUntilConnected() async -> Bool { await connected.wait(deadline: .seconds(8)) } + func waitUntilTerminated() async -> Bool { await terminated.wait(deadline: .seconds(8)) } +} + +@MainActor +private final class HostedAdapterSocket: DebugAdapterSocketConnection { + var onReady: (() -> Void)? + var onData: ((Data) -> Void)? + var onFailure: ((Error) -> Void)? + var onComplete: (() -> Void)? + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var sent: [Data] = [] + + func start() { startCount += 1 } + func send(_ data: Data) { sent.append(data) } + func stop() { stopCount += 1 } +} + +/// Remembers that an event happened and resumes one waiter when it does. Both +/// the event and the local deadline arrive on the main actor, so a passing run +/// returns on the event and the deadline only bounds a failing one. +@MainActor +private final class EventLatch { + private var fired = false + private var waiter: CheckedContinuation? + + func fire() { + fired = true + resume(true) + } + + func wait(deadline: DispatchTimeInterval) async -> Bool { + if fired { return true } + return await withCheckedContinuation { continuation in + waiter = continuation + DispatchQueue.main.asyncAfter(deadline: .now() + deadline) { [weak self] in + MainActor.assumeIsolated { self?.resume(false) } + } + } + } + + private func resume(_ value: Bool) { + guard let pending = waiter else { return } + waiter = nil + pending.resume(returning: value) + } } diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 0d4434ad37..6572be6e2c 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -105,7 +105,7 @@ struct RealJavaDebugIntegrationTests { CoreDebugAdapterProtocolSession( adapterID: "java", transport: RealJavaDebugRecordingTransport( - wrapping: MacJavaDebugAdapterTransport( + wrapping: MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await languageManager.startJavaDebugServer(rootURL: rootURL) } @@ -412,7 +412,7 @@ struct RealJavaDebugIntegrationTests { CoreDebugAdapterProtocolSession( adapterID: "java", transport: RealJavaDebugRecordingTransport( - wrapping: MacJavaDebugAdapterTransport( + wrapping: MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await languageManager.startJavaDebugServer(rootURL: rootURL) } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b15f176a9a..4324270347 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1217,7 +1217,7 @@ struct RunConfigurationIntegrationTests { let portGate = JavaDebugPortGate() let socket = TestDebugSocketConnection() let endpointRecorder = DebugEndpointRecorder() - let transport = MacJavaDebugAdapterTransport( + let transport = MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, socketFactory: { host, port in endpointRecorder.record(host: host, port: port) @@ -1250,7 +1250,7 @@ struct RunConfigurationIntegrationTests { let portGate = JavaDebugPortGate() let socket = TestDebugSocketConnection() let endpointRecorder = DebugEndpointRecorder() - let transport = MacJavaDebugAdapterTransport( + let transport = MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, socketFactory: { host, port in endpointRecorder.record(host: host, port: port) diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 573555b1e1..642936f9ea 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -2,8 +2,9 @@ //! about a debug adapter before starting any process for it. //! //! A declaration names the adapter, how its process is started and reached -//! (stdio or a loopback TCP endpoint), and optional matching, default DAP -//! arguments and capability suppression. It carries no behavior: there is no +//! (stdio, a loopback TCP endpoint, or a port the plugin's own language server +//! opens on request), and optional matching, default DAP arguments and +//! capability suppression. It carries no behavior: there is no //! resolver, workflow or host integration, so a plugin can only choose among //! facts Core already understands. DAP capabilities belong to the running //! adapter and are negotiated in `initialize`; `capabilities.disabled` can @@ -37,6 +38,8 @@ const MAX_SELECTORS: usize = 16; const MAX_CONFIGURATION_TYPES: usize = 16; /// Longest announced-endpoint prefix, in bytes. const MAX_ENDPOINT_PREFIX: usize = 256; +/// Longest language server command ID, in characters. +const MAX_ENDPOINT_COMMAND: usize = 128; /// Most console commands one adapter may declare. const MAX_CONSOLE_COMMANDS: usize = 1024; /// Longest console command name, in characters. @@ -262,7 +265,11 @@ pub(crate) struct DebugCapabilities { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DebugLaunch { - pub(crate) process: DebugProcess, + /// Absent exactly for `tcpLanguageServer`, whose adapter lives inside a + /// language server the plugin already declares and has no process of its + /// own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) process: Option, pub(crate) transport: DebugTransport, /// Required by, and only allowed for, the TCP transports. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -275,13 +282,27 @@ pub(crate) enum DebugTransport { Stdio, TcpAnnounced, TcpPort, + /// The adapter runs inside the plugin's language server, which opens a + /// loopback port when asked by a command. + TcpLanguageServer, } -/// Where a TCP adapter listens. The fields of the other transport must stay +/// Where a TCP adapter listens. The fields of the other transports must stay /// absent, which `validate` checks. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DebugEndpoint { + /// `tcpLanguageServer`: language ID of a language server of this plugin. + #[serde( + default, + rename = "languageID", + skip_serializing_if = "Option::is_none" + )] + pub(crate) language_id: Option, + /// `tcpLanguageServer`: the `workspace/executeCommand` command that starts + /// the adapter and answers with its loopback port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, /// `tcpAnnounced`: the stream the adapter prints its endpoint on. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) stream: Option, @@ -404,6 +425,8 @@ impl DebugProcess { pub(crate) struct DeclarationContext<'a> { pub(crate) plugin_api_version: u32, pub(crate) declared_settings: &'a DeclaredSettings, + /// Language IDs of the plugin's `languageServers[]`. + pub(crate) language_servers: &'a BTreeSet, } /// Validates every declaration and sorts them by ID. @@ -438,9 +461,14 @@ pub(crate) fn validate_declarations( /// Whether any adapter starts on the selected runtime, which is the only use /// of `serverProcess` 2. pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> bool { - adapters - .iter() - .any(|adapter| adapter.launch.process.selected_runtime_tool().is_some()) + adapters.iter().any(|adapter| { + adapter + .launch + .process + .as_ref() + .and_then(DebugProcess::selected_runtime_tool) + .is_some() + }) } /// Whether any adapter serves `attach`, which needs its own permission. @@ -869,9 +897,24 @@ impl DebugConsole { impl DebugLaunch { fn validate(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - self.process - .validate(&format!("{field}.process"), context)?; let endpoint_field = format!("{field}.endpoint"); + let hosted = self.transport == DebugTransport::TcpLanguageServer; + match (&self.process, hosted) { + (Some(process), false) => process.validate(&format!("{field}.process"), context)?, + (None, true) => {} + (None, false) => { + return Err(invalid( + format!("{field}.process"), + "An adapter process is required unless the transport is tcpLanguageServer.", + )); + } + (Some(_), true) => { + return Err(invalid( + format!("{field}.process"), + "A tcpLanguageServer adapter runs inside the language server and has no process.", + )); + } + } match (self.transport, &self.endpoint) { (DebugTransport::Stdio, None) => Ok(()), (DebugTransport::Stdio, Some(_)) => Err(invalid( @@ -884,6 +927,7 @@ impl DebugLaunch { )), (DebugTransport::TcpAnnounced, Some(endpoint)) => { let valid = endpoint.port.is_none() + && !endpoint.has_language_server_fields() && matches!(endpoint.stream.as_deref(), Some("stdout" | "stderr")) && matches!(endpoint.value.as_deref(), Some("port" | "hostPort")) && endpoint.prefix.as_deref().is_some_and(|prefix| { @@ -904,6 +948,7 @@ impl DebugLaunch { let valid = endpoint.stream.is_none() && endpoint.prefix.is_none() && endpoint.value.is_none() + && !endpoint.has_language_server_fields() && endpoint.port.is_some_and(|port| port >= 1); if valid { Ok(()) @@ -914,10 +959,42 @@ impl DebugLaunch { )) } } + (DebugTransport::TcpLanguageServer, Some(endpoint)) => { + if endpoint.stream.is_some() + || endpoint.prefix.is_some() + || endpoint.value.is_some() + || endpoint.port.is_some() + { + return Err(invalid( + endpoint_field, + "A language server endpoint names only a language and a command.", + )); + } + let language = endpoint.language_id.as_deref().unwrap_or_default(); + if !context.language_servers.contains(language) { + return Err(invalid( + format!("{endpoint_field}.languageID"), + "The language server is not declared in languageServers.", + )); + } + if !endpoint.command.as_deref().is_some_and(is_server_command) { + return Err(invalid( + format!("{endpoint_field}.command"), + "A command is 1-128 letters, digits, and . _ : -.", + )); + } + Ok(()) + } } } } +impl DebugEndpoint { + fn has_language_server_fields(&self) -> bool { + self.language_id.is_some() || self.command.is_some() + } +} + /// Event that activates the plugin when a debug session needs the adapter. pub(crate) fn activation_event(adapter: &DebugAdapterDeclaration) -> String { format!("onDebug:{}", adapter.id) @@ -929,19 +1006,22 @@ pub(crate) fn activation_event(adapter: &DebugAdapterDeclaration) -> String { /// the runtime is the one the user already chose for Run. pub(crate) fn programs(adapter: &DebugAdapterDeclaration) -> Vec { let mut program = match &adapter.launch.process { - DebugProcess::Server(process) => process.program(ServerRole::DebugAdapter), - DebugProcess::SelectedRuntime(process) => json!({ + Some(DebugProcess::Server(process)) => process.program(ServerRole::DebugAdapter), + Some(DebugProcess::SelectedRuntime(process)) => json!({ "kind": "selectedRuntimeDebugAdapter", "tool": process.launch.tool, "arguments": process.arguments, "validationArguments": process.validation_arguments, }), + // The server is reviewed as the plugin's language server; what is new + // here is the command that makes it serve debugging. + None => json!({ "kind": "languageServerDebugAdapter" }), }; program["debugAdapter"] = json!(adapter.id); program["transport"] = json!(adapter.launch.transport); program["endpoint"] = json!(adapter.launch.endpoint); let mut programs = vec![program]; - if let DebugProcess::Server(process) = &adapter.launch.process { + if let Some(DebugProcess::Server(process)) = &adapter.launch.process { programs.extend(process.runtime_programs()); } programs @@ -981,7 +1061,7 @@ pub(crate) fn check_package_resources( resources: &[PackageResource], ) -> Validation<()> { for (index, adapter) in adapters.iter().enumerate() { - if let DebugProcess::Server(process) = &adapter.launch.process { + if let Some(DebugProcess::Server(process)) = &adapter.launch.process { for (key, path) in process.packaged_files() { check_resource( resources, @@ -1226,7 +1306,7 @@ pub(crate) fn plan( "The adapter does not serve this request.", )); } - if adapter.launch.process.selected_runtime_tool().is_some() + if uses_selected_runtime(std::slice::from_ref(adapter)) && facts.runtime_selection.map_or(true, Generation::is_stale) { return Err(PluginRejection::at( @@ -1309,6 +1389,15 @@ fn is_local_id(value: &str) -> bool { }) } +/// A `workspace/executeCommand` command ID, as language servers register them. +fn is_server_command(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ENDPOINT_COMMAND + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) +} + fn is_language_id(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_LOCAL_ID diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 7b837acf57..4e94104324 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1086,11 +1086,17 @@ impl DeclarativePluginManifest { "Debug adapters require plugin API level 4.", )); } + let language_servers: BTreeSet = self + .language_servers + .iter() + .map(|server| server.language_id.clone()) + .collect(); crate::debug::adapters::validate_declarations( &mut self.debug_adapters, &crate::debug::adapters::DeclarationContext { plugin_api_version: self.plugin_api_version, declared_settings, + language_servers: &language_servers, }, )?; if self.plugin_api_version >= CAPABILITIES_API_VERSION { @@ -3037,7 +3043,9 @@ fn backend_launch_plan( /// The launch plan of a debug adapter. `debugAdapter` repeats what the host /// needs besides the process: the transport and what to show. An adapter on -/// the runtime Run selected has no process of its own to plan yet. +/// the runtime Run selected has no process of its own to plan yet. An adapter +/// inside the plugin's language server plans that server, as a build provider +/// on a shared server does. fn debug_adapter_launch_plan( manifest: &DeclarativePluginManifest, adapter_id: &str, @@ -3054,13 +3062,26 @@ fn debug_adapter_launch_plan( format!("The plugin declares no debug adapter {adapter_id}."), ) })?; - let DebugProcess::Server(process) = &adapter.launch.process else { - return Err(PluginRejection::new( - "plugin.launch.unavailable", - format!("The debug adapter {adapter_id} starts on the selected runtime."), - )); + let mut plan = match &adapter.launch.process { + Some(DebugProcess::Server(process)) => { + server_launch_plan(manifest, process, None, request)? + } + Some(DebugProcess::SelectedRuntime(_)) => { + return Err(PluginRejection::new( + "plugin.launch.unavailable", + format!("The debug adapter {adapter_id} starts on the selected runtime."), + )); + } + None => { + let language_id = adapter + .launch + .endpoint + .as_ref() + .and_then(|endpoint| endpoint.language_id.as_deref()) + .unwrap_or_default(); + language_server_launch_plan(manifest, language_id, request)? + } }; - let mut plan = server_launch_plan(manifest, process, None, request)?; plan["debugAdapter"] = adapter.plan_descriptor(&manifest.id); Ok(plan) } diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 7f4701720e..a255f3b2e7 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -31,6 +31,8 @@ const MIX_PROVIDER_FIXTURE: &str = const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); 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_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"); @@ -57,6 +59,7 @@ fn fixture(name: &str) -> Value { "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, "backend.plugin.json" => BACKEND_FIXTURE, "debug-adapter.plugin.json" => DEBUG_ADAPTER_FIXTURE, + "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -1299,6 +1302,154 @@ fn debug_adapter_launch_plan_refuses_unknown_adapters_and_mixed_selectors() { .is_err()); } +#[test] +fn debug_adapter_inside_the_language_server_has_no_process_of_its_own() { + let outcome = validate(fixture("debug-adapter-language-server.plugin.json")); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let launch = &outcome["plugin"]["debugAdapters"][0]["launch"]; + assert_eq!( + launch, + &json!({ + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "example", "command": "example.startDebugSession" }, + }) + ); + + // The adapter plans the plugin's language server, and the descriptor tells + // the host which command to ask it for the port. + let plan = language_server_launch_plan_command(json!({ + "manifest": fixture("debug-adapter-language-server.plugin.json"), + "debugAdapterID": "example", + "request": { + "pluginDirectory": "/plugins/example", + "stateDirectory": "/state/example", + "architecture": "arm64" + }, + })) + .expect("payload is well formed"); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!( + plan["plan"]["launch"], + json!({ "kind": "systemCommand", "commands": ["example-ls"] }) + ); + let descriptor = &plan["plan"]["debugAdapter"]; + assert_eq!( + descriptor["providerID"], + "dev.lithe.fixture.debug-adapter-language-server/example" + ); + assert_eq!(descriptor["transport"], "tcpLanguageServer"); + assert_eq!( + descriptor["endpoint"], + json!({ "languageID": "example", "command": "example.startDebugSession" }) + ); +} + +#[test] +fn debug_adapter_inside_the_language_server_is_a_reviewed_program() { + let review = crate::plugins::marketplace::install_review_command(json!({ + "manifest": fixture("debug-adapter-language-server.plugin.json"), + })) + .expect("payload is well formed"); + let programs = review["review"]["programs"].as_array().unwrap(); + let adapter = programs + .iter() + .find(|program| program["debugAdapter"] == "example") + .expect("the adapter is listed"); + assert_eq!(adapter["kind"], "languageServerDebugAdapter"); + assert_eq!(adapter["transport"], "tcpLanguageServer"); + // Pointing the adapter at another command is a new program to approve. + assert_eq!(adapter["endpoint"]["command"], "example.startDebugSession"); + // The server itself stays a program of its own, reviewed once. + assert!(programs + .iter() + .any(|program| program["languageID"] == "example")); +} + +#[test] +fn debug_adapter_inside_the_language_server_rejects_inconsistent_launches() { + let cases = [ + ( + "a process beside the language server", + json!({ + "process": { "launch": { "kind": "systemCommand", "commands": ["x"] } }, + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "example", "command": "example.start" }, + }), + "debugAdapters[0].launch.process", + ), + ( + "no endpoint", + json!({ "transport": "tcpLanguageServer" }), + "debugAdapters[0].launch.endpoint", + ), + ( + "a language server the plugin does not declare", + json!({ + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "absent", "command": "example.start" }, + }), + "debugAdapters[0].launch.endpoint.languageID", + ), + ( + "no language", + json!({ + "transport": "tcpLanguageServer", + "endpoint": { "command": "example.start" }, + }), + "debugAdapters[0].launch.endpoint.languageID", + ), + ( + "a command with whitespace", + json!({ + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "example", "command": "example start" }, + }), + "debugAdapters[0].launch.endpoint.command", + ), + ( + "no command", + json!({ + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "example" }, + }), + "debugAdapters[0].launch.endpoint.command", + ), + ( + "a fixed port as well", + json!({ + "transport": "tcpLanguageServer", + "endpoint": { "languageID": "example", "command": "example.start", "port": 5005 }, + }), + "debugAdapters[0].launch.endpoint", + ), + ( + "no process on stdio", + json!({ "transport": "stdio" }), + "debugAdapters[0].launch.process", + ), + ( + "language server fields on a fixed port", + json!({ + "process": { "launch": { "kind": "systemCommand", "commands": ["x"] } }, + "transport": "tcpPort", + "endpoint": { "port": 5005, "command": "example.start" }, + }), + "debugAdapters[0].launch.endpoint", + ), + ]; + for (name, launch, field) in cases { + let mut manifest = fixture("debug-adapter-language-server.plugin.json"); + manifest["debugAdapters"][0]["launch"] = launch; + let outcome = validate(manifest); + assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); + assert_eq!( + outcome["error"]["code"], "plugin.manifest.invalid", + "{name}: {outcome}" + ); + assert_eq!(outcome["error"]["field"], field, "{name}: {outcome}"); + } +} + #[test] fn debug_start_arguments_fill_bindings_without_overriding_defaults() { let launch = debug_start( diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 35d2fd50b9..67d70ec72e 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1015,7 +1015,8 @@ defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/p ## API level 4: debug adapters `debugAdapters[]` declares debug adapters the plugin implements. Each one is a -DAP process the host starts and talks to over stdio or a loopback TCP port. +DAP process the host starts and talks to over stdio or a loopback TCP port, or +a debug server hosted by the plugin's own language server. The declaration is data only: there is no resolver, workflow, host integration, or `initializationOptions`, and any such field is refused. Requires `requires.debugAdapters` 1, `debug.launch` (plus `debug.attach` when an @@ -1034,8 +1035,9 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `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. | | `console.commands[]` | Debugger commands (`name`, optional one-line `description`) the Debug Console offers as completions. An adapter that answers the DAP `completions` request completes live, so list commands only for an adapter that does not (or whose live answers lack some commands); the host merges both. Names are inserted as written, unique, and not padded with spaces; descriptions are shown as written and not translated. | -| `launch.process` | The shared `serverProcess` launch fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": "python" \| "java" \| "node"}}`, which needs `serverProcess` 2 and is the only use of it. | -| `launch.transport` | `stdio`, `tcpAnnounced` (`endpoint`: `stream`, one-line `prefix`, `value` = `port` or `hostPort`), or `tcpPort` (`endpoint.port`). TCP endpoints are loopback only. | +| `launch.process` | The shared `serverProcess` launch fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": "python" \| "java" \| "node"}}`, which needs `serverProcess` 2 and is the only use of it. Required for every transport except `tcpLanguageServer`, which must not have it. | +| `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. | The cases in `fixtures/debug-adapter-rejections.json` state the stable code and field for each refusal and the layer that reports it: `schema`, diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json new file mode 100644 index 0000000000..aa5a9c3a1f --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.debug-adapter-language-server", + "displayName": "Debug Adapter In Language Server Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { "debugAdapters": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "debug.launch", + "languageServer.launch" + ], + "activationEvents": [ + "onDebug:example", + "onLanguage:example" + ], + "languages": [ + { + "id": "example", + "displayName": "Example", + "fileExtensions": ["ex"], + "projectFileNames": ["example.toml"] + } + ], + "languageServers": [ + { + "languageID": "example", + "launch": { "kind": "systemCommand", "commands": ["example-ls"] }, + "arguments": ["--stdio"] + } + ], + "debugAdapters": [ + { + "id": "example", + "displayName": "Example Debugger", + "adapterID": "example-dap", + "selectors": [{ "targetKinds": ["file"], "languageIDs": ["example"] }], + "launch": { + "transport": "tcpLanguageServer", + "endpoint": { + "languageID": "example", + "command": "example.startDebugSession" + } + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 26b75129fd..27992ea6ff 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -90,7 +90,7 @@ "items": { "$ref": "#/$defs/vcsProvider" } }, "debugAdapters": { - "description": "pluginAPIVersion 4, requires.debugAdapters 1: debug adapters the plugin implements, each started as its own DAP process. Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", + "description": "pluginAPIVersion 4, requires.debugAdapters 1: debug adapters the plugin implements, each started as its own DAP process, or hosted by the plugin's own language server (transport tcpLanguageServer). Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/debugAdapter" } @@ -297,7 +297,6 @@ "debugLaunch": { "type": "object", "required": [ - "process", "transport" ], "properties": { @@ -307,6 +306,9 @@ }, "oneOf": [ { + "required": [ + "process" + ], "properties": { "transport": { "const": "stdio" @@ -315,6 +317,7 @@ }, { "required": [ + "process", "endpoint" ], "properties": { @@ -355,6 +358,7 @@ }, { "required": [ + "process", "endpoint" ], "properties": { @@ -377,6 +381,44 @@ } } } + }, + { + "required": [ + "endpoint" + ], + "not": { + "required": [ + "process" + ] + }, + "properties": { + "transport": { + "const": "tcpLanguageServer" + }, + "endpoint": { + "description": "The adapter runs inside a language server this plugin declares in languageServers, which has no adapter process of its own. The host starts that server, sends workspace/executeCommand with command, and connects to the loopback port the server answers with (an integer or a numeric string).", + "type": "object", + "additionalProperties": false, + "required": [ + "languageID", + "command" + ], + "properties": { + "languageID": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9+_-]+$" + }, + "command": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + } + } + } + } } ], "unevaluatedProperties": false diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f56ffb8fdc..fd40fef939 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3296,11 +3296,16 @@ build system, and a build system without a provider with `plugin.launch.unavailable`. A `debugAdapterID` selects a `debugAdapters[]` entry with a `serverProcess` -(naming it together with another selector is the same `invalid_request` -failure); the plan adds `debugAdapter: { id, providerID, displayName, -adapterID, requests, selectors, configurationTypes, transport, endpoint?, -disabledCapabilities }`. An adapter that starts on the selected runtime is -refused with `plugin.launch.unavailable`. Core validates every declared +or a `tcpLanguageServer` transport (naming it together with another selector is +the same `invalid_request` failure); the plan adds `debugAdapter: { id, +providerID, displayName, adapterID, requests, selectors, configurationTypes, +transport, endpoint?, disabledCapabilities }`. An adapter that starts on the +selected runtime is refused with `plugin.launch.unavailable`. A +`tcpLanguageServer` adapter has no process of its own: its plan is the launch +plan of the plugin language server that `endpoint.languageID` names, so the +host starts that server as for editing, then sends `workspace/executeCommand` +with `endpoint.command` and connects to the loopback port the server answers +with (an integer or a numeric string from 1 to 65535). Core validates every declared `transport`, but a host starts only the transports it implements: the macOS host runs `stdio` and refuses `tcpPort` and `tcpAnnounced` before starting the process, with `plugin.launch.unsupportedTransport`. diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index fbe1ace5b0..0f56a60b13 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9011,18 +9011,20 @@ "native.theAgentDidNotAcceptTheRequestIt": "The Agent did not accept the request. It may have stopped.", "native.savedBreakpointsCouldNotBeRead": "Saved breakpoints could not be read.", "native.savedDebuggerSteppingFiltersCouldNotBeRead": "Saved debugger stepping filters could not be read.", - "native.theJavaLanguageServiceIsUnavailable": "The Java language service is unavailable.", + "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "The language service that hosts the debug adapter is unavailable.", "native.theDebugAdapterNeedsWhichWasNotFoundInstall": "The debug adapter {arg1} needs {arg2}, which was not found. Install it, then try again.", "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage": "The debug adapter {arg1} must launch an executable from its plugin package.", "native.thePluginDoesNotProvideTheDebugAdapter": "The plugin does not provide the debug adapter {arg1}.", - "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "The debug adapter {arg1} uses the {arg2} transport, which Lithe cannot start yet. Only stdio adapters are supported.", + "native.theDebugAdapterNamesNoLanguageServerToHostIt": "The debug adapter {arg1} names no language server to host it.", + "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "The debug adapter {arg1} uses the {arg2} transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand": "The debug adapter {arg1} must launch a packaged executable or a system command.", - "native.couldNotConnectToTheJavaDebugServer": "Could not connect to the Java Debug Server: {arg1}", - "native.theJavaDebugServerConnectionIsStopped": "The Java Debug Server connection is stopped.", + "native.couldNotConnectToTheDebugAdapter": "Could not connect to the Debug Adapter: {arg1}", + "native.theDebugAdapterConnectionIsStopped": "The debug adapter connection is stopped.", + "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "The {arg1} language server returned an invalid TCP port for its debug adapter.", + "native.theLanguageServerOfThisPluginIsNotAvailable": "The {arg1} language server of this plugin is not available.", "native.couldNotStartTheJavaTestResultListener": "Could not start the Java test result listener: {arg1}", "native.theJavaTestResultListenerDidNotBecome": "The Java test result listener did not become ready in time.", "native.nodeJsDebuggingRequiresTheOfficialVscodeJs": "Node.js debugging requires the official vscode-js-debug standalone DAP bundle. Set LITHE_JS_DEBUG_PATH to its dapDebugServer.js file or unpack it under .lithe/toolchains/js-debug.", - "native.couldNotConnectToTheDebugAdapter": "Could not connect to the Debug Adapter: {arg1}", "native.dittoExitedWithCodeLld": "ditto exited with code {arg1}: {arg2}", "native.githubProducedAnInvalidRequest": "GitHub produced an invalid request", "native.githubReturnedAnInvalidHttpResponse": "GitHub returned an invalid HTTP response", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 9ecb3d1af3..e9db5ee591 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9011,18 +9011,20 @@ "native.theAgentDidNotAcceptTheRequestIt": "Agent 未接受该请求,可能已经停止。", "native.savedBreakpointsCouldNotBeRead": "无法读取已保存的断点。", "native.savedDebuggerSteppingFiltersCouldNotBeRead": "无法读取已保存的调试器步进过滤器。", - "native.theJavaLanguageServiceIsUnavailable": "Java 语言服务不可用。", + "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "承载调试适配器的语言服务不可用。", "native.theDebugAdapterNeedsWhichWasNotFoundInstall": "调试适配器 {arg1} 需要 {arg2},但未找到。请先安装,然后重试。", "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage": "调试适配器 {arg1} 必须启动其插件包内的可执行文件。", "native.thePluginDoesNotProvideTheDebugAdapter": "插件没有提供调试适配器 {arg1}。", - "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "调试适配器 {arg1} 使用 {arg2} 传输方式,Lithe 暂时无法启动。目前只支持 stdio 适配器。", + "native.theDebugAdapterNamesNoLanguageServerToHostIt": "调试适配器 {arg1} 没有指定承载它的语言服务器。", + "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "调试适配器 {arg1} 使用 {arg2} 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。", "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand": "调试适配器 {arg1} 必须启动插件包内的可执行文件或系统命令。", - "native.couldNotConnectToTheJavaDebugServer": "无法连接 Java Debug Server:{arg1}", - "native.theJavaDebugServerConnectionIsStopped": "Java Debug Server 的连接已停止。", + "native.couldNotConnectToTheDebugAdapter": "无法连接调试适配器:{arg1}", + "native.theDebugAdapterConnectionIsStopped": "调试适配器的连接已停止。", + "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "{arg1} 语言服务器为其调试适配器返回了无效的 TCP 端口。", + "native.theLanguageServerOfThisPluginIsNotAvailable": "此插件的 {arg1} 语言服务器不可用。", "native.couldNotStartTheJavaTestResultListener": "无法启动 Java 测试结果监听器:{arg1}", "native.theJavaTestResultListenerDidNotBecome": "Java 测试结果监听器未能及时就绪。", "native.nodeJsDebuggingRequiresTheOfficialVscodeJs": "Node.js 调试需要官方的 vscode-js-debug 独立 DAP 包。请将 LITHE_JS_DEBUG_PATH 设置为其 dapDebugServer.js 文件,或将其解压到 .lithe/toolchains/js-debug。", - "native.couldNotConnectToTheDebugAdapter": "无法连接调试适配器:{arg1}", "native.dittoExitedWithCodeLld": "ditto 以代码 {arg1} 退出:{arg2}", "native.githubProducedAnInvalidRequest": "GitHub 产生了无效的请求", "native.githubReturnedAnInvalidHttpResponse": "GitHub 返回了无效的 HTTP 响应", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 641429d338..30a9695c2c 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -83812,24 +83812,35 @@ } }, { - "id": "native.theJavaLanguageServiceIsUnavailable", - "description": "Native UI: The Java language service is unavailable.", + "id": "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable", + "description": "Native UI: The language service that hosts the debug adapter is unavailable.", "platforms": [ "macos" ], "macos": { - "key": "The Java language service is unavailable.", + "key": "The language service that hosts the debug adapter is unavailable.", "arguments": [] } }, { - "id": "native.couldNotConnectToTheJavaDebugServer", - "description": "Native UI: Could not connect to the Java Debug Server: %@", + "id": "native.theDebugAdapterConnectionIsStopped", + "description": "Native UI: The debug adapter connection is stopped.", "platforms": [ "macos" ], "macos": { - "key": "Could not connect to the Java Debug Server: %@", + "key": "The debug adapter connection is stopped.", + "arguments": [] + } + }, + { + "id": "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter", + "description": "Native UI: The %@ language server returned an invalid TCP port for its debug adapter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The %@ language server returned an invalid TCP port for its debug adapter.", "arguments": [ "arg1" ] @@ -83839,14 +83850,19 @@ } }, { - "id": "native.theJavaDebugServerConnectionIsStopped", - "description": "Native UI: The Java Debug Server connection is stopped.", + "id": "native.theLanguageServerOfThisPluginIsNotAvailable", + "description": "Native UI: The %@ language server of this plugin is not available.", "platforms": [ "macos" ], "macos": { - "key": "The Java Debug Server connection is stopped.", - "arguments": [] + "key": "The %@ language server of this plugin is not available.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" } }, { @@ -89122,14 +89138,30 @@ "arg1": "string" } }, + { + "id": "native.theDebugAdapterNamesNoLanguageServerToHostIt", + "description": "Native UI: The debug adapter %@ names no language server to host it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter %@ names no language server to host it.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet", - "description": "Native UI: The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio adapters are supported.", + "description": "Native UI: The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", "platforms": [ "macos" ], "macos": { - "key": "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio adapters are supported.", + "key": "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", "arguments": [ "arg1", "arg2" diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index 4c192d6bee..5825ad9605 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -6,6 +6,7 @@ "macos": { "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-rejections.json", "rust/lithe-core/src/debug/adapters.rs", "macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift", @@ -13,6 +14,8 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginDebugExecutableResolver.swift", "macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", "macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift", "macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift", "macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift", @@ -20,6 +23,7 @@ "macos/Tests/LitheTests/PluginLiveActivationTests.swift", "macos/Tests/LitheTests/PluginDebugAdapterTests.swift", "macos/Tests/LitheTests/DebugExecutableResolverTests.swift", + "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift" ], "implementationStatus": "implemented", @@ -28,11 +32,12 @@ "windows": { "evidence": [ "rust/lithe-core/src/debug/adapters.rs", - "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json" + "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", + "shared/contracts/plugins/v1/fixtures/debug-adapter-language-server.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 仍使用宿主自带适配器。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)。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。" } diff --git a/shared/proposals/debug-adapters-1210/README.md b/shared/proposals/debug-adapters-1210/README.md index 77367a8147..a37ef0849e 100644 --- a/shared/proposals/debug-adapters-1210/README.md +++ b/shared/proposals/debug-adapters-1210/README.md @@ -58,12 +58,14 @@ ## 固定启动声明,不引入工作流 -`launch` 固定包含 `process` 和 `transport`: +`launch` 固定包含 `transport`,并在除 `tcpLanguageServer` 外的传输里包含 `process`: - `stdio`:适配器进程的标准输入输出直接传 DAP,stderr 仅记录诊断。 - `tcpAnnounced`:从明确指定的 stdout/stderr 读取有界 UTF-8 行。字面量 `prefix` 后只接受端口或 `host:port`;IPv6 使用方括号,必须是 loopback。限制单行 4 KiB、总启动输出 1 MiB、连接准备 30 秒;未完整解出合法端点就不连接。没有 `jsDebugStandalone` 等按产品命名的解码档位。 - `tcpPort`:连接本次适配器进程的固定 loopback 端口;进程先启动,端口占用则失败,不悄悄改端口。它不是 DAP attach 参数里的被调试程序端口。若无法证明连接对应本次启动的进程,平台应拒绝该传输,不公布支持。 +- `tcpLanguageServer`:适配器寄宿在同一插件已声明的语言服务器里,没有自己的进程,因此不写 `launch.process`(写了就拒绝,其他传输则必须写)。`endpoint` 只有 `languageID`(必须出现在同一插件的 `languageServers[]`)和 `command`(`workspace/executeCommand` 的命令 ID)。宿主先照常启动该语言服务器,再发送这个命令,服务器返回本次调试服务的 loopback 端口(整数或数字字符串,1...65535),宿主连接该端口。命令是安装与更新时用户审阅的内容的一部分,改命令会重新确认。它不是通用的“调用插件函数”:没有参数、没有返回值解释、不能指向其他插件的服务器。Java Debug Server 寄宿在 JDT LS 中,就是这种形式。 + TCP 启动期限只约束端口发现与连接,不限制整个调试会话。关闭、失败、取消和禁用都会关闭连接、清理本次创建的进程;已有共享服务和外部 attach 目标不属于这条进程清理链。 普通 process 沿用 `packaged`、`systemCommand`、Java/Node `runtime`;`runSelectedRuntime` 是共享进程版本 2 的新增分支,先支持 Python、Java、Node,取得 **Run 对同一目标选中的运行时**,然后执行声明的参数。没有独立 Debug 解释器偏好、隐式安装或静默切到 PATH。没有显式选择时沿用 Run 自己的发现链。一个包只声明一个 `serverProcess` 版本:包含该分支时为 2,并继续接受该包内其他进程的旧启动形式;完全不用新形式仍声明 1,避免无谓过滤旧宿主。 From 16f11a118cc896adc59188322c5c51433cc8430a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 11:50:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(debug):=20#1210=20=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=20attach=20=E5=A5=91=E7=BA=A6=EF=BC=9Aattach=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=A3=B0=E6=98=8E=20connection=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E9=80=82=E9=85=8D=E5=99=A8=E5=9C=A8=E8=BF=9C=E7=AB=AF?= =?UTF-8?q?=E7=9A=84=E8=AE=BE=E8=AE=A1=20Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - attach 配置的 targetBindings 新增 connection:host+port 或 address,目的地是适配器参数里的点分路径(最多 4 段),可选 pathMappings(objects/first 两种形状) - 声明期拒绝目的地冲突(与其他目的地、processId/cwd 参数或默认 arguments 重名/包含),运行期 set_argument 只写空位 - debug.adapters.startArguments 支持 connection 目标,校验主机/端口/映射,返回 connection:{host,port,remote};回环只按字面量判断,其余一律 remote:true - 适配器没有路径映射绑定而目标带了映射时拒绝,而不是悄悄丢弃 - plan 描述符新增 remoteAttach:{pathMappings},宿主据此决定界面,不按适配器名字判断 - 不新增权限,debug.attach 已覆盖;契约 README、schema、rust-core-api、fixture(含 22 条新拒绝用例)、功能矩阵同步 - 新增 Agent Note:适配器运行在远端——经 SSH 的 stdio 调试(仅设计,待确认四项默认选择后再实现),并更新统一调试 Note --- ...ified-debug-engine-and-adapter-registry.md | 34 ++ ...026-10-27-remote-debug-adapter-over-ssh.md | 226 +++++++ rust/lithe-core/src/debug/adapters.rs | 563 +++++++++++++++++- rust/lithe-core/src/plugins/declarative.rs | 47 +- .../src/tests/declarative_plugins.rs | 307 +++++++++- shared/contracts/plugins/v1/README.md | 83 ++- .../v1/fixtures/debug-adapter-rejections.json | 132 ++++ .../debug-adapter-remote-attach.plugin.json | 126 ++++ .../plugins/v1/plugin-manifest.schema.json | 68 +++ shared/contracts/rust-core-api.md | 23 +- .../features/plugin-debug-adapters.json | 6 +- .../proposals/debug-adapters-1210/README.md | 4 +- 12 files changed, 1582 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md create mode 100644 shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json 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 4d86ef4f55..7d7a80d710 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 @@ -166,6 +166,40 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa - **停止调试不停止语言服务器。** 传输 `stop()` 只关闭 TCP 连接;语言服务器是共享资源,沿用“结束 Java 调试不终止 JDT LS”的所有权规则。 - 被否方案:让宿主按 `adapterID == "java"` 特判端口获取。这正是要删除的东西。 +#### 远程 attach(连接到另一台机器上的被调试程序) + +“远程调试”有两种常被混为一谈的情形:被调试程序在远端(JVM 开了 `-agentlib:jdwp`、debugpy `--listen`、Node `--inspect`、本机 gdb 连远端 `gdbserver`),或适配器本身在远端(`ssh host gdb -i=dap`,DAP 仍走 stdio,只是进程是 ssh)。第一种本来就是 DAP `attach`,只缺“把主机和端口交给适配器”这一步,这一轮先做它,适配器仍在本机,传输仍是 stdio 或回环 TCP,不放宽 loopback 规则。第二种在跨机器调试里同样常见,不能当作“不做”,它的下一步设计见本节末尾和独立的设计 Note。 + +做法是给 `attach` 配置新增一种目标绑定 `targetBindings.connection`,目标种类 `connection`(`TARGET_KINDS` 里本来就有,之前没有事实和绑定)。 + +| 决定 | 内容 | 原因 | +| --- | --- | --- | +| 绑定的值是**目标参数路径** | `host`、`port`(须成对)、`address`(一个 `host:port` 字符串,IPv6 带方括号)、可选 `pathMappings`,值是适配器参数里的点分路径(最多 4 段)。事实来源是封闭的:`target.host`、`target.port`、`target.pathMappings` | 适配器给这些参数起的名字和嵌套都不同:java-debug 是 `hostName`/`port`,debugpy 是 `connect.host`/`connect.port`,js-debug 是 `address`/`port`,gdb 是一个 `target` 字符串。若沿用 `program`/`processId` 的写法(键是固定的目标参数名),就得宿主为每个适配器记名字,也就回到按名字分支。这些参数名只是设计参考,Core 不内置任何一个,具体以各适配器文档和对应插件的验收为准 | +| 路径映射有两种形状 | `objects`:写一个对象列表,`argument` 是列表名,`localKey`/`remoteKey` 是每项的键(debugpy 的 `pathMappings`);`first`:只取第一条映射写成两个字符串(js-debug 的 `localRoot`/`remoteRoot`) | 这两种覆盖了目前评估过的适配器,且声明仍是纯数据 | +| 声明时拒绝目的地冲突 | 目的地路径与另一个目的地、`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 }` 或 `null`,宿主据此决定显示“连接到远程主机”和映射列表,不必按适配器名字判断。 + +这一轮不做,且不是遗漏: +- lldb 的 `sourceMap`(成对数组):形状不同于上面两种,等 lldb 插件真正需要时再加第三种形状,不为猜测的需求扩大契约。 +- IPv6 zone ID(`fe80::1%en0`)和国际化域名:后者用 punycode;前者依赖本机网卡,写进可分享的配置不可移植。 +- SSH 隧道:用户先自己建隧道再连 `127.0.0.1` 已经可用,由宿主管理隧道要先解决密钥、主机指纹和进程所有权,另开提案。 +- 适配器运行在远端:不是不做,而是它需要下面这一节的几样新东西(Core 路径翻译、用户提供的 ssh 目的地、独立权限与每次确认),不适合塞进这个契约 PR。 +- 绑定键等于参数名的写法:见上表第一行,已否决。 + +#### 下一步:适配器在远端(SSH + stdio) + +形态:宿主经系统 `ssh` 在远端执行插件声明的适配器命令(如 `gdb -i=dap`),DAP 仍走 stdio,传输、帧、会话模型都不变;认证、加密、主机指纹交给 ssh,命令通过已有的 `TransportChain` 构造。这一点修正了前文对“远端适配器要先设计认证和加密传输”的判断,那只适用于不经 ssh、直接跨网络连 TCP 的适配器,仍然不做。 + +难点(Core 路径翻译、用户提供的目的地、权限与确认、ssh 失败分类、首帧前噪声)和被否方案见独立的设计 Note:[适配器运行在远端——经 SSH 的 stdio 调试](2026-10-27-remote-debug-adapter-over-ssh.md)。它只写设计,其中“需要你确认的默认选择”四项确认之前不开始实现。 + +宿主侧还没有接入:macOS 的 `attach` 仍被拒绝,Java 的 `JavaAttachView` 是仅限 Java 的界面。后续由一个通用的“附加到远程”对话框取代它:主机、端口、可选映射、非回环确认,文案走 `shared/i18n`。Attach 会话结束时发送 `terminateDebuggee: false`,只断开不结束远端程序,沿用上面“`attach` 默认只断开”的所有权规则。 + P0–P5 完成后关闭 #1210。可选扩展、插件依赖和私有 resolver 都不是关闭条件。 ### 正确做法 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..5456038d5d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md @@ -0,0 +1,226 @@ +# Agent 笔记:适配器运行在远端——经 SSH 的 stdio 调试 + +状态:提议中 + +## 先说结论 + +跨机器调试里很常见的一种情形是**适配器本身跑在远端**:例如 `ssh host gdb -i=dap`,DAP(Debug Adapter Protocol,编辑器和调试器之间的标准协议)照旧走标准输入输出,只是中间多了一个 ssh 进程。这种情形不需要 Lithe 自己的加密传输:认证、加密、主机指纹全部交给系统 `ssh`,Lithe 复用已有的传输链(`TransportChain`,见[远程开发 Note](2026-10-24-remote-development-workspace-host.md))拼出命令,并在 Core 会话层做本地与远端路径的双向翻译。 + +三件事必须由 Lithe 做,不能交给插件或 ssh:**目的地由用户提供并每次确认**(插件只能声明“这个适配器可以在远端运行”以及要运行的命令,不能决定连到哪里)、**路径翻译**(远端适配器回来的全是远端路径)、**失败诊断**(把 ssh 的错误分类成用户能看懂的原因,原文照样显示)。 + +本 Note 只写设计,**不开始实现**。“需要你确认的默认选择”一节列出的四项没有确认之前,不动代码。上一轮的[远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)(适配器在本机、被调试程序在远端)已经落地,两者互补,不互相替代。 + +## 问题 + +“远程调试”有两种情形,现有契约只覆盖第一种: + +| | 被调试程序在远端(已做:`targetBindings.connection`) | 适配器在远端(本 Note) | +| --- | --- | --- | +| 适配器进程在哪 | 本机 | 远端,经 ssh 的 stdio | +| 典型场景 | JVM 开了 `-agentlib:jdwp`、debugpy `--listen`、Node `--inspect` | `ssh host gdb -i=dap`、远端的 lldb-dap、Python 虚拟环境只在远端 | +| 路径 | 适配器在本机,路径是本地的,最多靠适配器自己的 `pathMappings` | 适配器看到的是远端路径,回来的 `stackTrace`、断点事件也是远端路径 | +| 需要新东西 | 目的地参数绑定 | 命令怎么在远端执行、目的地由谁给、路径怎么翻译 | + +不处理的后果:用户在编辑器里看不到远端 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 命令,不再写第二套 ssh 参数构造,所以以后模型 A 落地时,同一个适配器声明、同一个确认流程、同一份路径翻译函数都可以沿用,只是翻译表变成空(路径本来就是远端的)。 + +数据流: + +```text +编辑器(本地路径) + ⇄ Core:DAP 客户端 + 路径翻译(断点库、重定位、投影都只见本地路径) + ⇄ 本机 ssh 进程(stdin/stdout 管道) + ⇄ 加密网络 + ⇄ 远端 sshd → 适配器(远端路径)→ 被调试程序 +``` + +### 谁负责什么 + +| 事项 | 负责方 | 说明 | +| --- | --- | --- | +| 声明“这个适配器可以在远端运行”,以及远端要执行的命令 | 插件(清单数据) | 只有一个命令名加固定参数,不含目的地,不含任何用户输入 | +| 目的地 `[user@]host[:port]`(或 ssh 配置里的别名)、路径映射、远端程序路径 | 用户 | 在“在远程主机上调试”对话框里填;每次启动前确认 | +| 校验目的地与映射、通过传输链拼命令、路径翻译、ssh 失败分类、首帧前噪声丢弃 | Rust Core | 一份实现,macOS 和 Windows 共用 | +| 定位并拉起系统 `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 会话层**,是一个纯字符串函数,不碰文件系统。授权(哪些本地目录可以被调试器读到)仍然是平台的职责,不因翻译而改变。 + +- **映射表**:用户在对话框里填,最多 16 条 `local ↔ remote`;本地根必须是绝对路径,远端根必须是 POSIX 绝对路径(第一版不支持 Windows 远端,见“不做”)。 +- **匹配规则**:最长前缀优先,只在路径组件边界匹配(`/work/app` 不匹配 `/work/application`),区分大小写,规范化多余的 `/`,不解析符号链接(Core 不碰文件系统)。 +- **出站**(本地 → 远端,仅在构造发给适配器的 DAP 帧时转换):`setBreakpoints`/`setFunctionBreakpoints` 一类请求里的 `source.path`,以及由本地目标事实绑定出来的路径(`target.path`、`target.workingDirectory`)。用户**直接当作远端路径输入**的值(`executable` 目标的程序路径、远端进程号)不翻译。断点库、断点重定位、投影都只保存本地路径,不会因翻译而漂移。 +- **入站**(远端 → 本地):`stackTrace` 帧的 `source`、`stopped`/`breakpoint`/`loadedSource` 事件、`output.source`,在 `map_source` 之前先翻译,之后沿用现有“工作区相对路径 → 会话来源(`sourceReference`)→ 无源码”的回退顺序;匹配“已解析断点”也先翻译再比较。 +- **映射不到本地就不猜。** 回退到现有的无源码或 `sourceReference` 行为,界面显示远端原路径。`modules` 里的二进制路径按原样显示。 +- **与适配器自带映射的关系**:对 ssh 运行的适配器,Core 始终做翻译,**不**把编辑器与适配器之间的映射写进适配器自己的 `pathMappings` 绑定,否则会翻两次。那个绑定只在“适配器在本机、被调试程序在远端”(已做的远程 attach)时使用。 +- 附加(`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 传输(跨网络直连适配器)。这是最初“要先设计认证和加密传输”的判断,在 ssh 方案下不再需要。 +- 保存私钥、密码、口令;密码和键盘交互认证(等 askpass)。 +- Windows 远端主机(路径风格、命令解析都不同)。Windows 本机作为客户端的宿主接入另议,Core 部分两个平台共用。 +- 插件声明任意 ssh 包装命令或 launcher argv:这会让插件决定本机执行什么。 +- 容器(`docker exec`)作为启动器:`ContainerHop` 已有,以后同一套流程可以接入,不在这一轮。 +- 自动从 `~/.ssh/config` 列出主机建议:是否做、怎么做另议。 +- 模型 A(完整远程工作区):属于远程开发的 M4。 +- 子会话的 TCP(`startDebugging` 反向请求)的远端化。 + +### 需要你确认的默认选择 + +没有确认之前不开始实现: + +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 前不拼进任何字符串。 +- 翻译函数是纯函数,出站只在构造 DAP 帧时用,入站只在 `map_source` 之前用;本地的断点库和投影永远只见本地路径。 +- 新增一类 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` + +最吸引人的地方是不需要任何新代码,现在就能用。不采用的原因是:只适用于适配器自己监听 TCP 的情形;stdio-only 的适配器(gdb `-i=dap`)没法通过隧道连。它仍然是个可用的权宜之计,不被禁止。 + +### 插件声明固定的 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`、`--`;目的地为空、以 `-` 开头、含空白或控制字符被拒绝;端口越界被拒绝;用户输入不出现在远端命令参数里。 +- 路径翻译:最长前缀、组件边界(`/work/app` 与 `/work/application`)、往返一致、多余 `/`、未映射回退到无源码/`sourceReference`、已解析断点在入站翻译后能匹配。 +- 首帧噪声:噪声后接正常帧能成功;超过 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,允许修改。 + +## 适用范围 + +- `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..f187aef651 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -25,6 +25,7 @@ use crate::plugins::settings::DeclaredSettings; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; +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,181 @@ 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 two ways adapters take source roots: a list of objects with a local and +/// a remote key, or one local and one remote string. +#[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 }, +} + +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, .. } => 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 { .. } => Vec::new(), + } + } +} + +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 +717,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 +740,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 +861,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 +890,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 +901,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 +1430,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 +1442,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 +1468,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 +1509,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 +1536,208 @@ 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))?; + } + if let (Some(mappings), Some(first)) = (&binding.path_mappings, target.path_mappings.first()) { + 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 } => { + set_argument(arguments, local, Value::from(first.local.clone()))?; + set_argument(arguments, remote, Value::from(first.remote.clone()))?; + } + } + } + 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. +fn check_path_mappings(mappings: &[PathMapping]) -> Validation<()> { + let invalid_mapping = |message: String| { + PluginRejection::at("debug.target.invalid", "target.pathMappings", message) + }; + if mappings.len() > MAX_PATH_MAPPINGS { + return Err(invalid_mapping(format!( + "A target has up to {MAX_PATH_MAPPINGS} 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(), + )); + } + } + Ok(()) } impl DebugAdapterDeclaration { @@ -1281,6 +1755,11 @@ 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. + "remoteAttach": self.connection_binding().map(|binding| json!({ + "pathMappings": binding.path_mappings.is_some(), + })), }) } } @@ -1315,6 +1794,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 +1852,66 @@ pub(crate) fn plan( { return Err(missing("target.workingDirectory", "working directory")); } + if let Some(connection) = &bindings.connection { + connection_endpoint(target)?; + if connection.path_mappings.is_some() { + check_path_mappings(&target.path_mappings)?; + } 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..4860a16001 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,303 @@ 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 the first mapping. + let scalar = remote_start("scalar", target); + assert_eq!( + scalar["arguments"], + json!({ + "address": "build.example.org", + "port": 5678, + "localRoot": local, + "remoteRoot": "/srv/app", + }) + ); + // No mapping means no argument, not an empty one. + let none = remote_start("nested", connection_target("build.example.org", 5678)); + assert!(none["arguments"].get("pathMappings").is_none(), "{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}"); + } + // `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"; + assert_eq!(descriptor(remote, "flat"), json!({ "pathMappings": false })); + assert_eq!( + descriptor(remote, "joined"), + json!({ "pathMappings": false }) + ); + assert_eq!( + descriptor(remote, "nested"), + json!({ "pathMappings": true }) + ); + assert_eq!( + descriptor(remote, "scalar"), + json!({ "pathMappings": true }) + ); + 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..89ef51b185 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,87 @@ 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`, debugpy behind `--listen`, 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. `connection` does not cover an adapter that runs on the +remote machine (for example `ssh host gdb -i=dap`); that needs path translation +and a user-supplied destination and is not declared in this version. + +```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`, debugpy `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 two shapes. `objects` fills a list of + objects, one per mapping: `argument` names the list, `localKey` and + `remoteKey` the keys of each object. `first` is for an adapter with a single + pair of roots and fills `local` and `remote` from the first mapping. An + adapter whose mapping is something else (lldb `sourceMap` pairs) declares + none, 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 }` for an adapter that takes a connection and `null` +otherwise, so a host knows whether to offer the host-and-port form and the +mapping list. + 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 four 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..3d97ae9b58 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json @@ -344,6 +344,138 @@ "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": "pairs", "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"} } ] } 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..bf8d9eeb54 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json @@ -0,0 +1,126 @@ +{ + "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" + ], + "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" + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 27992ea6ff..79c9697380 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -235,6 +235,70 @@ "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" } + } + } + ] + } + }, + "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 +546,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..5bded71ab4 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,25 @@ 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 }` for an adapter +that takes a connection and `null` otherwise. + `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 5825ad9605..694915fdb2 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", @@ -33,11 +34,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..4a526daf33 100644 --- a/shared/proposals/debug-adapters-1210/README.md +++ b/shared/proposals/debug-adapters-1210/README.md @@ -78,7 +78,9 @@ 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`(第一条映射写成两个字符串)两种形状。事实来源仍然封闭:`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 就声称能启动。 路径身份和执行路径分开:工作区内源码的持久化身份仍为相对路径;Build/Run 可以提供用户已选择或授权的外部产物引用,平台在执行时解析为绝对路径。源码外构建和工作区外可执行文件无需伪造相对路径。本提案不新增 `workspacePath`、`configuration.id` 或 `target.configurationID` 的重复结构。 From c9df5b7f6e32d7859a22bc621c41101ad93dad49 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 12:24:31 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(debug):=20#1210=20=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=20attach=20=E5=85=BC=E5=AE=B9=E6=80=A7=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=EF=BC=9A=E8=B7=AF=E5=BE=84=E6=98=A0=E5=B0=84=E8=A1=A5=20dictio?= =?UTF-8?q?nary=20/=20pairs=20=E4=B8=A4=E7=A7=8D=E5=BD=A2=E7=8A=B6?= =?UTF-8?q?=EF=BC=8C=E7=BC=BA=E5=8F=A3=E6=B8=85=E5=8D=95=E4=B8=8E=E8=B7=AF?= =?UTF-8?q?=E7=BA=BF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按各调试器上游文档核对“附加到远端程序”的参数写法后,路径映射就四种写法: - objects:对象列表(debugpy pathMappings、delve substitutePath) - first:唯一一条映射写成两个字符串(js-debug localRoot/remoteRoot) - dictionary:以 local 或 remote 一侧为键的对象(CodeLLDB sourceMap、PHP pathMappings) - pairs:二元数组列表,order 为 remoteLocal 或 localRemote(lldb-dap sourceMap) 形状装不下用户给的映射就拒绝,不截断:first 只接受一条(此前只取第一条,与 “不悄悄丢映射”的原则矛盾,属于未发布 API 4 内的行为修正),dictionary 在键的 一侧不能出现同一个根两次,违反返回 debug.target.invalid(target.pathMappings)。 plan.debugAdapter.remoteAttach 增加 maxPathMappings,宿主据此决定最多显示几行。 契约与 fixture:schema 增加两个 oneOf 分支;remote-attach fixture 增加 keyed、 paired 两个适配器(共 6 个);拒绝用例新增 6 条(共 28 条)。 Note:#1210 统一 Note 新增“兼容性审计”,逐个适配器标注“已核对 / 待核验”,列出 G1–G11 缺口(空闲端口占位符、tcpConnect、监听 attach、URI 目标、命令模板等)的 现状、决定和触发条件,并更新后续路线;SSH Note 说明适配器原生映射与 Core 路径翻译 是叠加的两层,远端适配器必须由 Core 翻译。 --- ...ified-debug-engine-and-adapter-registry.md | 60 +++++++- ...026-10-27-remote-debug-adapter-over-ssh.md | 7 +- rust/lithe-core/src/debug/adapters.rs | 136 +++++++++++++++--- .../src/tests/declarative_plugins.rs | 94 +++++++++--- shared/contracts/plugins/v1/README.md | 31 ++-- .../v1/fixtures/debug-adapter-rejections.json | 38 ++++- .../debug-adapter-remote-attach.plugin.json | 60 +++++++- .../plugins/v1/plugin-manifest.schema.json | 22 +++ shared/contracts/rust-core-api.md | 8 +- .../proposals/debug-adapters-1210/README.md | 2 +- 10 files changed, 403 insertions(+), 55 deletions(-) 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 7d7a80d710..2de286f5a1 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 @@ -175,7 +175,8 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa | 决定 | 内容 | 原因 | | --- | --- | --- | | 绑定的值是**目标参数路径** | `host`、`port`(须成对)、`address`(一个 `host:port` 字符串,IPv6 带方括号)、可选 `pathMappings`,值是适配器参数里的点分路径(最多 4 段)。事实来源是封闭的:`target.host`、`target.port`、`target.pathMappings` | 适配器给这些参数起的名字和嵌套都不同:java-debug 是 `hostName`/`port`,debugpy 是 `connect.host`/`connect.port`,js-debug 是 `address`/`port`,gdb 是一个 `target` 字符串。若沿用 `program`/`processId` 的写法(键是固定的目标参数名),就得宿主为每个适配器记名字,也就回到按名字分支。这些参数名只是设计参考,Core 不内置任何一个,具体以各适配器文档和对应插件的验收为准 | -| 路径映射有两种形状 | `objects`:写一个对象列表,`argument` 是列表名,`localKey`/`remoteKey` 是每项的键(debugpy 的 `pathMappings`);`first`:只取第一条映射写成两个字符串(js-debug 的 `localRoot`/`remoteRoot`) | 这两种覆盖了目前评估过的适配器,且声明仍是纯数据 | +| 路径映射有四种形状 | `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 | @@ -183,15 +184,66 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa | 路径映射被不支持的适配器**拒绝**而不是忽略 | 适配器没有 `pathMappings` 绑定,目标却带了映射,返回 `debug.target.invalid`(`target.pathMappings`) | 悄悄丢掉用户填的映射,会话能启动但断点对不上源码,比启动失败更难排查 | | 主机与路径的输入校验 | 主机是 DNS 名或 IP 字面量(1–253 字符,标签不超过 63 且不以 `-` 开头或结尾),不接受 scheme、端口、方括号、空白、用户名;全数字点分必须是合法 IPv4;端口 1–65535;映射最多 16 条,每个根 1–1024 字符且无控制字符,本地根必须是绝对路径,远程根不限制形状(它是远端的路径) | 这些字符串最终会进入适配器参数,宁可严格;远端的路径风格由对方决定,Core 不替它判断 | -`plan.debugAdapter.remoteAttach` 返回 `{ pathMappings: bool }` 或 `null`,宿主据此决定显示“连接到远程主机”和映射列表,不必按适配器名字判断。 +`plan.debugAdapter.remoteAttach` 返回 `{ pathMappings: bool, maxPathMappings: n }` 或 `null`,宿主据此决定显示“连接到远程主机”和映射列表以及最多几行,不必按适配器名字判断。 -这一轮不做,且不是遗漏: -- lldb 的 `sourceMap`(成对数组):形状不同于上面两种,等 lldb 插件真正需要时再加第三种形状,不为猜测的需求扩大契约。 +这一轮不做,且不是遗漏(需要契约的缺口统一在下面的兼容性审计里编号、写明触发条件): - IPv6 zone ID(`fe80::1%en0`)和国际化域名:后者用 punycode;前者依赖本机网卡,写进可分享的配置不可移植。 - SSH 隧道:用户先自己建隧道再连 `127.0.0.1` 已经可用,由宿主管理隧道要先解决密钥、主机指纹和进程所有权,另开提案。 - 适配器运行在远端:不是不做,而是它需要下面这一节的几样新东西(Core 路径翻译、用户提供的 ssh 目的地、独立权限与每次确认),不适合塞进这个契约 PR。 - 绑定键等于参数名的写法:见上表第一行,已否决。 +#### 兼容性审计:契约能装下哪些调试器,缺口在哪 + +2026-10-27 按各调试器的上游文档逐项核对“附加到远端程序”的参数写法,要回答的是“这个契约装不装得下主流调试器”,而不是等某个插件撞上缺口再改契约。结论分两半:**参数写法**这一半,核对过的适配器都能用“默认 `arguments` + 三类绑定(`host`/`port`/`address`、`pathMappings`)”表达;**连接方式**这一半有几处装不下,下面逐条编号。 + +“核对状态”一列区分来源:**已核对**是读到了上游文档或 README 的参数表;**待核验**来自记忆或搜索摘要,没有读到原文,只能当设计参考,对应插件落地时必须按文档重新核对,这一列不是验收结论。参数名同样不由 Core 内置,具体以适配器文档和插件验收为准。 + +| 调试器(适配器) | 附加到远端程序的参数写法 | 契约现状 | 核对状态 | +| --- | --- | --- | --- | +| Java(java-debug,JDWP) | `hostName`、`port` | 覆盖:平铺 `host`/`port`;没有路径映射参数 | 已核对 | +| Python(debugpy) | `connect: { host, port }`,`pathMappings: [{ localRoot, remoteRoot }]` | 覆盖:嵌套路径加 `objects`。IDE 一侧监听、被调试程序主动连入的写法见 G4 | 已核对;反向写法待核验 | +| 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` 字符串 | 覆盖:`address` 绑定写入 `target`。是否需要 `remote ` 之类的前缀没读到 | `target` 字段已核对;前缀待核验 | +| .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 服务器,没有适配器进程(Godot 编辑器、用户自己启动的 `dlv dap --listen`) | `launch.process` 对除 `tcpLanguageServer` 外的传输必填 | 以后加 `tcpConnect` 传输,端点来自 `connection` 目标(用户填写的主机和端口),清单不得写死地址;它是 `remote: true` 的典型场景,必须沿用“每个非回环端点单独确认” | 第一个 Godot 或 delve 直连插件 | +| 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 | 许可条款限制使用场景的适配器 | 契约不内置任何适配器 | 不在契约层处理:能否随插件分发、能否由用户自行安装,是插件作者和用户的合规判断;插件作者应在插件说明里写明适配器的来源和许可 | 无 | + +所以“兼容所有调试器”的准确说法是:核对过的主流适配器的 attach 参数写法都能装下(四种映射形状加平铺 / 嵌套 / 联合地址),连接方式的缺口(G1–G6)已编号并写明触发条件,不能保证表里没有核对到的适配器。 + +后续路线,按“先做对应调试器标准用法的缺口”排序: + +| 顺序 | 内容 | 依赖 | +| --- | --- | --- | +| 1 | 本 PR:契约、Core 校验、四种映射形状、拒绝 fixtures | 无 | +| 2 | macOS 宿主接入 `attach`,通用“附加到远程”对话框取代仅限 Java 的 `JavaAttachView`,非回环端点确认 | 本 PR 合并 | +| 3 | 适配器在远端(SSH + stdio) | 独立 Note 里的四项默认选择由用户确认 | +| 4 | G4(监听 attach)与 G2(`tcpConnect`) | 第一个 PHP / Godot 插件出现;两者都涉及网络暴露,各自单独确认 | +| 5 | G1、G5、G6、G8 | 对应插件出现,且已按文档核对参数 | +| 6 | Windows 宿主 | Windows 的 P2–P4 先开始 | + #### 下一步:适配器在远端(SSH + stdio) 形态:宿主经系统 `ssh` 在远端执行插件声明的适配器命令(如 `gdb -i=dap`),DAP 仍走 stdio,传输、帧、会话模型都不变;认证、加密、主机指纹交给 ssh,命令通过已有的 `TransportChain` 构造。这一点修正了前文对“远端适配器要先设计认证和加密传输”的判断,那只适用于不经 ssh、直接跨网络连 TCP 的适配器,仍然不做。 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 index 5456038d5d..cbc2842506 100644 --- 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 @@ -8,7 +8,9 @@ 三件事必须由 Lithe 做,不能交给插件或 ssh:**目的地由用户提供并每次确认**(插件只能声明“这个适配器可以在远端运行”以及要运行的命令,不能决定连到哪里)、**路径翻译**(远端适配器回来的全是远端路径)、**失败诊断**(把 ssh 的错误分类成用户能看懂的原因,原文照样显示)。 -本 Note 只写设计,**不开始实现**。“需要你确认的默认选择”一节列出的四项没有确认之前,不动代码。上一轮的[远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)(适配器在本机、被调试程序在远端)已经落地,两者互补,不互相替代。 +本 Note 只写设计,**不开始实现**。“需要你确认的默认选择”一节列出的四项没有确认之前,不动代码。上一轮的[远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)(适配器在本机、被调试程序在远端)已经落地,两者互补,不互相替代。哪些调试器能装进这两种形态、哪些连接方式还有缺口,见同一份 Note 里的“兼容性审计”一节。 + +本 Note 里出现的适配器命令行(`gdb -i=dap`、`lldb-dap` 等)和参数名只是设计举例,不是验收结论:兼容性审计里标了“已核对”的才读过上游文档,标“待核验”的来自记忆,插件落地时必须按文档重新核对。 ## 问题 @@ -94,7 +96,8 @@ - **出站**(本地 → 远端,仅在构造发给适配器的 DAP 帧时转换):`setBreakpoints`/`setFunctionBreakpoints` 一类请求里的 `source.path`,以及由本地目标事实绑定出来的路径(`target.path`、`target.workingDirectory`)。用户**直接当作远端路径输入**的值(`executable` 目标的程序路径、远端进程号)不翻译。断点库、断点重定位、投影都只保存本地路径,不会因翻译而漂移。 - **入站**(远端 → 本地):`stackTrace` 帧的 `source`、`stopped`/`breakpoint`/`loadedSource` 事件、`output.source`,在 `map_source` 之前先翻译,之后沿用现有“工作区相对路径 → 会话来源(`sourceReference`)→ 无源码”的回退顺序;匹配“已解析断点”也先翻译再比较。 - **映射不到本地就不猜。** 回退到现有的无源码或 `sourceReference` 行为,界面显示远端原路径。`modules` 里的二进制路径按原样显示。 -- **与适配器自带映射的关系**:对 ssh 运行的适配器,Core 始终做翻译,**不**把编辑器与适配器之间的映射写进适配器自己的 `pathMappings` 绑定,否则会翻两次。那个绑定只在“适配器在本机、被调试程序在远端”(已做的远程 attach)时使用。 +- **与适配器自带映射的关系**:适配器自己的映射参数(delve 的 `substitutePath`、lldb-dap 与 CodeLLDB 的 `sourceMap`、debugpy 的 `pathMappings`、js-debug 的 `localRoot`/`remoteRoot`)和 Core 的翻译是**叠加的两层**,作用在不同的路径对上:适配器自带映射处理“调试信息里的路径(编译时路径)→ 适配器所在文件系统上的路径”,Core 处理“编辑器看到的本地路径 ↔ 适配器所在文件系统上的路径”。适配器在本机时(已做的远程 attach)这两层的后一层是恒等的,所以用户填的映射直接写进适配器自带的映射绑定就够了;适配器在远端时后一层不是恒等,**必须由 Core 翻译**。因此对 ssh 运行的适配器,Core 始终做翻译,**不**把用户在对话框里填的映射写进适配器的 `pathMappings` 绑定:适配器所在的远端机器上并没有那些本地路径,写进去只会让它去找不存在的目录。编译时路径到远端路径这一层仍由用户在适配器自己的配置里写;是否要给它一个声明入口,等第一个远端 `gdb -i=dap` 或 `lldb-dap` 的真机验收时再看是否确有需要。 +- **适配器没有自带映射也能用**:Core 的翻译不依赖适配器声明了哪种映射形状,也就不受那四种形状(`objects`、`first`、`dictionary`、`pairs`)的限制。这是把翻译放在 Core、而不是让每个适配器各自装下用户映射的另一个理由。 - 附加(`attach`)在 ssh 模式下,`connection` 的主机和端口按**远端机器的视角**解释:例如远端的 gdb 连同机的 `localhost:1234` 的 gdbserver。确认弹窗同时显示 ssh 目的地和这个端点。 ### 认证、主机指纹与确认 diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index f187aef651..9f19579768 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -24,7 +24,7 @@ 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. @@ -295,8 +295,7 @@ pub(crate) struct DebugConnectionBinding { pub(crate) path_mappings: Option, } -/// The two ways adapters take source roots: a list of objects with a local and -/// a remote key, or one local and one remote string. +/// 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 { @@ -310,13 +309,42 @@ pub(crate) enum DebugPathMappingBinding { /// 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, .. } => vec![("pathMappings.argument", argument)], + Self::Objects { argument, .. } + | Self::Dictionary { argument, .. } + | Self::Pairs { argument, .. } => vec![("pathMappings.argument", argument)], Self::First { local, remote } => { vec![ ("pathMappings.local", local), @@ -336,7 +364,18 @@ impl DebugPathMappingBinding { ("pathMappings.localKey", local_key), ("pathMappings.remoteKey", remote_key), ], - Self::First { .. } => Vec::new(), + 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 + } } } } @@ -1566,7 +1605,13 @@ fn bind_connection( }; set_argument(arguments, address, Value::from(joined))?; } - if let (Some(mappings), Some(first)) = (&binding.path_mappings, target.path_mappings.first()) { + // `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, @@ -1586,8 +1631,36 @@ fn bind_connection( set_argument(arguments, argument, Value::Array(entries))?; } DebugPathMappingBinding::First { local, remote } => { - set_argument(arguments, local, Value::from(first.local.clone()))?; - set_argument(arguments, remote, Value::from(first.remote.clone()))?; + 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))?; } } } @@ -1708,15 +1781,22 @@ fn is_loopback_host(host: &str) -> bool { .is_ok_and(|address| address.is_loopback()) } -/// Checks the path mappings the host sends along with a remote target. -fn check_path_mappings(mappings: &[PathMapping]) -> Validation<()> { +/// 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) }; - if mappings.len() > MAX_PATH_MAPPINGS { - return Err(invalid_mapping(format!( - "A target has up to {MAX_PATH_MAPPINGS} path mappings." - ))); + // 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| { @@ -1737,6 +1817,26 @@ fn check_path_mappings(mappings: &[PathMapping]) -> Validation<()> { )); } } + // 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(()) } @@ -1756,9 +1856,11 @@ impl DebugAdapterDeclaration { "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. + // 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), })), }) } @@ -1854,8 +1956,8 @@ pub(crate) fn plan( } if let Some(connection) = &bindings.connection { connection_endpoint(target)?; - if connection.path_mappings.is_some() { - check_path_mappings(&target.path_mappings)?; + 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. diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 4860a16001..f3982a282d 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -1587,8 +1587,10 @@ fn debug_start_arguments_map_source_roots_in_the_shape_the_adapter_takes() { { "localRoot": local, "remoteRoot": "/opt/lib" }, ]) ); - // An adapter with one local and one remote root takes the first mapping. - let scalar = remote_start("scalar", target); + // 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!({ @@ -1598,9 +1600,29 @@ fn debug_start_arguments_map_source_roots_in_the_shape_the_adapter_takes() { "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. - let none = remote_start("nested", connection_target("build.example.org", 5678)); - assert!(none["arguments"].get("pathMappings").is_none(), "{none}"); + 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] @@ -1696,6 +1718,42 @@ fn debug_start_arguments_refuse_path_mappings_the_adapter_cannot_use() { ); 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); @@ -1751,19 +1809,21 @@ fn debug_adapter_launch_plan_says_which_adapters_attach_to_a_remote_endpoint() { plan["plan"]["debugAdapter"]["remoteAttach"].clone() }; let remote = "debug-adapter-remote-attach.plugin.json"; - assert_eq!(descriptor(remote, "flat"), json!({ "pathMappings": false })); - assert_eq!( - descriptor(remote, "joined"), - json!({ "pathMappings": false }) - ); - assert_eq!( - descriptor(remote, "nested"), - json!({ "pathMappings": true }) - ); - assert_eq!( - descriptor(remote, "scalar"), - json!({ "pathMappings": true }) - ); + // 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 diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 89ef51b185..a0b5e5adb7 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1075,12 +1075,22 @@ and a user-supplied destination and is not declared in this version. `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 two shapes. `objects` fills a list of - objects, one per mapping: `argument` names the list, `localKey` and - `remoteKey` the keys of each object. `first` is for an adapter with a single - pair of roots and fills `local` and `remote` from the first mapping. An - adapter whose mapping is something else (lldb `sourceMap` pairs) declares - none, and a start that sends mappings to it is refused. +- `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 @@ -1107,9 +1117,10 @@ Core can prove local from the text: `localhost` (any case), `127.0.0.0/8`, and `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 }` for an adapter that takes a connection and `null` -otherwise, so a host knows whether to offer the host-and-port form and the -mapping list. +`{ "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`, @@ -1118,7 +1129,7 @@ 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, and -`fixtures/debug-adapter-remote-attach.plugin.json` declares four adapters that +`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 3d97ae9b58..19f09f99d9 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-rejections.json @@ -420,7 +420,7 @@ { "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": "pairs", "argument": "sourceMap"}}}}}}], + "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"} }, { @@ -476,6 +476,42 @@ "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 index bf8d9eeb54..fa9f8689e5 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapter-remote-attach.plugin.json @@ -18,7 +18,9 @@ "onDebug:flat", "onDebug:nested", "onDebug:scalar", - "onDebug:joined" + "onDebug:joined", + "onDebug:keyed", + "onDebug:paired" ], "languages": [], "debugAdapters": [ @@ -121,6 +123,62 @@ }, "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 79c9697380..5b0c193a1e 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -286,6 +286,28 @@ "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"] } + } } ] } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5bded71ab4..2e7b2ee49a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3338,8 +3338,12 @@ the argument paths that receive each fact, so Core writes `hostName`, 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 }` for an adapter -that takes a connection and `null` otherwise. +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", diff --git a/shared/proposals/debug-adapters-1210/README.md b/shared/proposals/debug-adapters-1210/README.md index 4a526daf33..52247fd995 100644 --- a/shared/proposals/debug-adapters-1210/README.md +++ b/shared/proposals/debug-adapters-1210/README.md @@ -80,7 +80,7 @@ debugpy、lldb-dap 可以走 stdio(宿主已不再内置 Python、Rust 调试 `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`(第一条映射写成两个字符串)两种形状。事实来源仍然封闭:`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 就声称能启动。 +`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 就声称能启动。 路径身份和执行路径分开:工作区内源码的持久化身份仍为相对路径;Build/Run 可以提供用户已选择或授权的外部产物引用,平台在执行时解析为绝对路径。源码外构建和工作区外可执行文件无需伪造相对路径。本提案不新增 `workspacePath`、`configuration.id` 或 `target.configurationID` 的重复结构。 From b1452a6b08cec2a68fe807215b56915532c08fba Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 19:25:41 +0800 Subject: [PATCH 4/4] docs(debug): separate network paths from TCP lifecycle scope --- ...ified-debug-engine-and-adapter-registry.md | 41 +++---- ...7-debug-network-paths-and-tcp-lifecycle.md | 101 ++++++++++++++++++ ...026-10-27-remote-debug-adapter-over-ssh.md | 56 +++++----- shared/contracts/plugins/v1/README.md | 25 +++-- .../proposals/debug-adapters-1210/README.md | 2 + 5 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md 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 2de286f5a1..226fd80ce0 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,13 +170,15 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa #### 远程 attach(连接到另一台机器上的被调试程序) -“远程调试”有两种常被混为一谈的情形:被调试程序在远端(JVM 开了 `-agentlib:jdwp`、debugpy `--listen`、Node `--inspect`、本机 gdb 连远端 `gdbserver`),或适配器本身在远端(`ssh host gdb -i=dap`,DAP 仍走 stdio,只是进程是 ssh)。第一种本来就是 DAP `attach`,只缺“把主机和端口交给适配器”这一步,这一轮先做它,适配器仍在本机,传输仍是 stdio 或回环 TCP,不放宽 loopback 规则。第二种在跨机器调试里同样常见,不能当作“不做”,它的下一步设计见本节末尾和独立的设计 Note。 +“远程调试”要分清两条连接: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`,debugpy 是 `connect.host`/`connect.port`,js-debug 是 `address`/`port`,gdb 是一个 `target` 字符串。若沿用 `program`/`processId` 的写法(键是固定的目标参数名),就得宿主为每个适配器记名字,也就回到按名字分支。这些参数名只是设计参考,Core 不内置任何一个,具体以各适配器文档和对应插件的验收为准 | +| 绑定的值是**目标参数路径** | `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` 只写空位) | @@ -188,26 +192,26 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa 这一轮不做,且不是遗漏(需要契约的缺口统一在下面的兼容性审计里编号、写明触发条件): - IPv6 zone ID(`fe80::1%en0`)和国际化域名:后者用 punycode;前者依赖本机网卡,写进可分享的配置不可移植。 -- SSH 隧道:用户先自己建隧道再连 `127.0.0.1` 已经可用,由宿主管理隧道要先解决密钥、主机指纹和进程所有权,另开提案。 -- 适配器运行在远端:不是不做,而是它需要下面这一节的几样新东西(Core 路径翻译、用户提供的 ssh 目的地、独立权限与每次确认),不适合塞进这个契约 PR。 +- 网络代理、隧道和端口转发:用户可以自行提供可达端点,但能否使用仍取决于对应宿主入口;不能把契约校验写成已完成的 attach 功能。Lithe 本轮不创建、配置或清理这些网络设施。 +- SSH 远端执行:现有 SSH + stdio Note 暂留为子方案草案,后续与独立网络方案一起评审,不作为本轮 TCP 的前置或下一项交付。路径映射优先复用上游能力,不预设远端适配器必须由 Core 翻译。 - 绑定键等于参数名的写法:见上表第一行,已否决。 #### 兼容性审计:契约能装下哪些调试器,缺口在哪 -2026-10-27 按各调试器的上游文档逐项核对“附加到远端程序”的参数写法,要回答的是“这个契约装不装得下主流调试器”,而不是等某个插件撞上缺口再改契约。结论分两半:**参数写法**这一半,核对过的适配器都能用“默认 `arguments` + 三类绑定(`host`/`port`/`address`、`pathMappings`)”表达;**连接方式**这一半有几处装不下,下面逐条编号。 +2026-10-27 按各调试器的上游文档逐项核对“附加到远端程序”的参数写法,要回答的是“这个契约装不装得下主流调试器”,而不是等某个插件撞上缺口再改契约。**参数形状能表达不等于连接方式可用**:以下逐项区分现有绑定、尚缺的表达方式与尚未实现的宿主入口,不作“全部覆盖”的承诺。 “核对状态”一列区分来源:**已核对**是读到了上游文档或 README 的参数表;**待核验**来自记忆或搜索摘要,没有读到原文,只能当设计参考,对应插件落地时必须按文档重新核对,这一列不是验收结论。参数名同样不由 Core 内置,具体以适配器文档和插件验收为准。 | 调试器(适配器) | 附加到远端程序的参数写法 | 契约现状 | 核对状态 | | --- | --- | --- | --- | | Java(java-debug,JDWP) | `hostName`、`port` | 覆盖:平铺 `host`/`port`;没有路径映射参数 | 已核对 | -| Python(debugpy) | `connect: { host, port }`,`pathMappings: [{ localRoot, remoteRoot }]` | 覆盖:嵌套路径加 `objects`。IDE 一侧监听、被调试程序主动连入的写法见 G4 | 已核对;反向写法待核验 | +| 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` 字符串 | 覆盖:`address` 绑定写入 `target`。是否需要 `remote ` 之类的前缀没读到 | `target` 字段已核对;前缀待核验 | +| 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 | 已核对 | @@ -220,7 +224,7 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa | 编号 | 缺口 | 现状 | 决定 | 触发条件 | | --- | --- | --- | --- | --- | | G1 | 适配器的 TCP 模式需要空闲端口且不宣告(netcoredbg 的 `--server=`) | `tcpPort` 只接受清单里写死的回环端口,固定端口会冲突 | 以后加“宿主分配空闲回环端口、替换参数里的 `{port}` 占位符”的传输 | 第一个只能走 TCP 的适配器。它们大多也支持 stdio,所以不阻塞 | -| G2 | 连接一个已经在运行的 DAP 服务器,没有适配器进程(Godot 编辑器、用户自己启动的 `dlv dap --listen`) | `launch.process` 对除 `tcpLanguageServer` 外的传输必填 | 以后加 `tcpConnect` 传输,端点来自 `connection` 目标(用户填写的主机和端口),清单不得写死地址;它是 `remote: true` 的典型场景,必须沿用“每个非回环端点单独确认” | 第一个 Godot 或 delve 直连插件 | +| 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 的插件 | @@ -231,24 +235,24 @@ Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJa | G10 | 导入 VS Code `launch.json` 里的远程字段(`pipeTransport`、`miDebuggerServerAddress`、`debugServer`、`listen`) | 导入器还不存在 | 导入时**拒绝并说明**是哪个字段、为什么、该用哪个入口,不悄悄忽略;与“映射装不下就拒绝”同一原则 | 做导入器时 | | G11 | 许可条款限制使用场景的适配器 | 契约不内置任何适配器 | 不在契约层处理:能否随插件分发、能否由用户自行安装,是插件作者和用户的合规判断;插件作者应在插件说明里写明适配器的来源和许可 | 无 | -所以“兼容所有调试器”的准确说法是:核对过的主流适配器的 attach 参数写法都能装下(四种映射形状加平铺 / 嵌套 / 联合地址),连接方式的缺口(G1–G6)已编号并写明触发条件,不能保证表里没有核对到的适配器。 +所以当前只是兼容性盘点,不是“兼容所有调试器”的验收结论:四种映射形状与平铺 / 嵌套 / 联合地址解决部分参数表达,直连 DAP、监听方向、命令模板等缺口仍须按 G1–G11 分别落地。 -后续路线,按“先做对应调试器标准用法的缺口”排序: +后续路线:先收敛普通连接和生命周期,不将网络通路扩展排成紧随其后的必做步骤。 | 顺序 | 内容 | 依赖 | | --- | --- | --- | | 1 | 本 PR:契约、Core 校验、四种映射形状、拒绝 fixtures | 无 | -| 2 | macOS 宿主接入 `attach`,通用“附加到远程”对话框取代仅限 Java 的 `JavaAttachView`,非回环端点确认 | 本 PR 合并 | -| 3 | 适配器在远端(SSH + stdio) | 独立 Note 里的四项默认选择由用户确认 | -| 4 | G4(监听 attach)与 G2(`tcpConnect`) | 第一个 PHP / Godot 插件出现;两者都涉及网络暴露,各自单独确认 | -| 5 | G1、G5、G6、G8 | 对应插件出现,且已按文档核对参数 | -| 6 | Windows 宿主 | Windows 的 P2–P4 先开始 | +| 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 + stdio) -形态:宿主经系统 `ssh` 在远端执行插件声明的适配器命令(如 `gdb -i=dap`),DAP 仍走 stdio,传输、帧、会话模型都不变;认证、加密、主机指纹交给 ssh,命令通过已有的 `TransportChain` 构造。这一点修正了前文对“远端适配器要先设计认证和加密传输”的判断,那只适用于不经 ssh、直接跨网络连 TCP 的适配器,仍然不做。 +候选形态:宿主经系统 `ssh` 在远端执行适配器命令(如 `gdb -i=dap`),DAP 仍走 stdio;认证、加密、主机指纹交给 ssh,复用已有的 `TransportChain`。这不是普通 TCP 的唯一远程方式,也不能据此说“不经 SSH 就不能调试”。裸 TCP 不自带认证和加密,本轮只在用户认可的可达网络内使用,不推荐将调试端口暴露到公网。 -难点(Core 路径翻译、用户提供的目的地、权限与确认、ssh 失败分类、首帧前噪声)和被否方案见独立的设计 Note:[适配器运行在远端——经 SSH 的 stdio 调试](2026-10-27-remote-debug-adapter-over-ssh.md)。它只写设计,其中“需要你确认的默认选择”四项确认之前不开始实现。 +目的地、权限、路径映射、诊断和生命周期的探索见 [SSH 草案](2026-10-27-remote-debug-adapter-over-ssh.md)。它暂缓实现,原来的四项默认选择也只是候选,不因确认就自动进入本轮;先完成独立网络方案评审,并优先复用适配器的原生路径映射,避免双重转换。 宿主侧还没有接入:macOS 的 `attach` 仍被拒绝,Java 的 `JavaAttachView` 是仅限 Java 的界面。后续由一个通用的“附加到远程”对话框取代它:主机、端口、可选映射、非回环确认,文案走 `shared/i18n`。Attach 会话结束时发送 `terminateDebuggee: false`,只断开不结束远端程序,沿用上面“`attach` 默认只断开”的所有权规则。 @@ -333,3 +337,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 index cbc2842506..5b3904b3ff 100644 --- 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 @@ -4,41 +4,41 @@ ## 先说结论 -跨机器调试里很常见的一种情形是**适配器本身跑在远端**:例如 `ssh host gdb -i=dap`,DAP(Debug Adapter Protocol,编辑器和调试器之间的标准协议)照旧走标准输入输出,只是中间多了一个 ssh 进程。这种情形不需要 Lithe 自己的加密传输:认证、加密、主机指纹全部交给系统 `ssh`,Lithe 复用已有的传输链(`TransportChain`,见[远程开发 Note](2026-10-24-remote-development-workspace-host.md))拼出命令,并在 Core 会话层做本地与远端路径的双向翻译。 +本 Note 是**暂缓实现的 SSH 子方案草案**:当前这轮只做普通 TCP 连接和生命周期,代理、隧道、端口转发与 SSH 远端执行另行评审,范围以[独立网络边界 Note](2026-10-27-debug-network-paths-and-tcp-lifecycle.md)为准。下文的声明、默认值和实施阶段均为候选,不是已批准的契约或本轮交付。 -三件事必须由 Lithe 做,不能交给插件或 ssh:**目的地由用户提供并每次确认**(插件只能声明“这个适配器可以在远端运行”以及要运行的命令,不能决定连到哪里)、**路径翻译**(远端适配器回来的全是远端路径)、**失败诊断**(把 ssh 的错误分类成用户能看懂的原因,原文照样显示)。 +候选形态是**适配器本身跑在远端**:例如 `ssh host gdb -i=dap`,DAP(Debug Adapter Protocol,编辑器和调试器之间的标准协议)照旧走标准输入输出,中间由 ssh 承载。认证、加密、主机指纹交给系统 `ssh`,命令复用已有传输链(`TransportChain`,见[远程开发 Note](2026-10-24-remote-development-workspace-host.md));目的地由用户确认,路径映射优先复用适配器原生能力,不强制 Core 重复翻译。 -本 Note 只写设计,**不开始实现**。“需要你确认的默认选择”一节列出的四项没有确认之前,不动代码。上一轮的[远程 attach 契约](2026-10-25-unified-debug-engine-and-adapter-registry.md)(适配器在本机、被调试程序在远端)已经落地,两者互补,不互相替代。哪些调试器能装进这两种形态、哪些连接方式还有缺口,见同一份 Note 里的“兼容性审计”一节。 +[#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`) | 适配器在远端(本 Note) | +| | 本机适配器连远端程序(已有 `targetBindings.connection` 契约与校验) | 经 SSH 启动远端适配器(本 Note) | | --- | --- | --- | | 适配器进程在哪 | 本机 | 远端,经 ssh 的 stdio | -| 典型场景 | JVM 开了 `-agentlib:jdwp`、debugpy `--listen`、Node `--inspect` | `ssh host gdb -i=dap`、远端的 lldb-dap、Python 虚拟环境只在远端 | -| 路径 | 适配器在本机,路径是本地的,最多靠适配器自己的 `pathMappings` | 适配器看到的是远端路径,回来的 `stackTrace`、断点事件也是远端路径 | +| 典型场景 | 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 命令,不再写第二套 ssh 参数构造,所以以后模型 A 落地时,同一个适配器声明、同一个确认流程、同一份路径翻译函数都可以沿用,只是翻译表变成空(路径本来就是远端的)。 +本 Note 探索模型 B。若后续采用,优先复用 `TransportChain::command`,不再写第二套 ssh 参数构造;哪些声明、确认和路径处理可以与模型 A 共用,需在独立方案里验证,不能预先承诺完全相同。 数据流: ```text 编辑器(本地路径) - ⇄ Core:DAP 客户端 + 路径翻译(断点库、重定位、投影都只见本地路径) + ⇄ Core:DAP 客户端(按上游约定选择路径映射责任方) ⇄ 本机 ssh 进程(stdin/stdout 管道) ⇄ 加密网络 ⇄ 远端 sshd → 适配器(远端路径)→ 被调试程序 @@ -50,7 +50,7 @@ | --- | --- | --- | | 声明“这个适配器可以在远端运行”,以及远端要执行的命令 | 插件(清单数据) | 只有一个命令名加固定参数,不含目的地,不含任何用户输入 | | 目的地 `[user@]host[:port]`(或 ssh 配置里的别名)、路径映射、远端程序路径 | 用户 | 在“在远程主机上调试”对话框里填;每次启动前确认 | -| 校验目的地与映射、通过传输链拼命令、路径翻译、ssh 失败分类、首帧前噪声丢弃 | Rust Core | 一份实现,macOS 和 Windows 共用 | +| 校验目的地与映射、通过传输链拼命令、必要的路径翻译、ssh 失败分类、首帧前噪声丢弃 | Rust Core | 候选共享规则;原生映射优先,同一对路径空间不重复转换 | | 定位并拉起系统 `ssh`、显示确认弹窗、进程树清理、退出确认 | 平台 | 沿用现有 `MacProcessDebugAdapterTransport` 一类的进程所有权规则 | | 认证、加密、主机指纹、跳板(ProxyJump)、`~/.ssh/config` | 系统 `ssh` | 与用户在终端里手动 ssh 的行为一致 | @@ -89,15 +89,17 @@ ### 路径翻译 -翻译放在 **Core 会话层**,是一个纯字符串函数,不碰文件系统。授权(哪些本地目录可以被调试器读到)仍然是平台的职责,不因翻译而改变。 +**先核对上游映射语义,不因适配器在远端就强制 Core 翻译。** 例如 debugpy 的 `pathMappings.localRoot` 表示客户端路径前缀,不要求它存在于适配器所在机器;它已经可以转换客户端与远端路径。之前把适配器映射一律解释成“编译路径到适配器文件系统”,并据此断言必须由 Core 再翻译,是不准确的。 + +每对路径空间只设一个转换责任方:优先把映射交给支持该语义的适配器;只有上游能力不足且真实流程证明需要时,才考虑 **Core 会话层**的纯字符串翻译。平台继续负责文件授权。以下是该回退方案的候选规则,不是所有 SSH 会话都要启用: - **映射表**:用户在对话框里填,最多 16 条 `local ↔ remote`;本地根必须是绝对路径,远端根必须是 POSIX 绝对路径(第一版不支持 Windows 远端,见“不做”)。 - **匹配规则**:最长前缀优先,只在路径组件边界匹配(`/work/app` 不匹配 `/work/application`),区分大小写,规范化多余的 `/`,不解析符号链接(Core 不碰文件系统)。 -- **出站**(本地 → 远端,仅在构造发给适配器的 DAP 帧时转换):`setBreakpoints`/`setFunctionBreakpoints` 一类请求里的 `source.path`,以及由本地目标事实绑定出来的路径(`target.path`、`target.workingDirectory`)。用户**直接当作远端路径输入**的值(`executable` 目标的程序路径、远端进程号)不翻译。断点库、断点重定位、投影都只保存本地路径,不会因翻译而漂移。 -- **入站**(远端 → 本地):`stackTrace` 帧的 `source`、`stopped`/`breakpoint`/`loadedSource` 事件、`output.source`,在 `map_source` 之前先翻译,之后沿用现有“工作区相对路径 → 会话来源(`sourceReference`)→ 无源码”的回退顺序;匹配“已解析断点”也先翻译再比较。 +- **出站**(本地 → 远端,仅在构造发给适配器的 DAP 帧时转换):`setBreakpoints` 请求里的 `source.path`,以及由本地目标事实绑定出来的路径(`target.path`、`target.workingDirectory`)。用户**直接当作远端路径输入**的值(`executable` 目标的程序路径、远端进程号)不翻译。断点库、断点重定位、投影都只保存本地路径,不会因翻译而漂移。 +- **入站**(远端 → 本地):`stackTrace` 帧的 `source`、`breakpoint`/`loadedSource` 事件中的来源、`output.source`,在 `map_source` 之前先翻译,之后沿用现有“工作区相对路径 → 会话来源(`sourceReference`)→ 无源码”的回退顺序;匹配“已解析断点”也先翻译再比较。 - **映射不到本地就不猜。** 回退到现有的无源码或 `sourceReference` 行为,界面显示远端原路径。`modules` 里的二进制路径按原样显示。 -- **与适配器自带映射的关系**:适配器自己的映射参数(delve 的 `substitutePath`、lldb-dap 与 CodeLLDB 的 `sourceMap`、debugpy 的 `pathMappings`、js-debug 的 `localRoot`/`remoteRoot`)和 Core 的翻译是**叠加的两层**,作用在不同的路径对上:适配器自带映射处理“调试信息里的路径(编译时路径)→ 适配器所在文件系统上的路径”,Core 处理“编辑器看到的本地路径 ↔ 适配器所在文件系统上的路径”。适配器在本机时(已做的远程 attach)这两层的后一层是恒等的,所以用户填的映射直接写进适配器自带的映射绑定就够了;适配器在远端时后一层不是恒等,**必须由 Core 翻译**。因此对 ssh 运行的适配器,Core 始终做翻译,**不**把用户在对话框里填的映射写进适配器的 `pathMappings` 绑定:适配器所在的远端机器上并没有那些本地路径,写进去只会让它去找不存在的目录。编译时路径到远端路径这一层仍由用户在适配器自己的配置里写;是否要给它一个声明入口,等第一个远端 `gdb -i=dap` 或 `lldb-dap` 的真机验收时再看是否确有需要。 -- **适配器没有自带映射也能用**:Core 的翻译不依赖适配器声明了哪种映射形状,也就不受那四种形状(`objects`、`first`、`dictionary`、`pairs`)的限制。这是把翻译放在 Core、而不是让每个适配器各自装下用户映射的另一个理由。 +- **与适配器自带映射的关系**:逐个核对 `substitutePath`、`sourceMap`、`pathMappings` 等参数的路径方向和客户端约定,不能只看字段名。编译路径到运行路径、运行路径到客户端路径确实不同且必要时才允许分层;同一对根不能同时交给上游和 Core 转换。 +- **上游没有合适的映射时**:不能直接声称可用。先为回退方案记录上游限制,覆盖断点和栈帧的双向流程,再决定契约与实现;本 Note 不提供已实现的通用翻译能力。 - 附加(`attach`)在 ssh 模式下,`connection` 的主机和端口按**远端机器的视角**解释:例如远端的 gdb 连同机的 `localhost:1234` 的 gdbserver。确认弹窗同时显示 ssh 目的地和这个端点。 ### 认证、主机指纹与确认 @@ -132,7 +134,7 @@ ### 不做 -- Lithe 自己的加密 TCP 传输(跨网络直连适配器)。这是最初“要先设计认证和加密传输”的判断,在 ssh 方案下不再需要。 +- Lithe 自研的加密或认证协议。这不否决普通 TCP 直连:它可用于用户认可的受保护网络,复用现有 socket;本轮不新增安全通路。 - 保存私钥、密码、口令;密码和键盘交互认证(等 askpass)。 - Windows 远端主机(路径风格、命令解析都不同)。Windows 本机作为客户端的宿主接入另议,Core 部分两个平台共用。 - 插件声明任意 ssh 包装命令或 launcher argv:这会让插件决定本机执行什么。 @@ -141,16 +143,18 @@ - 模型 A(完整远程工作区):属于远程开发的 M4。 - 子会话的 TCP(`startDebugging` 反向请求)的远端化。 -### 需要你确认的默认选择 +### 待独立方案评审的候选默认值 -没有确认之前不开始实现: +本轮不实施;即使确认以下候选,也不自动把 SSH 加回当前范围: 1. **使用系统 `ssh` 和用户已有的 ssh 配置**(经 `TransportChain`),不内置 SSH 库、不另做加密传输。 -2. **路径翻译放在 Core 会话层**,授权仍在平台。 +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。 @@ -161,7 +165,7 @@ ### 正确做法 - 远端命令只通过 `TransportChain::command` 构造;目的地和路径映射由用户通过对话框提供,进入 Core 前不拼进任何字符串。 -- 翻译函数是纯函数,出站只在构造 DAP 帧时用,入站只在 `map_source` 之前用;本地的断点库和投影永远只见本地路径。 +- 先核对上游路径映射;必要的 Core 回退才使用纯函数,且不与上游重复转换同一对路径空间。 - 新增一类 ssh 失败原因时,加入分类表和 i18n 词条,同时保留 ssh 原文。 - 想在远端启动一个新适配器时,只改插件清单,不改宿主。 @@ -182,7 +186,7 @@ ### 用户自己建 SSH 隧道,再用已有的 attach 连 `127.0.0.1` -最吸引人的地方是不需要任何新代码,现在就能用。不采用的原因是:只适用于适配器自己监听 TCP 的情形;stdio-only 的适配器(gdb `-i=dap`)没法通过隧道连。它仍然是个可用的权宜之计,不被禁止。 +最吸引人的地方是不需要 Lithe 管理网络设施,当前普通 TCP 方向允许这种用法,但对应的宿主端点入口仍需已实现。它不能取代所有远端执行场景:stdio-only 的适配器(gdb `-i=dap`)不能直接通过 TCP 端口隧道接入。因此保留它,而不是把 SSH 远端启动当作唯一方案。 ### 插件声明固定的 ssh 包装命令(`ssh` 作为 `commands[0]`) @@ -204,7 +208,7 @@ - 清单校验:`remote` 与非 `stdio` 传输、与 `packaged`/`runtime`/`runSelectedRuntime`、与 `environment`/`workingDirectory`、与含 `${…}` 或不安全字符的参数同时出现,都被拒绝;拒绝 fixture 覆盖每一种。 - 命令构造:通过 `TransportChain::command` 得到的 argv 含 `BatchMode=yes`、`ClearAllForwardings=yes`、`--`;目的地为空、以 `-` 开头、含空白或控制字符被拒绝;端口越界被拒绝;用户输入不出现在远端命令参数里。 -- 路径翻译:最长前缀、组件边界(`/work/app` 与 `/work/application`)、往返一致、多余 `/`、未映射回退到无源码/`sourceReference`、已解析断点在入站翻译后能匹配。 +- 路径映射:先证明选定适配器的原生映射能否覆盖客户端与远端路径,同一对根只转换一次。确需 Core 回退时,再验证最长前缀、组件边界、往返一致、未映射回退和已解析断点匹配。 - 首帧噪声:噪声后接正常帧能成功;超过 64 KiB 或没有 `Content-Length:` 时会话结束并返回被丢弃输出;进入帧流之后的格式错误仍然报错。 - ssh 失败分类:每一类用固定 stderr 样本测试,原文被保留。 - 平台集成测试使用**假 ssh 可执行文件**(有界等待),验证确认弹窗每次出现、结束后没有残留的 ssh 进程、断开时发送了正确的 `terminateDebuggee`。 @@ -220,6 +224,10 @@ - 用户的 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/` diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index a0b5e5adb7..a172ce3bd8 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1042,12 +1042,25 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest ### Remote attach An adapter that attaches to a debuggee on another machine (a JVM started with -`-agentlib:jdwp`, debugpy behind `--listen`, a Node inspector, gdbserver) is an +`-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. `connection` does not cover an adapter that runs on the -remote machine (for example `ssh host gdb -i=dap`); that needs path translation -and a user-supplied destination and is not declared in this version. +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": { @@ -1067,8 +1080,8 @@ and a user-supplied destination and is not declared in this version. - The values of `connection` are paths into the adapter's `arguments`, because adapters name and nest them differently: java-debug takes `hostName` and - `port`, debugpy `connect.host` and `connect.port`, js-debug `address` and - `port`, gdb one `host:port` string. A path is 1–4 dot-separated names of + `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 diff --git a/shared/proposals/debug-adapters-1210/README.md b/shared/proposals/debug-adapters-1210/README.md index 52247fd995..43be2fb6c8 100644 --- a/shared/proposals/debug-adapters-1210/README.md +++ b/shared/proposals/debug-adapters-1210/README.md @@ -82,6 +82,8 @@ debugpy、lldb-dap 可以走 stdio(宿主已不再内置 Python、Rust 调试 `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` 的重复结构。 `capabilities.disabled` 只接受标准 DAP capability 名称或 `exceptionBreakpointFilters`,Core 按固定版本的 DAP 定义检查名称并映射到对应请求/数据。结构 schema 只检查名称形状,不能证明名称存在。completions、steppingGranularity、cancel 等随 DAP,而不是再维护一份静态支持列表。省略不隐藏功能,声明也不能让尚未实现该操作的宿主点亮按钮;会话取消和清理始终保留,不能用 suppression 关闭。