Repository navigation
feat(plugins): 官方 Node 插件后端 SDK 与示例构建插件 (#1351 PR 2) - #1365
Conversation
macOS sends the workspace as a directory URI ending in '/', so the sample's inside-workspace check rejected every path and each build reported sample.config. Resolve the root before comparing, and make the plugin_sdk_node end-to-end test send that form of rootUri. Found during the macOS native acceptance of plugin functions (#1366). Refs #1351
The sample-build plugin from #1365 was exercised in a debug app: list, invoke with progress, plugin failure codes, cancel, cross-plugin refusal, and process cleanup on project close and plugin disable. plugin-functions is now verified on macOS. Consent for functions.* is remembered per plugin and workspace until the project closes, like build.*; the Note and the contract README said it was asked on every call. Refs #1351
Lithe Review结论: Findings
Scope Check
Suggestions
Summary整体边界与现有插件契约一致,示例覆盖了主要构建、函数、取消和状态恢复路径。未发现 P0/P1 阻塞项;上述两个结果边界值得在合并前补齐。没有可见 UI 改动,因此无需截图审查。 Verification
|
xiaoyumuxi
left a comment
There was a problem hiding this comment.
补充代码审查(head 8a2e041)。已有自动评论指出构建结果 version 覆盖与非 JSON 结果编码异常,这里不重复发相同的行内意见。本次另外发现示例构建插件对并发快照写入、未知项目选择的处理不够可靠,详见两条行内评论。建议补回归测试。本次为静态审查,未独立运行测试。
| // Step 1: prepare. Read the workspace file and the previous snapshot. | ||
| context.progress({ message: 'Preparing', percentage: 0 }); | ||
| const config = await readConfig('.'); | ||
| const previous = store.read('snapshot', null); |
There was a problem hiding this comment.
[P2] 并发 workspace.build 会覆盖快照代数。 previous 在运行子进程之前读取,但 record(previous, ...) 在异步 context.run 返回后才执行。如果页面同时调用两次 workspace.build,它们可能都读到 generation = N,随后各自写入 generation = N + 1;最后一次写入覆盖前一次,代数不再反映实际完成的构建次数。建议在提交快照时重新读取最新状态并同步生成新代数,或者序列化同一工作区的构建;补一个两次调用交错完成的测试。
| context.progress({ message: 'Preparing', percentage: 0 }); | ||
| const config = await readConfig('.'); | ||
| const previous = store.read('snapshot', null); | ||
| const projects = selected ? config.projects.filter(({ name }) => selected.includes(name)) : config.projects; |
There was a problem hiding this comment.
[P2] 未知项目选择会被报告为构建成功。 当调用传入 { "projects": ["typo"] } 而配置里不存在此名称时,filter 返回空数组;下面的循环直接跳过,仍会 record(..., 'succeeded') 并发送 Done,造成未构建任何项目却成功的假阳性。建议验证 selected 的类型与所选项目是否全部存在,未知项目应返回明确的插件错误;增加相应测试。
- buildResult refuses a handler version other than 1 and always stamps format/version last, so a passthrough version can no longer break Core decoding. - Function results are checked to be JSON-encodable and within 1 MiB before the response is written; #respond also turns an encoding failure into a controlled internal error instead of an unhandled rejection. - sample-build reads the previous snapshot when a build ends, so overlapping workspace.build calls get consecutive generations. - sample-build rejects an empty, malformed, or unknown project selection with a plugin error instead of reporting a successful empty build. - TestHost can drive a backend child process; new tests cover all four cases.
xiaoyumuxi
left a comment
There was a problem hiding this comment.
第二轮深入审查:生命周期、资源回收与边界输入
审查锚定 33a69b24cf16454b8d5309a90c124548409dd63c。已对照新提交:上一轮的快照代数、未知项目选择,以及自动审查的版本覆盖、不可编码结果处理均已有修复,本轮不重复提交。
结论
新增 5 条行内发现(2 个 P1、3 个 P2)。优先解决两条 P1:函数失败后仍运行且无法按原调用取消的并行工具,以及后端强制退出后遗留的工具进程。它们直接影响“工作随调用/会话结束”的承诺,不只是示例代码问题。
实际验证
在 Linux / Node 22.16.0 上,使用从该提交读取且与 GitHub blob SHA 核对一致的 SDK 源码运行了独立边界测试。9 个用例中,普通调用/普通取消的对照用例通过,8 个期望正确行为的断言失败:其中 5 个对应行内发现,另外 3 个属于下面的低优先级健壮性观察。这里不是声称仓库原有测试有 8 个失败,也未运行完整 SDK 原测试套件、Rust Core 端到端测试或 macOS 应用。
可观测结果包括:正常 leader 退出后注册表为 0,但孙进程在 killAllProcesses() 后仍存活;强杀后端后 context.run 工具仍存活;并行工具启动失败后函数回 -32603,另一个工具仍被注册且仍运行;写入 undefined 后原快照变成缺失;分块输出“中”时流式回调得到替换字符,而最终捕获字符串正常。复现进程在测试 finally 中被显式清理。
另外 3 个低优先级健壮性观察
这些也已用 SDK 复现,但不把畸形宿主输入或非当前正常 Core 时序包装成合并阻塞项:
backend.mjs:267:#dispatch先访问message.malformed,后判断message === null。发送合法分帧的 JSONnull会直接抛 TypeError,而非受控地拒绝无效 JSON-RPC 消息。应先验证 null/类型。framing.mjs:25–36:8 KiB 头部上限只在尚未找到头部分隔符时检查。一个 chunk 同时包含超过 8 KiB 的完整头部和正文时会被接受;建议对找到的headerEnd同样检查上限,测试完整帧与分块帧两种情况。backend.mjs:345–370:异步onInitialize挂起时收到 shutdown,随后初始化 hook 完成,会无条件把 phase 设回 ready,再次接受函数调用。应防止结束后的初始化恢复会话。注意:现有 Core 在尚未 initialized 时选择强杀而非发送 shutdown,因此这是 SDK 协议状态机的健壮性问题,不声称正常 macOS 关闭路径一定触发它。
范围与缺口
追踪了 SDK → JSON-RPC → Rust Core 的进程终止边界及相关 Swift 接线;没有要求修复无关历史模块。Windows 的 taskkill 路径与 macOS 原生进程回收仍需平台回归验证。建议把本轮失败场景加入生命周期测试,而不只检查单个直接子进程和顺序成功路径。
| }) | ||
| .then(({ result, error }) => { | ||
| onSettled?.(); | ||
| this.#incoming.delete(key); |
There was a problem hiding this comment.
[P1] 函数失败时先收回仍在运行的并行工具,再删除调用记录。 这里结束请求只删除 #incoming,没有中止该调用的 controller,也没有清理它的 context.run 子进程。正常的并行构建就能触发:先启动一个持续运行的工具,再 await Promise.all([slowTool, context.run('/不存在的工具')]);第二个启动报 ENOENT 后,函数立即返回 -32603,第一个工具仍继续运行/修改工作区。此时再发送原 ID 的 $/cancelRequest 也无效,因为记录已被删除。
已在本提交 SDK 上复现:失败响应到达后 runningProcessCount() === 1,随后取消原调用,工具仍存活。建议为每次调用维护资源所有权,在成功/失败终结路径清理尚未完成的工具,而不只依靠宿主显式取消;同时避免误停其他调用。补一个“一项并行工具失败,兄弟工具仍在运行”的回归测试。
| shell: false, | ||
| windowsHide: true, | ||
| // Its own process group on POSIX, so a stop reaches grandchildren too. | ||
| detached: !isWindows, |
There was a problem hiding this comment.
[P1] 独立进程组缺少后端强制退出后的清理责任方。 新 SDK 把每个工具放入独立进程组,但组号仅保存在后端 JS 的 running 集合中。当前 Core 的 rust/lithe-core/src/lsp/interface/process.rs 中 SystemProcess::terminate() 只对后端调用 Child::kill();初始化未完成时停止、关闭超时等路径都会使用它。后端一旦被 SIGKILL 结束,SDK 的 #finish / killAllProcesses 不会执行,工具也不会随着后端 PID 一起退出。
已用实际 SDK 后端复现:函数通过 context.run 启动持续运行的 Node 工具,收到其 PID 后对后端发送 SIGKILL,后端退出后该工具仍存活。这是本次 detached 工具与现有 Core 停止边界的集成缺口。建议把进程树/进程组所有权交给能在后端死亡后继续清理的宿主或监督进程,并覆盖后端崩溃/强杀;仅增加 JS exit 回调无法处理 SIGKILL。
| settled = true; | ||
| // A stopped tree stays registered until its forced stop: the leader | ||
| // can exit on SIGTERM while a grandchild in its group ignores it. | ||
| if (!entry.stopping) running.delete(entry); |
There was a problem hiding this comment.
[P2] leader 正常退出不代表整个进程组已经结束。 finish 在直接子进程 close 时删除注册项并移除取消监听,但一个启动器可以启动同组的长期工作进程(stdio 设为 ignore),然后自己正常退出。此时 entry.stopping 为 false,仍有成员存活的组会从注册表消失,后续 killAllProcesses() 以及原 signal 的取消都到不了它。
已复现:通过 runProcess 启动一个 spawn 孙进程后 unref() 并退出 0 的 leader;runProcess 成功返回、注册表为 0,而调用 killAllProcesses() 后孙进程仍活着。这与后端被强杀是不同路径:后端本身一直正常运行。建议在 leader 结束时清理剩余组成员,或保留可验证的组所有权直到组确实结束,并补 leader 先退出的回归场景。
| /** Stores `value`, which must be JSON-serializable, replacing the previous one. */ | ||
| write(key, value) { | ||
| const path = this.#path(key); | ||
| const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value }); |
There was a problem hiding this comment.
[P2] undefined 会静默覆盖已有状态,而不是触发 invalidValue。 这里序列化的是 envelope 对象,故 value === undefined 时仍得到字符串 {"litheStateStore":1},下面的 body === undefined 分支不会执行;随后原子替换会覆盖旧快照,read 又因缺少 value 返回 fallback。
已复现:store.write('snapshot', {generation:8}); store.write('snapshot', undefined) 不抛错,重新读取得到 MISSING,原有数据已丢失。函数值/Symbol 等同样会在对象属性序列化时被省略。建议在替换文件前验证 value 的编码结果不会被省略,无效输入应抛 sdk.state.invalidValue 并保留旧文件;补“错误写入不破坏原状态”的测试。
| let truncated = false; | ||
| return { | ||
| push(chunk) { | ||
| listener?.(chunk.toString('utf8')); |
There was a problem hiding this comment.
[P2] 流式输出按 Buffer 单独解码会损坏跨 chunk 的 UTF-8 字符。 chunk.toString('utf8') 不保留上一块未完成的字符。管道可以在任意字节处分块,因此中文日志、路径或其他多字节文本会在 onStdout / onStderr 中变成替换字符;最终 stdout.text() 因先拼 Buffer 再解码反而正常,已有捕获输出测试测不到此问题。
已用握手控制分块复现:子进程先写“中”的第 1 个 UTF-8 字节,收到父进程确认后再写余下字节;流式回调拼接得到“���”,最终 result.stdout 为“中”。建议每条流用独立的 StringDecoder(并在结束时 flush),或让 stream 自己保留 UTF-8 解码状态,补跨块字符测试。
- Abort the call signal once the handler settles so tools it left running stop - Swallow rejections of unawaited run() calls so they cannot crash the backend - Track an initializing phase; a shutdown during initialize keeps the session closed - Refuse non-object JSON values and oversized headers even when the frame arrives whole - End a tool's process group when its leader exits normally - Decode streamed output with StringDecoder so split UTF-8 characters stay whole - Refuse state values JSON would drop instead of storing undefined - Document that ending what a killed backend left is the host's job
…vironment runProcess with env used to drop LITHE_PROCESS_SCOPE and LITHE_PLUGIN_BACKEND_SCOPE, so the host could not find what a killed backend left. They are now carried over and cannot be forged by the plugin.
* feat(plugins): invoke plugin functions from the plugin's own web pages on macOS Build providers start as sessions of the plugin that owns them, so Core negotiates litheFunctions. Plugin web pages list, invoke and cancel the functions of their own instances through functions.* bridge methods, with consent, progress events, Core error codes and pluginErrorCode. Shared language servers serve no plugin's functions; lithe/host/* stays closed (see the new Agent Note). Refs #1351 * docs(plugins): record macOS native acceptance of plugin functions The sample-build plugin from #1365 was exercised in a debug app: list, invoke with progress, plugin failure codes, cancel, cross-plugin refusal, and process cleanup on project close and plugin disable. plugin-functions is now verified on macOS. Consent for functions.* is remembered per plugin and workspace until the project closes, like build.*; the Note and the contract README said it was asked on every call. Refs #1351 * fix(plugins): reserve page function calls before consent - functions.invoke takes its slot under the 16-call limit and registers its call before awaiting consent, so concurrent requests cannot all pass a stale count and exceed the limit once consent is granted. - functions.cancel during the consent prompt marks the call; it ends with requestCancelled after consent and the provider function never starts. - A page's call ID is never accepted twice, also after the call ended, so late progress of an old call cannot be attributed to a new one; the set is released when the page closes. - Contract and Agent Note describe the limit, cancel, and ID rules; tests cover invoke -> cancel -> approve, 16 calls pending consent plus one, and ID reuse. * fix(plugins): keep plugin calls off the main actor and end what a backend leaves - Send plugin function and build requests to Core off the main actor; hold events until the operation is registered, and end a backend whose blocked write outlives the call's timeout or a short grace after cancel or stop - Cancel operations and stop a session with a write in flight off the main actor - Let each caller stop waiting for a shared provider start without cancelling it - Promote only Core's plugin.function refusal codes from details; keep transport codes and leave OS error text in the message - Start each plugin backend with its own LITHE_PLUGIN_BACKEND_SCOPE and end the processes carrying it once the session is released - Scripted provider start honours initialState so the initializing-stop test keeps its late ready reply
xiaoyumuxi
left a comment
There was a problem hiding this comment.
合并后深度复查:SDK 边界与故障路径
审查基线为最终 head 7213936a068a498a4cb78e692c242f44b8db7c1c,不是最早的 8a2e041。已核对现有评论和后续修复;不重复报告已修复的版本覆盖、BigInt/循环结果崩溃、并行工具失败清理、undefined 状态覆盖、UTF-8 分块等问题。
本轮新增 7 条行内意见:6 条 P2、1 条 P3。重点是现有修复尚未覆盖的 exit/close 区别、单次调用取消与后台进程所有权、处理器和流回调的错误边界、发送背压,以及实际消费端类型声明解析。
验证范围
- 在 Linux / Node v22.16.0 上对本 head 的实际 SDK 源码执行定向探针,相关文件的 Git blob SHA 已核对一致。
- TypeScript 5.8.3:按 README 的相对
.mjs导入方式编译消费端,并用相同声明字节改为.d.mts做对照。 - 核对 #1366 的
onReleased后端清理接线,区分“整个 Provider 结束”与“只取消一个函数”。 - 未运行完整 macOS 应用、Windows 分支、Rust Core 端到端测试或全量上游单测;不会把上述定向验证描述成全平台验收。
PR 已合并,以下问题建议作为后续修复处理。
| outcome(); | ||
| }; | ||
| child.once('error', (error) => finish(() => reject(error))); | ||
| child.once('close', (exitCode, exitSignal) => |
There was a problem hiding this comment.
[P2] 把组清理挂在 close 上,会被继承输出管道的孙进程反向阻塞。 现有 finish 中的 SIGKILL 修复了孙进程 stdio: 'ignore' 的情况,但这里仍只在直接子进程的 close 后调用它。启动器若 spawn(..., {stdio: ['ignore', 1, 2]}).unref() 后正常退出,孙进程仍持有同一 stdout/stderr;leader 已经 exit,却不会产生 close,于是清理孙进程的代码永远等不到执行,await context.run(...) 也不结束。
在本 head 实测:leader 已退出、孙进程仍存活、Promise 未完成,runningProcessCount() === 1;显式清理后 Promise 才返回。建议在 exit 时启动剩余进程组的回收,流的最终收尾另做有界等待;补一个“leader 正常退出,孙进程继承输出管道”的测试,而不只测 stdio ignore。
| signalTree(entry.child, 'SIGTERM'); | ||
| entry.forceTimer = setTimeout(() => { | ||
| running.delete(entry); | ||
| signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); |
There was a problem hiding this comment.
[P2] 单次调用取消清不掉另起进程组的后代,宿主的会话级兜底也不会在此时运行。 signalTree 仅向 -child.pid 发信号。工具启动 detached: true 的后台进程后,该后代就不在这个组内;计时器仍删除登记项,随后 killAllProcesses() 也找不到它。
本 head 实测:取消后 runProcess 以 AbortError 结束、注册表归零,但 detached 孙进程仍存活,调用 killAllProcesses() 后仍存活。#1366 新增的 scope 标记只在 session.onReleased 等路径触发宿主清理;这里只取消一个函数,Provider 继续服务,所以不能靠那条路径补救。这不同于之前“后端被 SIGKILL”的问题。
建议把逃离进程组的后代纳入可按调用回收的所有权/监督机制,避免为清一个调用误杀整个 Provider 的其他调用,并覆盖“取消函数但保留会话”的测试。
| log: (...parts) => this.log(...parts), | ||
| }; | ||
| Promise.resolve() | ||
| .then(() => invoke(context)) |
There was a problem hiding this comment.
[P2] 执行处理器前没有检查已收到的取消,导致返回 cancelled 时副作用已经发生。 #run 先登记 controller,再把 invoke(context) 排入微任务;同一输入 chunk 后面的 $/cancelRequest 可以先把 signal 置为 aborted,但这里仍执行处理器,直到处理器返回才 signal.throwIfAborted()。
把一次 snapshot.clear 请求和对应取消帧一起送入已初始化的后端,处理器只执行 writes++; return null:本 head 实测 writes === 1,同时响应为 -32800。真实的同步删除快照或写文件函数会呈现“已经取消,实际仍修改”的行为。
建议在 invoke(context) 之前也检查 signal;补“请求与取消同一 chunk,取消已处理但 handler 尚未开始”的回归测试。
| let size = 0; | ||
| let truncated = false; | ||
| const deliver = (text) => { | ||
| if (text) listener?.(text); |
There was a problem hiding this comment.
[P2] 输出回调抛异常会越过请求错误边界,直接终止整个后端。 listener 在 child.stdout/stderr 的 data 事件栈上执行,没有把异常转成 runProcess 的拒绝。因此处理器即便写了 try { await context.run(..., {onStdout: chunk => JSON.parse(chunk)}) } catch (...) { ... },也接不到格式错误,SDK 的 #run 也捕获不到。
本 head 的独立 Node 后端探针中,工具输出非 JSON 后进程退出码为 1、stderr 有 SyntaxError,函数没有收到任何响应;这会连带中断会话中的其他调用,而不只是当前工具失败。建议捕获每条输出流及 decoder flush 的回调异常,终止该工具树并 reject 所属调用;补一个“流回调抛错但后端仍可处理下一请求”的测试。
|
|
||
| #write(message) { | ||
| if (!this.#output || this.#phase === 'exited') return; | ||
| this.#output.write(encodeFrame(message)); |
There was a problem hiding this comment.
[P2] 忽略输出背压使进度帧在宿主暂停读取时无界堆积。 write() 返回 false 并不会抛错,当前实现仍持续编码和排队;单条进度消息的 1024-byte 上限不能限制累计队列。宿主暂停、调试或读取变慢时,频繁报告进度的普通长任务就可以把后端内存持续推高。
探针先完成 initialize,再让 Writable 停止完成写入(highWaterMark=64),从一个函数发送 8192 条各自合法的进度:writableLength 增至 9,510,233 bytes,writableNeedDrain === true,SDK 没有施加任何边界。
建议实现有界发送队列并处理 drain,或对进度做合并/限流和丢弃策略;响应及生命周期消息也需要明确的排队预算。补“初始化后宿主停止读取,持续发送进度”的测试。
| @@ -0,0 +1,142 @@ | |||
| // Type declarations for the Lithe plugin backend SDK. Keep in sync with the | |||
There was a problem hiding this comment.
[P2] 声明文件名与 README 推荐的相对 .mjs 导入不匹配。 文档要求复制 src/,然后按 ./vendor/lithe-plugin-sdk/src/index.mjs 导入。该相对路径绕过 package.json 的 exports.types;在 NodeNext 下,.mjs 对应的是 .d.mts,这里的 index.d.ts 不会被消费端找到。
TypeScript 5.8.3 实测:一个 .mts 消费端相对导入 src/index.mjs,执行 tsc --strict --noEmit --module nodenext --moduleResolution nodenext --skipLibCheck,得到 TS7016,处理器参数同时变成隐式 any。只把完全相同的声明字节另存为 index.d.mts,同一命令立即通过。
建议发布/复制时提供匹配的 .d.mts 并同步 exports,增加按 README 实际路径导入的消费端测试;仅检查导出名字或单独编译 .d.ts 测不到这一问题。
| for (;;) { | ||
| const headerEnd = this.#buffer.indexOf(HEADER_END); | ||
| // The same bound whether the header arrived whole or in pieces. | ||
| if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) { |
There was a problem hiding this comment.
[P3] 合法的最大长度头部会因分块位置不同而被拒绝。 完整到达时,8192-byte header 加 \r\n\r\n 可以通过;但若第一块恰好包含该 header 和终止符的第一个 \r,此时 buffer 长 8193、headerEnd 为 -1,这个条件立即抛错。同一帧完整输入可解析为 {},拆在 8193 处却得到 The message header is too long.,已在本 head 复现。
建议未找到完整分隔符时允许最多 3 个可能属于终止符的尾字节,再按实际 header 长度判定;增加接近上限时各个分隔符拆分位置的测试。
概述
Issue #1351 的 PR 2:提供第一个官方插件后端 SDK(Node,
sdk/node)和一个经真实 Core 会话端到端验证的示例构建插件。SDK 只实现shared/contracts/plugins/v1中已有的litheBuild1 与litheFunctions1,不新增任何线上字段;不使用 SDK 的后端同样合法。插件作者只写处理器,Lithe 的统一管理规则由 SDK 变成默认行为。改动点
新增
sdk/node(@lithe/plugin-sdk0.1.0,零依赖,private)src/framing.mjs:LSPContent-Length分帧,头部 8 KiB、帧 64 MiB 上限,非 JSON 帧回-32700。src/protocol.mjs:协议常量、函数 ID / 插件错误码校验、文本截断、保留方法判断(生命周期方法与所有lithe/*)。src/errors.mjs:PluginError(code, message)是唯一能设置pluginErrorCode的方式;其他异常回通用文案,不泄漏路径和堆栈(细节写 stderr);取消映射为-32800。src/backend.mjs:createBackend()build.model/dependencies处理类型化构建请求并补format/version;只有存在处理器且宿主提供 v1 时才协商litheBuild。function(id, h)声明初始函数;registerFunction/unregisterFunction在回复initialize前直接改初始列表(可在onInitialize中按状态决定),之后通过lithe/function/register|unregister串行发送,宿主拒绝时回滚处理器。signal、throwIfCancelled、绑定取消信号的run、log;函数额外有functionID、operationID、progress(截断/取整/夹紧,调用结束后忽略)。-32002、shutdown 后-32600、未知方法-32601、$/cancelRequest中止对应调用;shutdown 中止全部调用并结束全部子进程;exit在 shutdown 后退出码 0,否则 1。lithe/*方法(避免递归派发)、拒绝在capabilities中自行声明litheBuild/litheFunctions;以 stdio 服务时把console.log/info/debug改到 stderr。src/process.mjs:runProcess不经 shell;POSIX 用进程组,取消时 SIGTERM → 宽限期后 SIGKILL 整个进程组;Windows 用taskkill /T /F;输出有上限。src/state.mjs:openStateStore原子写(临时文件 + fsync + rename),解析真实路径后拒绝插件包内目录(在 mkdir 之前),保护只读包、代码签名和 Sparkle 增量基线。src/index.d.ts:完整类型声明(已用tsc --strict检查)。README.md:英文开发者指南(API、打包方式、保持 Lithe 统一管理的 6 条规则)。examples/sample-build:示例插件,runtime启动 Node,提供sample构建系统(模型/依赖来自 Provider)、函数workspace.build(分步进度、逐项目运行命令、失败抛sample.buildFailed)、snapshot.read,以及有快照时运行时注册的snapshot.clear;状态写入${workspaceStateDir}。测试
sdk/node/test/*.test.mjs(24 项,全部有界等待):协商、ID 规则(对照function-protocol.json)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期与退出码、分帧、取消时结束忽略 SIGTERM 的孙进程、会话结束清理子进程、状态存储与只读包拒绝、.d.ts导出与运行时一致。rust/lithe-core/tests/plugin_sdk_node.rs(设置LITHE_PLUGIN_SDK_NODE时运行):示例插件经 Core 清单校验、启动计划、运行时版本检查后启动;验证构建模型/依赖解码、函数结果与进度、插件错误码、取消后进程被结束、重启后状态恢复与snapshot.clear重新注册,最后确认插件包文件清单不变、workspace 状态目录无残留。CI 与文档
scripts/verify-rust-core-crates.sh:Rust Core lane 运行 SDK 单测(含 junit 报告),并以LITHE_PLUGIN_SDK_NODE打开端到端测试。scripts/classify-ci-changes.sh/test-classify-ci-changes.sh:sdk/*归入rust_core,不再走“未分类 → 全量验证”。shared/contracts/plugins/v1/README.md:新增 “Official backend SDK” 小节。.agents/skills/develop-lithe/SKILL.md:归属表新增sdk/。2026-10-20-plugin-backend-sdk.md(为什么做 SDK、零依赖复制分发、被否方案),并从函数契约 Note 链接。plugin-functions.json:补 SDK 证据与验证步骤;状态不变(宿主仍无调用入口,Windows 的taskkill路径尚未在 Windows 上验证)。后续修复(macOS 原生验收中发现)
sample-build的backend/main.mjs:先用path.resolve规范化工作区根目录。macOS 传来的目录rootUri末尾带/,原来的检查会拒绝所有路径,每次构建都报sample.config。plugin_sdk_node.rs端到端测试改用末尾带斜杠的rootUri,覆盖这种情况。修复前该测试失败,修复后通过。不在本 PR
lithe/host/*宿主能力(PR 3+)。本地验证
node --test sdk/node/test/*.test.mjs:24 通过LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node:通过bash scripts/test-classify-ci-changes.sh、verify-test-stability.sh、verify-rust-core-comments.sh、cargo fmt --check、verify-shared-contracts.sh、verify-agent-notes.sh、verify-platform-feature-matrix.sh、verify-platform-feature-matrix-change.sh origin/preview HEAD、verify-runtime-bundle-immutability.sh:全部通过Refs #1351