From c4058c52c358cc6655e606daf37a2192e5e6d0d2 Mon Sep 17 00:00:00 2001 From: Ver-zhzh Date: Sun, 11 Oct 2026 18:26:38 +0000 Subject: [PATCH] =?UTF-8?q?fix(sidecar):=20=E5=90=AF=E5=8A=A8=E6=9C=9F=20A?= =?UTF-8?q?CL=20=E5=8A=A0=E5=9B=BA=E6=B0=B8=E4=B8=8D=E6=94=B6=E6=95=9B=20+?= =?UTF-8?q?=20=E8=B6=85=E6=97=B6=E8=87=B4=E5=91=BD=EF=BC=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=86=A8=E8=83=80=E5=90=8E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=9C=8D=E5=8A=A1=E5=BF=85=E5=B4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SDDL 比较前归一化 AI 继承标记(D:PAI -> D:P):只差 AI 标记时判已收敛, 不再每次启动都在整棵子树上重跑数十秒的无效写入 - runServe 启动期 ensurePrivateDirectory 改走 hardenServeDataDir: 加固超时/失败时 warn 降级继续 listen,不再 process.exit(1) Closes #450 --- scripts/source-budgets.manifest.json | 4 +- src/platform/__tests__/private-path.test.ts | 23 +++++++++- src/platform/private-path.ts | 20 ++++++++- .../__tests__/serve-acl-hardening.test.ts | 44 +++++++++++++++++++ src/server/serve.ts | 15 ++++++- 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/server/__tests__/serve-acl-hardening.test.ts diff --git a/scripts/source-budgets.manifest.json b/scripts/source-budgets.manifest.json index a83742a33..eda80c6c2 100644 --- a/scripts/source-budgets.manifest.json +++ b/scripts/source-budgets.manifest.json @@ -152,8 +152,8 @@ "note": "995→1004:账本对齐实际行数(多会话功能增长欠账收口,2026-08-25) 1004→1014:收编公开仓 PR #256(Windows 跨平台语义)——resolveFilterToTestFile 的 glob 结果归一到 POSIX(import toPosixPath +1、注释 +4、files.push 改写 +1,净 +6);余 4 行为此前多会话功能增长的欠账(改前实测 1008 vs 账本 1004),本次一并对齐。归一只改返回值形态、未新增接缝,算法仍在该函数内。 2026-10-04:**代为登记**(对齐实测 1030,ceiling 1014→1030)——+16 行为并行会话的提交未随单登记;本笔只对齐行数以免门禁长期带红,拆分归该线作者。 1030→1033:缺陷①(dotnet×pytest 死锁,见 .rivet/plans/子代理失败修复-工具通道误判-取证一致性-派单能力提示.md)——hasPythonProjectMarker 的 tests/ 目录判据收紧为「tests/ 须真含 .py」:旧判据凭 isDirectory() 把 dotnet 项目的 tests/SelfTest.csproj 判成 Python,命中后 pytest 直连 spawn → ENOENT,而 verifier 工具面无 bash 可旁路,工单直接死锁。+3 行(3 行理由注释;改前实测 1030 = 旧 ceiling,任何修复都必须上账);判据仍在本函数内,无接缝可拆。 1033→1044:2026-10-05 验证完成证据/交付影响面接线;解析、reporter 与影响分析已沿接缝拆入 verification-command/test-completion/delivery-impact,核心仅增加执行前后挂钩与异步消费。" }, "src/server/serve.ts": { - "ceiling": 1534, - "note": "2026-10-07:ceiling 对齐实测 1531——含本会话信任门族修复(LAN 模式启动提示 +6 行),另含并行会话存量欠账(清仓以免门禁长期带红)。 serve 增长:跨盘审批广播接线(93956f5e2 回流 2026-09-07)——ceiling 989→1010。 1010→1027:自定义模型解析修复回流(main 74ac20588:无 key provider 早退改 continue + classifyModelSpecMiss,2026-09-07) 1027→1036:B4 reloadConfig 注入 + B5 oauth isAuthenticated(682161674,+9,2026-09-07 审查修复) 1036→1116:楔死族 serve 侧(0e792e26e +44)+ 会话内模型列表 key 过滤(e3fdaa146 +30)+ 磁盘对账单源接线(c77427d21)——多笔落地未上账,2026-09-09 审查补记)。1116→1120:/project/trust 路由注册(import + 注册调用共 +3)——路由主体、参数校验与 6 条测试都在新模块 src/server/trust-api.ts,此处只剩接线。 1120→1184:Mobile Remote 服务端回流(alpha 9899d9dca/b1c5fa100:RunServeOptions host/allowedHosts/mobileDir + RIVET_SERVE_HOST/ALLOW/MOBILE_DIR 解析 + --host/--mobile-dir + /remote/info 装配 + parseHostsAllow fail-open 修复,+64,2026-09-12) 1184→1220:HEAD 1215 欠账收口(+31,provider 热重载等他会话已落未上账)+ sidecar 插件暖场接线(warmPluginToolsCache 调用 + import,2026-09-12,+5) 1220→1268:桌面性能阶段 3 sidecar 首秒(1c9bbb0e5,2026-09-13,+48)——[serve-timing] 十阶段 mark + scheduleDeferredWarmup/fireNow/cancel 接线 + 宿主探针 prewarm 点火 + createAgent 有界等待;提交时漏记账本,此条补记。1268→1287:桌面性能阶段 4 全局推送通道(+19,2026-09-13)——ServerEventBus 构造/close、onSessionsChanged 与 TaskRegistry.onEvent 两条 publish 接线、GET /events 路由注册;health 快照构造点抽到 health-route.ts(createHealthSnapshot)与 SSE 心跳共用,loopLag 回调提为局部函数复用。总线/路由主体在新模块 server-event-bus.ts / server-events-route.ts,此处只剩接线。 1287→1292:生图槽配置落盘 hook(issue #8)——onImageGenConfigChanged 接线(与 specReload/onProviderConfigChanged 同判定:注入式 ctx 不接线以保持测试确定性;没有它,存活 agent 的工具表不刷新,「配置成功但工具不出现」)——本地线原记 ceiling 1225 未附说明段,merge 时按合并态实际行数对齐。 1292→1319:SSE 优雅关停(agent-13 修复)——sseRegistry 构造与注释(6)+ 三处路由接线(prompt deps / session routes deps / events route 第 4 参,3)+ finish 链 closeAll 与 closeIdleConnections 及注释(10)+ shutdownServer 幂等与 3s 保险丝及注释(8,覆盖 SIGINT/SIGTERM/parent-watchdog 三条触发路径)。机制主体在新模块 src/server/sse-registry.ts 与各 route builder 的注册/注销;本文件为装配层与关停编排。 1319→1323:事件写链收口排空(agent-16 修复)——finish 转 async 并在关停前 await persistence.flushAllAsync(3_000)(flushAllAsync 此前无生产调用点),+4 含注释。 1323→1331:关停保险丝对齐设计内预算 + 二次信号立即强退(387b17178,跨会话落地未上账——Wave A 验证时代收口,账目依据该提交实际 diff)。 1331→1410:PR #9-#12 合入(provider 多 key 全栈,2026-09-13)——resolveModelSpec 按模型归属 key 解析凭据(provider×keyId×model 三段式)(+79) 1410→1414:账号 device flow 路由接线(import + 注释 + 注册调用共 +4,2026-09-14)——路由主体、单次轮询语义、凭据不出 sidecar 与 11 条测试都在新模块 src/server/account-routes.ts,此处只剩接线。 1414→1419:MCP stdio 自动重连恢复后的工具面推送接线(提交 995812ef6,+6/-1 净 +5,跨会话落地未上账,本次代收口)——McpManager 构造传入 onToolsChanged 回调(自动重连成功 → injectMcpTools 把恢复的工具面推回已有 live agent 的会话),宿主侧是主动推送语义 1419→1432:issue #193——open-file 打开「无关联处理程序」的文件时,Windows 会弹「选取应用」对话框(宿主是 shell,detached 之后调用方既回收不了也察觉不到),且旧代码无论成败都回 `opened`。补一次判定:无处理程序 → 退化为 explorer /select, 定位 + 如实回报(+12,2026-09-18;本仓基线 1420——harness 线少 1 行 Pro 注释差异,收编 PR #195 时对齐)。判定/探测/命令选择的主体全部落在 src/tools/open-path.ts(decideOpenAction / windowsFileHasHandler / isDirectoryPath;该文件未点名,210 行远低于 redline 800,不另立条目);本文件只留 `const isDir/action` 6 行、effectiveCommand 三态 4 行、返回体三态 2 行——路由表与 apiToken 均为本文件私有,无接缝可拆。 1432→1446:设置意图 LLM 兜底路由装配(Wave 3 回流,2026-09-20,+14 实测)——import buildSettingsIntentRoutes + Object.assign 装配块(baseUrl/apiKey 取默认 provider,getModel 按请求求值:ctx.model 在配置重载时被整体替换,闭包捕获旧值会一直用过期模型)。校验/鉴权/schema 白名单全部落在新文件 src/server/settings-intent-route.ts(179 行,低于 redline 800 不另立条目);本文件只留装配,正是为守住行数棘轮。零缓冲。 1446→1447:settings-intent 凭据改 getter 求值(2026-09-20 审查修复)——getBaseUrl/getApiKey 与 getModel 同构按请求读 ctx(首启无 key 后配好 key、或切换 provider 时按值捕获会让 LLM 兜底永久失效),净 +1。 1447→1449:project 路由的 cwd 在册守卫接线(收编公开仓 PR #226 / issue #221)——三个 build*Routes 各多一个 knownWorkspaces 参数(+2;主体在 src/server/workspace-guard.ts 新模块,装配层只留注入),2026-09-21。 1449→1462:档位诚实化——listAllModels 每条模型透出 effortSupported(/sessions/:id/models 契约),+13 实测 1462。 1462→1469:阶段 2 执行后端选择缝(+7)——抽出 buildGatewayAgent(显式 AgentFactory)、resolveExecutionBackend 选择(+4)、关停链先收执行后端(+2)、import(+1)。隔离适配器在闭源 src/pro/runtime,灰度默认关闭(RIVET_EXECUTION_BACKEND=isolated),缺产物回退进程内;零缓冲。 1469→1471:阶段 2 恢复台账装配(+2)——import RecoveryJournal + 构造传入 RuntimeSessionManager。台账主体在 src/server/recovery-journal.ts 新模块。 1471→1479:隔离后端与启动失败可见性(+8,2026-09-27)——复用 RUNTIME_INSTANCE_ID(实例标识不再各处重算)、registry 初始化失败记 initializationError 并在建 agent 时抛出(此前只 console.error,表现为会话创建莫名失败)、AgentFactory 把 AbortSignal 透传给 buildManagedAgentAsync。 1479→1524:恢复台账提升为单实例(网关工厂恢复时要读检查点转录水位,之后同一实例交给 RuntimeSessionManager——建两份会读错目录)+ 水位透传(PLAN §4 第 2 步)。 1524→1538:describeRestore 抽出(恢复信息收口:结构化注入的待确认工具 + 转录水位对齐观测),供同步 restoreHistoryMessages 与桌面异步预取路径共用。 2026-09-28:对齐公开仓 PR #295(issue #290)——setScheduleWriteGuard(() => lock.isOwner()) 工具侧门 + buildScheduleRoutes isWriteAllowed 路由侧门接线(+4 含注释);ceiling 1538→1542。 2026-09-30:issue #302——GET /clipboard/image 路由装配(import + Object.assign + 注释共 +5)——WebKitGTK 粘贴事件不透剪贴板图片,桌面端 Ctrl+V 取图的 IPC 兜底;路由主体、取图链复用与 4 条测试都在新模块 src/server/clipboard-routes.ts,此处只剩接线。 合并 feat/wsl-remote-attach(PR #14)到 v3.27 线:watchdog 整块拆出至 src/server/parent-watchdog.ts(本文件只留 re-export,-41 行),按合并态实际行数校正 ceiling=1547→1506(wc -l 实测 1506)。 2026-10-01:serve 启动图瘦身——loadServeAgent 及模块缓存状态拆到 src/server/serve-agent-loader.ts,本文件只留 import + 再导出(1507→1476,-30);ceiling 对齐实测,零缓冲。 2026-10-03:个人中心实际星域足迹接线与历史不完整标记;聚合/鉴权主体拆到 profile-routes.ts,管理器只保留按逻辑 run 的一次性记录守卫;本次对齐实测行数(main 原有行数超账一并注明,其他文件不调整)。 2026-10-04:1478→1508(净 +26,另 4 行为此前存量欠账一并对齐)——桌面端『假续跑』修复的 serve 单写者接线:runServe 装配前 acquire 会话库独占锁(P0-1),拿不到锁时六个 desktopDir() 装配点降级(注册表/持久化/恢复台账/run 台账/定时任务/sidecar-exit)并置 initializationError='data-dir-locked',health 接线 storeLockHolder,假中断标记迁移后台挂载(P0-4),[loop-lag] 打印换 LoopStallDetector 归因(P1),退出释放锁。机制主体在 store-lock.ts / phantom-resume-migration.ts / loop-health.ts 三个新模块,本文件只留装配与降级判定(serve.ts 的单一职责);行数收紧留待后续拆分。零缓冲。;HTML 预览:关闭 sidecar 时清理独立上下文与资源租约,+4 接线。 2026-10-09:收编公开仓 PR #407——file-context 路由 cwd 工作区守卫接线(1531→1533,+2 含注释)。守卫判据复用既有 src/server/workspace-guard.ts 的 isKnownWorkspace,本文件只注入 knownWorkspaces 数据源(与 project-docs / project-templates / project-trust 同一装配点),路由侧校验与参数传递在 workspace-route.ts / config-routes.ts / file-context-routes.ts;接线无接缝可拆。 2026-10-11:收编公开仓 PR #437 Wave 3(20de52032)——远控终端适配在 serve 装配层的接线 +1 行未随该提交上账,本次对齐实测 1534。" + "ceiling": 1563, + "note": "2026-10-07:ceiling 对齐实测 1531——含本会话信任门族修复(LAN 模式启动提示 +6 行),另含并行会话存量欠账(清仓以免门禁长期带红)。 serve 增长:跨盘审批广播接线(93956f5e2 回流 2026-09-07)——ceiling 989→1010。 1010→1027:自定义模型解析修复回流(main 74ac20588:无 key provider 早退改 continue + classifyModelSpecMiss,2026-09-07) 1027→1036:B4 reloadConfig 注入 + B5 oauth isAuthenticated(682161674,+9,2026-09-07 审查修复) 1036→1116:楔死族 serve 侧(0e792e26e +44)+ 会话内模型列表 key 过滤(e3fdaa146 +30)+ 磁盘对账单源接线(c77427d21)——多笔落地未上账,2026-09-09 审查补记)。1116→1120:/project/trust 路由注册(import + 注册调用共 +3)——路由主体、参数校验与 6 条测试都在新模块 src/server/trust-api.ts,此处只剩接线。 1120→1184:Mobile Remote 服务端回流(alpha 9899d9dca/b1c5fa100:RunServeOptions host/allowedHosts/mobileDir + RIVET_SERVE_HOST/ALLOW/MOBILE_DIR 解析 + --host/--mobile-dir + /remote/info 装配 + parseHostsAllow fail-open 修复,+64,2026-09-12) 1184→1220:HEAD 1215 欠账收口(+31,provider 热重载等他会话已落未上账)+ sidecar 插件暖场接线(warmPluginToolsCache 调用 + import,2026-09-12,+5) 1220→1268:桌面性能阶段 3 sidecar 首秒(1c9bbb0e5,2026-09-13,+48)——[serve-timing] 十阶段 mark + scheduleDeferredWarmup/fireNow/cancel 接线 + 宿主探针 prewarm 点火 + createAgent 有界等待;提交时漏记账本,此条补记。1268→1287:桌面性能阶段 4 全局推送通道(+19,2026-09-13)——ServerEventBus 构造/close、onSessionsChanged 与 TaskRegistry.onEvent 两条 publish 接线、GET /events 路由注册;health 快照构造点抽到 health-route.ts(createHealthSnapshot)与 SSE 心跳共用,loopLag 回调提为局部函数复用。总线/路由主体在新模块 server-event-bus.ts / server-events-route.ts,此处只剩接线。 1287→1292:生图槽配置落盘 hook(issue #8)——onImageGenConfigChanged 接线(与 specReload/onProviderConfigChanged 同判定:注入式 ctx 不接线以保持测试确定性;没有它,存活 agent 的工具表不刷新,「配置成功但工具不出现」)——本地线原记 ceiling 1225 未附说明段,merge 时按合并态实际行数对齐。 1292→1319:SSE 优雅关停(agent-13 修复)——sseRegistry 构造与注释(6)+ 三处路由接线(prompt deps / session routes deps / events route 第 4 参,3)+ finish 链 closeAll 与 closeIdleConnections 及注释(10)+ shutdownServer 幂等与 3s 保险丝及注释(8,覆盖 SIGINT/SIGTERM/parent-watchdog 三条触发路径)。机制主体在新模块 src/server/sse-registry.ts 与各 route builder 的注册/注销;本文件为装配层与关停编排。 1319→1323:事件写链收口排空(agent-16 修复)——finish 转 async 并在关停前 await persistence.flushAllAsync(3_000)(flushAllAsync 此前无生产调用点),+4 含注释。 1323→1331:关停保险丝对齐设计内预算 + 二次信号立即强退(387b17178,跨会话落地未上账——Wave A 验证时代收口,账目依据该提交实际 diff)。 1331→1410:PR #9-#12 合入(provider 多 key 全栈,2026-09-13)——resolveModelSpec 按模型归属 key 解析凭据(provider×keyId×model 三段式)(+79) 1410→1414:账号 device flow 路由接线(import + 注释 + 注册调用共 +4,2026-09-14)——路由主体、单次轮询语义、凭据不出 sidecar 与 11 条测试都在新模块 src/server/account-routes.ts,此处只剩接线。 1414→1419:MCP stdio 自动重连恢复后的工具面推送接线(提交 995812ef6,+6/-1 净 +5,跨会话落地未上账,本次代收口)——McpManager 构造传入 onToolsChanged 回调(自动重连成功 → injectMcpTools 把恢复的工具面推回已有 live agent 的会话),宿主侧是主动推送语义 1419→1432:issue #193——open-file 打开「无关联处理程序」的文件时,Windows 会弹「选取应用」对话框(宿主是 shell,detached 之后调用方既回收不了也察觉不到),且旧代码无论成败都回 `opened`。补一次判定:无处理程序 → 退化为 explorer /select, 定位 + 如实回报(+12,2026-09-18;本仓基线 1420——harness 线少 1 行 Pro 注释差异,收编 PR #195 时对齐)。判定/探测/命令选择的主体全部落在 src/tools/open-path.ts(decideOpenAction / windowsFileHasHandler / isDirectoryPath;该文件未点名,210 行远低于 redline 800,不另立条目);本文件只留 `const isDir/action` 6 行、effectiveCommand 三态 4 行、返回体三态 2 行——路由表与 apiToken 均为本文件私有,无接缝可拆。 1432→1446:设置意图 LLM 兜底路由装配(Wave 3 回流,2026-09-20,+14 实测)——import buildSettingsIntentRoutes + Object.assign 装配块(baseUrl/apiKey 取默认 provider,getModel 按请求求值:ctx.model 在配置重载时被整体替换,闭包捕获旧值会一直用过期模型)。校验/鉴权/schema 白名单全部落在新文件 src/server/settings-intent-route.ts(179 行,低于 redline 800 不另立条目);本文件只留装配,正是为守住行数棘轮。零缓冲。 1446→1447:settings-intent 凭据改 getter 求值(2026-09-20 审查修复)——getBaseUrl/getApiKey 与 getModel 同构按请求读 ctx(首启无 key 后配好 key、或切换 provider 时按值捕获会让 LLM 兜底永久失效),净 +1。 1447→1449:project 路由的 cwd 在册守卫接线(收编公开仓 PR #226 / issue #221)——三个 build*Routes 各多一个 knownWorkspaces 参数(+2;主体在 src/server/workspace-guard.ts 新模块,装配层只留注入),2026-09-21。 1449→1462:档位诚实化——listAllModels 每条模型透出 effortSupported(/sessions/:id/models 契约),+13 实测 1462。 1462→1469:阶段 2 执行后端选择缝(+7)——抽出 buildGatewayAgent(显式 AgentFactory)、resolveExecutionBackend 选择(+4)、关停链先收执行后端(+2)、import(+1)。隔离适配器在闭源 src/pro/runtime,灰度默认关闭(RIVET_EXECUTION_BACKEND=isolated),缺产物回退进程内;零缓冲。 1469→1471:阶段 2 恢复台账装配(+2)——import RecoveryJournal + 构造传入 RuntimeSessionManager。台账主体在 src/server/recovery-journal.ts 新模块。 1471→1479:隔离后端与启动失败可见性(+8,2026-09-27)——复用 RUNTIME_INSTANCE_ID(实例标识不再各处重算)、registry 初始化失败记 initializationError 并在建 agent 时抛出(此前只 console.error,表现为会话创建莫名失败)、AgentFactory 把 AbortSignal 透传给 buildManagedAgentAsync。 1479→1524:恢复台账提升为单实例(网关工厂恢复时要读检查点转录水位,之后同一实例交给 RuntimeSessionManager——建两份会读错目录)+ 水位透传(PLAN §4 第 2 步)。 1524→1538:describeRestore 抽出(恢复信息收口:结构化注入的待确认工具 + 转录水位对齐观测),供同步 restoreHistoryMessages 与桌面异步预取路径共用。 2026-09-28:对齐公开仓 PR #295(issue #290)——setScheduleWriteGuard(() => lock.isOwner()) 工具侧门 + buildScheduleRoutes isWriteAllowed 路由侧门接线(+4 含注释);ceiling 1538→1542。 2026-09-30:issue #302——GET /clipboard/image 路由装配(import + Object.assign + 注释共 +5)——WebKitGTK 粘贴事件不透剪贴板图片,桌面端 Ctrl+V 取图的 IPC 兜底;路由主体、取图链复用与 4 条测试都在新模块 src/server/clipboard-routes.ts,此处只剩接线。 合并 feat/wsl-remote-attach(PR #14)到 v3.27 线:watchdog 整块拆出至 src/server/parent-watchdog.ts(本文件只留 re-export,-41 行),按合并态实际行数校正 ceiling=1547→1506(wc -l 实测 1506)。 2026-10-01:serve 启动图瘦身——loadServeAgent 及模块缓存状态拆到 src/server/serve-agent-loader.ts,本文件只留 import + 再导出(1507→1476,-30);ceiling 对齐实测,零缓冲。 2026-10-03:个人中心实际星域足迹接线与历史不完整标记;聚合/鉴权主体拆到 profile-routes.ts,管理器只保留按逻辑 run 的一次性记录守卫;本次对齐实测行数(main 原有行数超账一并注明,其他文件不调整)。 2026-10-04:1478→1508(净 +26,另 4 行为此前存量欠账一并对齐)——桌面端『假续跑』修复的 serve 单写者接线:runServe 装配前 acquire 会话库独占锁(P0-1),拿不到锁时六个 desktopDir() 装配点降级(注册表/持久化/恢复台账/run 台账/定时任务/sidecar-exit)并置 initializationError='data-dir-locked',health 接线 storeLockHolder,假中断标记迁移后台挂载(P0-4),[loop-lag] 打印换 LoopStallDetector 归因(P1),退出释放锁。机制主体在 store-lock.ts / phantom-resume-migration.ts / loop-health.ts 三个新模块,本文件只留装配与降级判定(serve.ts 的单一职责);行数收紧留待后续拆分。零缓冲。;HTML 预览:关闭 sidecar 时清理独立上下文与资源租约,+4 接线。 2026-10-09:收编公开仓 PR #407——file-context 路由 cwd 工作区守卫接线(1531→1533,+2 含注释)。守卫判据复用既有 src/server/workspace-guard.ts 的 isKnownWorkspace,本文件只注入 knownWorkspaces 数据源(与 project-docs / project-templates / project-trust 同一装配点),路由侧校验与参数传递在 workspace-route.ts / config-routes.ts / file-context-routes.ts;接线无接缝可拆。 2026-10-11:收编公开仓 PR #437 Wave 3(20de52032)——远控终端适配在 serve 装配层的接线 +1 行未随该提交上账,本次对齐实测 1534。 2026-10-12:issue #450——runServe 启动期 ACL 加固 fail-open(新增 hardenServeDataDir 导出:try/catch 包裹 ensurePrivateDirectory,超时/失败时 serverLogger.warn 降级继续 listen,不再 process.exit(1);+15 实测,含注释与空行)。加固逻辑主体仍在 src/platform/private-path.ts(SDDL 归一化),本文件只留调用点装配。" }, "src/agent/starflow-orchestrator.ts": { "ceiling": 1076, diff --git a/src/platform/__tests__/private-path.test.ts b/src/platform/__tests__/private-path.test.ts index 9ba7d0a03..792f32988 100644 --- a/src/platform/__tests__/private-path.test.ts +++ b/src/platform/__tests__/private-path.test.ts @@ -6,18 +6,37 @@ import { join } from 'node:path' import childProcess from 'node:child_process' import fs from 'node:fs' import { syncBuiltinESMExports } from 'node:module' -import { PRIVATE_PATH_ACL_SCRIPT, ensurePrivateDirectory, protectPrivatePath } from '../private-path.js' +import { PRIVATE_PATH_ACL_SCRIPT, ensurePrivateDirectory, normalizeAclSddlForComparison, protectPrivatePath } from '../private-path.js' import { privateAclAssertions, unsafeAclFixture, withoutAuditPrivilege } from './windows-acl-fixture.js' test('private paths only persist modified DACLs, keeping owner/group/audit untouched', () => { assert.doesNotMatch(PRIVATE_PATH_ACL_SCRIPT, /Set-Acl|SetOwner|SetGroup|SetAuditRule|SetAuditRuleProtection/) assert.match(PRIVATE_PATH_ACL_SCRIPT, /SetAccessRuleProtection\(\$true, \$false\)/) assert.match(PRIVATE_PATH_ACL_SCRIPT, /-notcontains \$current\.GetOwner\(\[Security\.Principal\.SecurityIdentifier\]\)\.Value/) - assert.match(PRIVATE_PATH_ACL_SCRIPT, /GetSecurityDescriptorSddlForm\('Access'\) -ne \$acl\.GetSecurityDescriptorSddlForm\('Access'\)/) + // issue #450: SDDL 比较前先归一化继承标记(D:PAI -> D:P),只差 AI 标记时判为已收敛 + assert.match(PRIVATE_PATH_ACL_SCRIPT, /-replace '\^D:PAI', 'D:P'/) + assert.match(PRIVATE_PATH_ACL_SCRIPT, /\$curSddl = \$current\.GetSecurityDescriptorSddlForm\('Access'\) -replace/) + assert.match(PRIVATE_PATH_ACL_SCRIPT, /if \(\$curSddl -ne \$newSddl\)/) assert.match(PRIVATE_PATH_ACL_SCRIPT, /\[IO\.Directory\]::SetAccessControl\(\$item\.FullName, \$acl\)/) assert.match(PRIVATE_PATH_ACL_SCRIPT, /\[IO\.File\]::SetAccessControl\(\$item\.FullName, \$acl\)/) }) +test('issue #450: SDDL normalization treats AI-marker-only drift as converged', () => { + const ace = '(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)' + // 报告者的实测用例:只差 AI 前缀 → 判一致(不再触发全树重写) + assert.equal(normalizeAclSddlForComparison(`D:PAI${ace}`), `D:P${ace}`) + assert.equal(normalizeAclSddlForComparison(`D:P${ace}`), `D:P${ace}`) + // 未设保护(D:AI)仍与目标 D:P 不同 → 照常写入加固 + assert.notEqual(normalizeAclSddlForComparison(`D:AI${ace}`), `D:P${ace}`) + // ACE 集合不同 → 照常写入 + assert.notEqual( + normalizeAclSddlForComparison(`D:PAI(A;OICI;FA;;;SY)`), + `D:P(A;OICI;FA;;;BA)`, + ) + // 非 D: 开头的输入原样返回 + assert.equal(normalizeAclSddlForComparison(''), '') +}) + test('Windows private-path caller sends the DACL-only script and retains failure diagnostics', () => { const root = mkdtempSync(join(tmpdir(), 'private-path-caller-')) const original = childProcess.execFileSync diff --git a/src/platform/private-path.ts b/src/platform/private-path.ts index 90e8a0856..3ad4a3a51 100644 --- a/src/platform/private-path.ts +++ b/src/platform/private-path.ts @@ -19,12 +19,30 @@ foreach ($sid in @($user.Value, 'S-1-5-18', 'S-1-5-32-544')) { } $current = $item.GetAccessControl() if (@($user.Value, 'S-1-5-18', 'S-1-5-32-544') -notcontains $current.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw 'Untrusted storage owner is unsafe' } -if ($current.GetSecurityDescriptorSddlForm('Access') -ne $acl.GetSecurityDescriptorSddlForm('Access')) { +# issue #450: normalize the SDDL before comparing (see normalizeAclSddlForComparison). +# Windows may read the DACL back as D:PAI(...) while the constructed target is +# D:P(...); with P (no parent inheritance) already set, AI is only a provenance +# marker and the effective grants are identical. Without normalization the two +# are forever judged "different", causing a tens-of-seconds full-subtree +# rewrite on every startup. +$curSddl = $current.GetSecurityDescriptorSddlForm('Access') -replace '^D:PAI', 'D:P' +$newSddl = $acl.GetSecurityDescriptorSddlForm('Access') +if ($curSddl -ne $newSddl) { if ($item.PSIsContainer) { [IO.Directory]::SetAccessControl($item.FullName, $acl) } else { [IO.File]::SetAccessControl($item.FullName, $acl) } } ` +/** + * issue #450: SDDL 语义归一化(与 PRIVATE_PATH_ACL_SCRIPT 内的 PowerShell + * `-replace '^D:PAI', 'D:P'` 同规则)。读回的 DACL 头带 `AI`(自动继承标记, + * 如 `D:PAI(...)`)而目标是 `D:P(...)` 时,有效权限完全相同,应判为已收敛, + * 避免每次启动都在整棵子树上重跑数十秒的无效写入。 + */ +export function normalizeAclSddlForComparison(sddl: string): string { + return sddl.replace(/^D:PAI/, 'D:P') +} + export function protectPrivatePath(path: string, platform = process.platform): void { if (lstatSync(path).isSymbolicLink()) throw new Error('Private storage cannot be a symbolic link') if (platform !== 'win32') { diff --git a/src/server/__tests__/serve-acl-hardening.test.ts b/src/server/__tests__/serve-acl-hardening.test.ts new file mode 100644 index 000000000..50ade101b --- /dev/null +++ b/src/server/__tests__/serve-acl-hardening.test.ts @@ -0,0 +1,44 @@ +/** + * issue #450:sidecar 启动期 ACL 加固超时/失败时必须降级放行(warn + 继续), + * 不能一票否决本地服务启动。 + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { hardenServeDataDir } from '../serve.js' +import { setServerLogger, resetServerLogger } from '../logger.js' + +function captureWarns() { + const warns: string[] = [] + setServerLogger({ + info() {}, + warn: (message: string) => { warns.push(message) }, + error() {}, + }) + return warns +} + +test('hardenServeDataDir: 加固成功时静默通过', (t) => { + const warns = captureWarns() + t.after(() => resetServerLogger()) + let calledWith = '' + hardenServeDataDir('/fake/data', (dir) => { calledWith = dir }) + assert.equal(calledWith, '/fake/data') + assert.equal(warns.length, 0) +}) + +test('hardenServeDataDir: 加固抛错时降级 warn、不向上传播', (t) => { + const warns = captureWarns() + t.after(() => resetServerLogger()) + assert.doesNotThrow(() => + hardenServeDataDir('/fake/data', () => { throw new Error('spawnSync powershell.exe ETIMEDOUT') }), + ) + assert.equal(warns.length, 1) + assert.match(warns[0]!, /#450/) + assert.match(warns[0]!, /ETIMEDOUT/) +}) + +test('hardenServeDataDir: 默认走真实 ensurePrivateDirectory(签名兼容)', () => { + // 只验证默认参数存在且可调用形状正确;真实加固的跨平台行为由 + // src/platform/__tests__/private-path.test.ts 覆盖。 + assert.equal(hardenServeDataDir.length, 1) +}) diff --git a/src/server/serve.ts b/src/server/serve.ts index b2d125da1..0964b8ed3 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -667,6 +667,19 @@ const DEFAULT_PORT = 3100 */ let storeLockDegraded = false +/** + * issue #450: 启动期数据目录权限加固。数据目录膨胀后(数万文件)Windows ACL + * 加固可能超时或失败——加固只是纵深防御的一层,失败时降级记录 warn 后继续 + * 启动,不让它一票否决本地服务。 + */ +export function hardenServeDataDir(dir: string, ensureDir: (dir: string) => void = ensurePrivateDirectory): void { + try { + ensureDir(dir) + } catch (err) { + serverLogger.warn(`[serve] 数据目录权限加固失败,降级继续启动 (#450): ${err instanceof Error ? err.message : String(err)}`) + } +} + /** * Start the runtime API server. Returns the bound port, a close() that aborts * all in-flight work, and the RuntimeSessionManager backing the multi-session @@ -685,7 +698,7 @@ export async function runServe(opts: RunServeOptions = {}): Promise env(桌面壳经父 env 继承 // RIVET_SERVE_HOST 可达,零 Rust 改动)> 默认 127.0.0.1(行为不变)。