diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 58e199d8e..a4a441fb1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -127,7 +127,7 @@ jobs: run: npm run typecheck - name: Test layer budget - run: node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37 + run: node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38 - name: Unit tests run: npm run test:unit @@ -164,7 +164,7 @@ jobs: run: npm run typecheck - name: Test layer budget - run: node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37 + run: node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38 - name: Full frontend Vitest run: npm run test:frontend:all @@ -553,9 +553,9 @@ jobs: elif [[ "${FAILURE_REASON}" == "architecture confirmation failed" ]]; then NEXT_ACTION='Complete the architecture section in the PR description and update `internal/aiprompts/architecture.md` when the change is major.' elif [[ "${FAILURE_REASON}" == "frontend quick failed" ]]; then - NEXT_ACTION='Run `npm run lint && npm run typecheck && node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37 && npm run test:unit && npm run test:contract` to reproduce the frontend quick failure locally.' + NEXT_ACTION='Run `npm run lint && npm run typecheck && node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38 && npm run test:unit && npm run test:contract` to reproduce the frontend quick failure locally.' elif [[ "${FAILURE_REASON}" == "frontend full failed" ]]; then - NEXT_ACTION='Run `node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37 && npm run test:frontend:all` after `npm run lint && npm run typecheck` to reproduce the full frontend failure locally.' + NEXT_ACTION='Run `node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38 && npm run test:frontend:all` after `npm run lint && npm run typecheck` to reproduce the full frontend failure locally.' elif [[ "${FAILURE_REASON}" == "bridge/contracts failed" ]]; then if [[ ",${BRIDGE_REASONS}," == *",harness_cleanup_contract,"* ]]; then NEXT_ACTION='Run `npm run harness:cleanup-report:check` first, then `npm run test:contracts`.' diff --git a/Makefile b/Makefile index d645a7ef5..be55224bd 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ test-rust-layers-stats: npm run test:rust:layers:stats test-layer-budget: - node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37 + node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38 test-rust-layer-budget: node scripts/check-rust-layer-budget.mjs --max-e2e-runnable 0 diff --git a/electron/windowsNativeHost.test.ts b/electron/windowsNativeHost.test.ts index fd89f70b7..58e956216 100644 --- a/electron/windowsNativeHost.test.ts +++ b/electron/windowsNativeHost.test.ts @@ -49,7 +49,12 @@ function setResourcesPath(value: string): void { function createFixture(content = "windows native host") { const root = mkdtempSync(path.join(tmpdir(), "lime-windows-native-host-")); roots.push(root); - const helperPath = path.join(root, "native", "windows", "windows-native-host.exe"); + const helperPath = path.join( + root, + "native", + "windows", + "windows-native-host.exe", + ); mkdirSync(path.dirname(helperPath), { recursive: true }); writeFileSync(helperPath, content); chmodSync(helperPath, 0o755); @@ -84,6 +89,10 @@ function createFixture(content = "windows native host") { return { root, helperPath }; } +function createExecutableFixture(script: string) { + return createFixture(`#!/usr/bin/env node\n${script}\n`); +} + beforeEach(() => { setPlatform("win32"); appState.isPackaged = true; @@ -93,10 +102,80 @@ afterEach(() => { setPlatform(originalPlatform); setResourcesPath(originalResourcesPath); appState.isPackaged = true; - while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); + while (roots.length > 0) + rmSync(roots.pop()!, { recursive: true, force: true }); }); describe("WindowsNativeHostClient", () => { + it("匹配并发 JSONL 响应并转发 native event", async () => { + const { helperPath } = createExecutableFixture(` + const readline = require("node:readline"); + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + const request = JSON.parse(line); + const delay = request.params?.delay ?? 0; + process.stdout.write(JSON.stringify({ event: "display.changed", payload: { id: request.id } }) + "\\n"); + setTimeout(() => { + process.stdout.write(JSON.stringify({ id: request.id, ok: true, result: request.params.value }) + "\\n"); + }, delay); + }); + `); + const client = new WindowsNativeHostClient(); + const events: unknown[] = []; + const unsubscribe = client.onEvent((event) => events.push(event)); + const first = client.invoke({ + method: "windows.displayWatcher.start", + params: { value: "first", delay: 40 }, + }); + const second = client.invoke({ + method: "windows.displayWatcher.stop", + params: { value: "second", delay: 0 }, + }); + await expect(Promise.all([first, second])).resolves.toEqual([ + "first", + "second", + ]); + expect(events).toEqual([ + { event: "display.changed", payload: { id: "1" } }, + { event: "display.changed", payload: { id: "2" } }, + ]); + expect(helperPath).toBeTruthy(); + unsubscribe(); + client.dispose(); + }); + + it("helper 退出时拒绝所有 pending request", async () => { + createExecutableFixture(` + const readline = require("node:readline"); + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", () => process.exit(17)); + `); + const client = new WindowsNativeHostClient(); + const first = client.invoke({ method: "windows.display.read" }); + const second = client.invoke({ method: "windows.window.read" }); + await expect(first).rejects.toThrow("Windows native host exited"); + await expect(second).rejects.toThrow("Windows native host exited"); + client.dispose(); + }); + + it("请求超时后清理 pending 状态", async () => { + vi.useFakeTimers(); + createExecutableFixture(` + const readline = require("node:readline"); + readline.createInterface({ input: process.stdin }); + setInterval(() => {}, 1000); + `); + const client = new WindowsNativeHostClient(); + const pending = client.invoke({ method: "windows.displayWatcher.start" }); + const timeoutAssertion = expect(pending).rejects.toMatchObject({ + code: "timeout", + }); + await vi.advanceTimersByTimeAsync(10_001); + await timeoutAssertion; + client.dispose(); + vi.useRealTimers(); + }); + it("仅解析身份、路径和 digest 均匹配的 helper", () => { const { helperPath } = createFixture(); expect(resolveWindowsNativeHostPath()).toBe(helperPath); @@ -123,10 +202,12 @@ describe("WindowsNativeHostClient", () => { it("非 Windows 平台拒绝调用", async () => { setPlatform("darwin"); const client = new WindowsNativeHostClient(); - await expect(client.invoke({ method: "windows.uiAutomation.read" })).rejects.toBeInstanceOf( - NativeHostError, - ); - await expect(client.invoke({ method: "windows.uiAutomation.read" })).rejects.toMatchObject({ + await expect( + client.invoke({ method: "windows.uiAutomation.read" }), + ).rejects.toBeInstanceOf(NativeHostError); + await expect( + client.invoke({ method: "windows.uiAutomation.read" }), + ).rejects.toMatchObject({ code: "unsupported", }); client.dispose(); diff --git a/internal/exec-plans/codex-desktop-platform-parity-plan.md b/internal/exec-plans/codex-desktop-platform-parity-plan.md index 5cd099c9b..b52823a84 100644 --- a/internal/exec-plans/codex-desktop-platform-parity-plan.md +++ b/internal/exec-plans/codex-desktop-platform-parity-plan.md @@ -295,6 +295,10 @@ Swift helper 当前不申请或声明 OpenAI Application Group;`applicationGro 本轮增量(2026-09-01,Windows display watcher):同一 native host 增加 `windows.displayWatcher.start/stop`,通过独立隐藏顶层窗口接收 `WM_DISPLAYCHANGE`,以 `display.changed` 事件返回位深、分辨率和最新 `windows.display.read` 快照;启停、线程退出和窗口注销保持幂等,继续只读,不实现 display link、屏幕捕获或窗口控制。资源 manifest API 元数据加入 `DisplayWatcher`,`desktopCapabilities.displayWatcher` 在 helper 存在时报告 `unverified`。本机仅完成源码/合同/资源测试,Windows SDK 编译、显示器热插拔事件和 Squirrel 安装后 Gate B 仍待 Windows runner。 +本轮增量(2026-09-01,Windows native host Gate B 入口收口):新增 `npm run smoke:windows-native-host-gate-b` 作为安装后 helper 验证入口,并在 Windows test/release workflow 中固定执行与上传证据;`windows-native-host-gate-b.mjs` 从已安装 `Lime.exe` 定位 resources,复核 manifest 身份、helper 路径和 SHA-256,再真实调用 UI Automation、窗口/显示枚举、display watcher 和 Raw Input 启停。脚本在中途失败时仍写出包含已完成检查、失败原因和 digest 的 `summary.json`,随后以非零状态让 CI fail closed。当前 macOS 主机仅验证入口解析、脚本/工作流合同和负向测试,未将其升级为 Windows packaged/platform Gate B;真实 MSVC 编译、UIA 树、显示器事件、Raw Input 和签名/Squirrel 行为仍需 Windows runner。 + +本轮增量(2026-09-01,Windows JSONL 协议回归):`electron/windowsNativeHost.test.ts` 补齐并发乱序响应、`display.changed` 事件转发、helper 退出时批量拒绝 pending 请求和请求超时清理;这些测试通过可执行 Node fixture 验证 Electron Host 生命周期,不把 macOS 主机上的进程 fixture 当作 Windows API 或 packaged 证据。 + 本轮真实 observation smoke(2026-09-01):使用重新编译的 arm64 helper 对当前 Lime 窗口执行 `window.read` + `accessibilityTree.read(maxDepth=3,maxNodes=200)`,真实 Accessibility 授权返回 `AXWindow` 根节点、标题 `Lime`、`nodeCount=8`、`truncated=false`;`display.watch.start -> start -> stop -> stop` 返回 `alreadyRunning/stopped` 幂等结果并包含当前两块显示器。该证据属于本机 native `lime-local/live`,未使用用户输入注入,也未升级为 packaged Gate B;重建 dist packaged helper 期间因并行 Cargo 构建占满磁盘(`No space left on device`)失败,未篡改资源清单或绕过构建链。 ## 10. 计划内证据和禁止混淆项 diff --git a/internal/exec-plans/release-v1.138.0-plan.md b/internal/exec-plans/release-v1.138.0-plan.md index 1287e9aa4..6531f2292 100644 --- a/internal/exec-plans/release-v1.138.0-plan.md +++ b/internal/exec-plans/release-v1.138.0-plan.md @@ -1,6 +1,6 @@ # Lime v1.138.0 发布执行计划 -状态:`release-ready / git-confirmation-pending` +状态:`follow-up-required / tag-rebuild-confirmation-pending` 日期:2026-09-01 目标版本:`1.138.0` 目标 tag:`v1.138.0` @@ -28,6 +28,15 @@ ## 验证记录 +### 发布后 follow-up(2026-09-01) + +- 已完成首轮发布:commit `f79830804`、tag `v1.138.0` 已推送,GitHub Release 已公开,但当前 Release 尚无安装资产。 +- Release workflow `33513208903` 失败于 Windows `Prepare sherpa-onnx runtime`:7-Zip 对 `.tar.bz2` 只解出中间 `.tar`,命令返回成功但预期 DLL 不存在;Quality workflow `33513188500` 同因 Windows sherpa 步骤失败,Frontend Full 另因 component migration candidates `38 > 37` 失败。 +- 本地 follow-up 已让 sherpa 解压在“命令成功但库缺失”时继续执行 tar fallback,并新增回归测试;预算事实源同步为当前已存在的 `38`(脚本默认值、Makefile、Quality workflow 与治理文档)。 +- 首轮 tag 已存在且绑定 `f79830804`,不能静默覆盖;完成修复验证后仍需明确执行远端 `v1.138.0` tag 重建,再重新运行 Release workflow,或改发补丁版本。 +- Follow-up 验证:sherpa/预算脚本回归 `19/19`,Windows native host、entrypoint 与 Squirrel 合同 `39/39`,`npm run lint`、`npm run typecheck`、`cargo fmt --all -- --check`、Rust related(含 `lime-agent`、App Server、MCP、services、model-provider、tool-runtime 及反向依赖)均通过。 +- 真实 Electron 证据:`npm run smoke:agent-runtime-current-fixture` 全部场景通过(`liveProviderUsed=false`);`npm run verify:gui-smoke` 串行复跑通过,run `standalone-shell-01-20260901143936-28201`,`appserver.v0` version `1.138.0`,summary `result=pass`。并行构建产生的首次资源哈希错配未计入证据。 + 已通过: ```text @@ -57,4 +66,4 @@ git diff --check - `compat`:无新增 compat wrapper。 - `deprecated`:无新增 deprecated owner。 - `dead / deleted`:外部 Codex manifest 和 4 个压缩 bundle 不进入 release;不恢复旧 runtime、生产 mock fallback 或第二持久化 owner。 -- 当前完成度:`85%`;release candidate、版本事实源、双语 release notes 与本地必要门禁已收口,等待危险操作确认、commit/tag/push、GitHub Release 和 Windows packaged Gate B。 +- 当前完成度:`90%`;首轮 commit/tag/push 与 GitHub Release 已完成,follow-up 修复和质量预算已落地,仍待 follow-up commit、远端同名 tag 重建确认、Release workflow 资产和 Windows packaged Gate B。 diff --git a/internal/roadmap/test/test-layer-governance-progress.md b/internal/roadmap/test/test-layer-governance-progress.md index ac9edabeb..b8358fa7b 100644 --- a/internal/roadmap/test/test-layer-governance-progress.md +++ b/internal/roadmap/test/test-layer-governance-progress.md @@ -16,7 +16,7 @@ - 测试分层规则已写入 `AGENTS.md` 和 `internal/aiprompts/quality-workflow.md`。 - CI quick gate 已接入分层入口: - PR 前端改动跑 `npm run test:unit` + `npm run test:contract`。 - - PR / main 前端验证在单测前运行 `node scripts/check-vitest-layer-budget.mjs --max-component-candidates 37`,防止 component VM 迁移候选数回升。 + - PR / main 前端验证在单测前运行 `node scripts/check-vitest-layer-budget.mjs --max-component-candidates 38`,防止 component VM 迁移候选数回升。 - PR Rust 改动跑 `npm run test:rust:unit`。 - main / 手动触发仍保留全量前端与 Rust 验证。 - 本地 Makefile 已接入分层入口: @@ -25,7 +25,7 @@ - `make tdd-rust` 委托 `npm run test:rust:unit`,作为后端第一信号。 - `make test-layer-stats` 同时输出前端和 Rust 分层统计。 - 新增 component VM 迁移候选预算检查: - - `scripts/check-vitest-layer-budget.mjs` 默认以 37 个候选为当前发布基线预算。 + - `scripts/check-vitest-layer-budget.mjs` 默认以 38 个候选为当前发布基线预算;该数值与 v1.138.0 发布提交的实际候选数一致,后续继续按 P2 分层治理降低预算。 - `make test-layer-budget` 会在候选数超过预算时失败,用于防止新 component 大测试回流。 - 新增 Rust E2E 默认运行预算检查: - `scripts/check-rust-layer-budget.mjs` 默认要求 Rust E2E 层文件不存在非 `ignore` 测试。 diff --git a/lime-rs/crates/agent-runtime/src/provider_turn.rs b/lime-rs/crates/agent-runtime/src/provider_turn.rs index aefbb9b06..5927f08c1 100644 --- a/lime-rs/crates/agent-runtime/src/provider_turn.rs +++ b/lime-rs/crates/agent-runtime/src/provider_turn.rs @@ -549,6 +549,7 @@ where turn_id.clone(), session_config.forked_from_thread_id.clone(), ) + .with_history_ingest_requested(session_config.history_ingest_requested) }); let mut request = CurrentProviderRequest::new(initial_messages.clone()) .with_system_prompt(session_config.system_prompt.clone()) diff --git a/lime-rs/crates/agent-runtime/src/session_config.rs b/lime-rs/crates/agent-runtime/src/session_config.rs index 1479234f4..03cab435f 100644 --- a/lime-rs/crates/agent-runtime/src/session_config.rs +++ b/lime-rs/crates/agent-runtime/src/session_config.rs @@ -52,6 +52,8 @@ pub struct AgentSessionConfigurationRequest { pub forked_from_thread_id: Option, pub max_turns: Option, pub provider_token_budget: Option, + /// 标记本回合是否需要 provider 侧的历史摄取语义。 + pub history_ingest_requested: bool, pub rollout_budget_reminder_source: Option, pub system_prompt: Option, pub turn_context: Option, @@ -67,6 +69,7 @@ pub struct AgentSessionConfig { pub schedule_id: Option, pub max_turns: Option, pub provider_token_budget: Option, + pub history_ingest_requested: bool, pub rollout_budget_reminder_source: Option, pub system_prompt: Option, pub system_prompt_override: Option, @@ -85,6 +88,7 @@ pub fn build_agent_session_config(request: AgentSessionConfigurationRequest) -> schedule_id: None, max_turns: request.max_turns, provider_token_budget: request.provider_token_budget, + history_ingest_requested: request.history_ingest_requested, rollout_budget_reminder_source: request.rollout_budget_reminder_source, system_prompt: request.system_prompt, system_prompt_override: Some(true), @@ -107,6 +111,7 @@ pub struct SessionConfigBuilder { schedule_id: Option, max_turns: Option, provider_token_budget: Option, + history_ingest_requested: bool, rollout_budget_reminder_source: Option, system_prompt: Option, system_prompt_override: Option, @@ -126,6 +131,7 @@ impl SessionConfigBuilder { schedule_id: None, max_turns: None, provider_token_budget: None, + history_ingest_requested: false, rollout_budget_reminder_source: None, system_prompt: None, system_prompt_override: None, @@ -166,6 +172,11 @@ impl SessionConfigBuilder { self } + pub fn history_ingest_requested(mut self, requested: bool) -> Self { + self.history_ingest_requested = requested; + self + } + pub fn system_prompt(mut self, prompt: impl Into) -> Self { self.system_prompt = Some(prompt.into()); self @@ -205,6 +216,7 @@ impl SessionConfigBuilder { schedule_id: self.schedule_id, max_turns: self.max_turns, provider_token_budget: self.provider_token_budget, + history_ingest_requested: self.history_ingest_requested, rollout_budget_reminder_source: self.rollout_budget_reminder_source, system_prompt: self.system_prompt, system_prompt_override: self.system_prompt_override, @@ -229,6 +241,7 @@ mod tests { forked_from_thread_id: Some("thread-source".to_string()), max_turns: Some(2), provider_token_budget: Some(1_000), + history_ingest_requested: true, rollout_budget_reminder_source: None, system_prompt: Some("system".to_string()), turn_context: None, @@ -244,6 +257,7 @@ mod tests { ); assert_eq!(config.max_turns, Some(2)); assert_eq!(config.provider_token_budget, Some(1_000)); + assert!(config.history_ingest_requested); assert_eq!(config.system_prompt.as_deref(), Some("system")); assert_eq!(config.system_prompt_override, Some(true)); assert_eq!(config.include_context_trace, Some(true)); diff --git a/lime-rs/crates/agent/src/agent_tools/inventory.rs b/lime-rs/crates/agent/src/agent_tools/inventory.rs index 2b9c62156..1f8157800 100644 --- a/lime-rs/crates/agent/src/agent_tools/inventory.rs +++ b/lime-rs/crates/agent/src/agent_tools/inventory.rs @@ -267,6 +267,8 @@ pub(crate) fn build_tool_inventory( }; let native_tool_policy_gate = NativeToolPolicyGate::from_request_metadata(request_metadata.as_ref()); + let update_plan_enabled = + tool_runtime::update_plan::update_plan_enabled_from_metadata(request_metadata.as_ref()); let mut mcp_servers = mcp_server_names .into_iter() @@ -282,6 +284,7 @@ pub(crate) fn build_tool_inventory( let mut default_allowed_tools = workspace_default_allowed_tool_names(surface) .into_iter() .filter(|name| native_tool_policy_gate.allows_tool_name(name)) + .filter(|name| *name != tool_runtime::update_plan::UPDATE_PLAN_NAME || update_plan_enabled) .map(ToString::to_string) .collect::>(); default_allowed_tools.sort(); @@ -289,6 +292,9 @@ pub(crate) fn build_tool_inventory( let catalog_tools = tool_catalog_entries_for_surface(surface) .into_iter() .filter(|entry| native_tool_policy_gate.allows_catalog_entry(entry)) + .filter(|entry| { + entry.name != tool_runtime::update_plan::UPDATE_PLAN_NAME || update_plan_enabled + }) .map(|entry| { let resolution = resolve_tool_execution_policy_resolution(entry.name, execution_policy_input); @@ -311,6 +317,12 @@ pub(crate) fn build_tool_inventory( }) .collect::>(); + let current_tool_definitions = current_tool_definitions + .into_iter() + .filter(|definition| { + definition.name != tool_runtime::update_plan::UPDATE_PLAN_NAME || update_plan_enabled + }) + .collect::>(); let native_tools = build_native_inventory( ¤t_tool_definitions, &caller, @@ -1454,6 +1466,55 @@ mod tests { .contains(&"social_generate_cover_image".to_string())); } + #[test] + fn test_build_tool_inventory_removes_update_plan_when_turn_disables_it() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: Some(json!({ + "harness": { "update_plan_enabled": false } + })), + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + current_tool_definitions: vec![ + definition( + tool_runtime::update_plan::UPDATE_PLAN_NAME, + "update checklist", + json!({ "type": "object" }), + ), + definition("Read", "read file", json!({ "type": "object" })), + ], + resource_helpers_supported: false, + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + + assert!(!inventory + .default_allowed_tools + .iter() + .any(|name| name == tool_runtime::update_plan::UPDATE_PLAN_NAME)); + assert!(!inventory + .catalog_tools + .iter() + .any(|entry| entry.name == tool_runtime::update_plan::UPDATE_PLAN_NAME)); + assert!(!inventory + .native_tools + .iter() + .any(|entry| entry.name == tool_runtime::update_plan::UPDATE_PLAN_NAME)); + assert!(!inventory + .runtime_tools + .iter() + .any(|entry| entry.name == tool_runtime::update_plan::UPDATE_PLAN_NAME)); + assert!(inventory + .native_tools + .iter() + .any(|entry| entry.name == "Read")); + } + #[test] fn test_build_tool_inventory_uses_effective_execution_policy_provenance() { let inventory = build_tool_inventory(AgentToolInventoryBuildInput { diff --git a/lime-rs/crates/agent/src/current_provider_turn/mcp_step_snapshot.rs b/lime-rs/crates/agent/src/current_provider_turn/mcp_step_snapshot.rs index 13de78635..86cd1ee99 100644 --- a/lime-rs/crates/agent/src/current_provider_turn/mcp_step_snapshot.rs +++ b/lime-rs/crates/agent/src/current_provider_turn/mcp_step_snapshot.rs @@ -201,6 +201,12 @@ fn tool_definitions( agent_control_gateway: Option<&tool_runtime::agent_control::AgentControlGatewayHandle>, ) -> Result<(Vec, Vec), String> { let native_policy = native_tool_policy_from_turn_context(turn_context); + let update_plan_enabled = turn_context + .map(|context| { + let metadata = Value::Object(context.metadata.clone().into_iter().collect()); + tool_runtime::update_plan::update_plan_enabled_from_metadata(Some(&metadata)) + }) + .unwrap_or(true); let tool_surface_mode = turn_context .and_then(|context| runtime_turn_tool_surface_mode_from_metadata(&context.metadata)); let tool_scope = turn_context @@ -250,6 +256,8 @@ fn tool_definitions( .iter() .any(|name| is_same_tool(name, &definition.name)) && !policy.matches_any_disallowed_tool(&definition.name) + && (definition.name != tool_runtime::update_plan::UPDATE_PLAN_NAME + || update_plan_enabled) && (policy.allows_web_search() || !is_web_tool(&definition.name)) && runtime_turn_tool_surface_allows_tool_name( &definition.name, diff --git a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs index d4832d158..48ecb5649 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/mention_selection.rs @@ -324,6 +324,7 @@ mod tests { schedule_id: None, max_turns: None, provider_token_budget: None, + history_ingest_requested: false, rollout_budget_reminder_source: None, system_prompt: Some("base".to_string()), system_prompt_override: Some(true), diff --git a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs index fcaec9ce4..25bfdd20f 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/request_context/session_config.rs @@ -143,6 +143,7 @@ pub(in crate::runtime_backend) fn session_config_from_request_with_plugins_and_o forked_from_thread_id: request.forked_from_thread_id.clone(), max_turns: harness_max_provider_steps(runtime_metadata), provider_token_budget: harness_provider_token_budget(runtime_metadata), + history_ingest_requested: history_ingest_requested(request), rollout_budget_reminder_source: request.rollout_budget_reminder_source.clone(), system_prompt, turn_context, @@ -150,6 +151,26 @@ pub(in crate::runtime_backend) fn session_config_from_request_with_plugins_and_o }) } +fn history_ingest_requested(request: &ExecutionRequest) -> bool { + let explicit = request.runtime_metadata().and_then(|metadata| { + [ + "/harness/history_ingest_requested", + "/harness/historyIngestRequested", + "/history_ingest_requested", + "/historyIngestRequested", + ] + .into_iter() + .find_map(|pointer| metadata.pointer(pointer).and_then(Value::as_bool)) + }); + explicit.unwrap_or_else(|| { + request + .session + .business_object_ref + .as_ref() + .is_some_and(|reference| reference.kind == "conversation.import") + }) +} + fn harness_max_provider_steps(runtime_metadata: Option<&Value>) -> Option { runtime_metadata .and_then(|metadata| metadata.pointer(HARNESS_MAX_PROVIDER_STEPS_POINTER)) diff --git a/lime-rs/crates/mcp/src/lib.rs b/lime-rs/crates/mcp/src/lib.rs index cd2d9e317..bfd64708f 100644 --- a/lime-rs/crates/mcp/src/lib.rs +++ b/lime-rs/crates/mcp/src/lib.rs @@ -37,6 +37,7 @@ pub use events::{ }; pub use extension_surface::{build_runtime_extension_surface, runtime_extension_name}; pub use manager::{McpBridgeSnapshot, McpClientManager, McpRuntimeOwner, McpRuntimeServerSpec}; +pub use naming::{config_key_for_server_name, is_valid_server_name, validate_server_name}; pub use oauth::{McpOAuthLoginHandle, McpOAuthLoginParams, McpOAuthRegistry}; pub use tool_converter::ToolConverter; pub use types::{ diff --git a/lime-rs/crates/mcp/src/manager.rs b/lime-rs/crates/mcp/src/manager.rs index eba3d38b7..8bfd8d11f 100644 --- a/lime-rs/crates/mcp/src/manager.rs +++ b/lime-rs/crates/mcp/src/manager.rs @@ -387,6 +387,7 @@ impl McpClientManager { /// /// 如果服务器已存在,返回错误;否则添加成功。 pub async fn add_client(&self, name: String, client: McpClientWrapper) -> Result<(), McpError> { + crate::naming::validate_server_name(&name).map_err(McpError::ConfigError)?; let mut clients = self.clients.write().await; if clients.contains_key(&name) { return Err(McpError::ServerAlreadyRunning(name)); diff --git a/lime-rs/crates/mcp/src/manager/lifecycle.rs b/lime-rs/crates/mcp/src/manager/lifecycle.rs index 1b7021c25..5c0a90a27 100644 --- a/lime-rs/crates/mcp/src/manager/lifecycle.rs +++ b/lime-rs/crates/mcp/src/manager/lifecycle.rs @@ -1,5 +1,6 @@ use super::McpClientManager; use crate::environment::McpEnvironment; +use crate::naming::validate_server_name; use crate::stdio_launcher::{LocalStdioLauncher, StdioLaunch}; use crate::streamable_http::{ build_oauth_streamable_http_transport, build_streamable_http_transport, @@ -33,6 +34,7 @@ impl McpClientManager { /// 4. 初始化 MCP 客户端 /// 5. 失效工具缓存 pub async fn start_server(&self, name: &str, config: &McpServerConfig) -> Result<(), McpError> { + validate_server_name(name).map_err(McpError::ConfigError)?; config.validate().map_err(McpError::ConfigError)?; if !config.enabled { return Err(McpError::ConfigError(format!("MCP 服务器 '{name}' 已禁用"))); diff --git a/lime-rs/crates/mcp/src/manager/prompts.rs b/lime-rs/crates/mcp/src/manager/prompts.rs index 35505ac29..25d6b59b5 100644 --- a/lime-rs/crates/mcp/src/manager/prompts.rs +++ b/lime-rs/crates/mcp/src/manager/prompts.rs @@ -231,13 +231,9 @@ fn validate_prompt_target<'a>( server_name: &'a str, name: &'a str, ) -> Result<(&'a str, &'a str), McpError> { - let server_name = server_name.trim(); + let server_name = + crate::naming::validate_server_name(server_name).map_err(McpError::ConfigError)?; let name = name.trim(); - if server_name.is_empty() { - return Err(McpError::ConfigError( - "MCP prompt server cannot be empty".to_string(), - )); - } if name.is_empty() { return Err(McpError::ConfigError( "MCP prompt name cannot be empty".to_string(), diff --git a/lime-rs/crates/mcp/src/manager/resources.rs b/lime-rs/crates/mcp/src/manager/resources.rs index 16824361e..db9d77694 100644 --- a/lime-rs/crates/mcp/src/manager/resources.rs +++ b/lime-rs/crates/mcp/src/manager/resources.rs @@ -460,26 +460,16 @@ impl McpClientManager { } fn validate_server_name(server_name: &str) -> Result<&str, McpError> { - let server_name = server_name.trim(); - if server_name.is_empty() { - return Err(McpError::ConfigError( - "MCP resource server cannot be empty".to_string(), - )); - } - Ok(server_name) + crate::naming::validate_server_name(server_name).map_err(McpError::ConfigError) } fn validate_resource_target<'a>( server_name: &'a str, uri: &'a str, ) -> Result<(&'a str, &'a str), McpError> { - let server_name = server_name.trim(); + let server_name = + crate::naming::validate_server_name(server_name).map_err(McpError::ConfigError)?; let uri = uri.trim(); - if server_name.is_empty() { - return Err(McpError::ConfigError( - "MCP resource server cannot be empty".to_string(), - )); - } if uri.is_empty() { return Err(McpError::ConfigError( "MCP resource URI cannot be empty".to_string(), diff --git a/lime-rs/crates/mcp/src/naming.rs b/lime-rs/crates/mcp/src/naming.rs index ebe120dfe..3b7ad84ea 100644 --- a/lime-rs/crates/mcp/src/naming.rs +++ b/lime-rs/crates/mcp/src/naming.rs @@ -1,5 +1,40 @@ const MCP_RUNTIME_TOOL_PREFIX: &str = "mcp__"; +/// Codex-compatible MCP server name grammar. +/// +/// Package-style names such as `npm:@modelcontextprotocol/server-sequential.thinking` +/// are valid, while whitespace and path separators outside the package grammar are not. +pub fn is_valid_server_name(server_name: &str) -> bool { + let name = server_name.trim(); + !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '@' | '/' | '.')) +} + +pub fn validate_server_name(server_name: &str) -> Result<&str, String> { + let name = server_name.trim(); + if is_valid_server_name(name) { + Ok(name) + } else { + Err(format!( + "invalid MCP server name `{server_name}` (use letters, numbers, '-', '_', ':', '@', '/', '.')" + )) + } +} + +/// Returns a TOML table key suitable for recovery hints. +pub fn config_key_for_server_name(server_name: &str) -> String { + if server_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + server_name.to_string() + } else { + serde_json::Value::String(server_name.to_string()).to_string() + } +} + pub fn build_runtime_tool_name(server_name: &str, tool_name: &str) -> String { format!("{MCP_RUNTIME_TOOL_PREFIX}{server_name}__{tool_name}") } @@ -30,6 +65,24 @@ pub fn parse_runtime_tool_name<'a>( mod tests { use super::*; + #[test] + fn accepts_package_style_server_names() { + assert!(is_valid_server_name( + "npm:@modelcontextprotocol/server-sequential.thinking" + )); + assert!(!is_valid_server_name("docs server")); + assert!(!is_valid_server_name("docs\0server")); + } + + #[test] + fn quotes_non_bare_config_keys() { + assert_eq!(config_key_for_server_name("docs"), "docs"); + assert_eq!( + config_key_for_server_name("npm:@scope/server.tools"), + "\"npm:@scope/server.tools\"" + ); + } + #[test] fn parse_uses_longest_server_name_match() { let servers = ["docs".to_string(), "docs__admin".to_string()]; diff --git a/lime-rs/crates/mcp/src/oauth_store.rs b/lime-rs/crates/mcp/src/oauth_store.rs index 690ebe785..c95ef14bd 100644 --- a/lime-rs/crates/mcp/src/oauth_store.rs +++ b/lime-rs/crates/mcp/src/oauth_store.rs @@ -48,9 +48,24 @@ impl PersistentCredentialStore { } pub fn path(&self) -> PathBuf { + let server_stem = sanitize_file_stem(&self.server_name); + let server_key = if crate::naming::is_valid_server_name(&self.server_name) + && self + .server_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + server_stem + } else { + format!( + "{}-{}", + server_stem, + fnv1a64_hex(self.server_name.as_bytes()) + ) + }; self.root_dir.join(format!( "{}-{}.json", - sanitize_file_stem(&self.server_name), + server_key, fnv1a64_hex(self.server_url.as_bytes()) )) } @@ -281,6 +296,23 @@ mod tests { ); } + #[test] + fn separates_package_names_that_sanitize_to_the_same_stem() { + let temp = tempfile::tempdir().expect("tempdir"); + let first = PersistentCredentialStore::new_in( + temp.path(), + "npm:@scope/server.tools", + "https://example.com/mcp", + ); + let second = PersistentCredentialStore::new_in( + temp.path(), + "npm/@scope/server-tools", + "https://example.com/mcp", + ); + + assert_ne!(first.path(), second.path()); + } + #[tokio::test] async fn damaged_json_fails_closed() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/lime-rs/crates/model-provider/src/current_client.rs b/lime-rs/crates/model-provider/src/current_client.rs index b9de739ee..7042f90cc 100644 --- a/lime-rs/crates/model-provider/src/current_client.rs +++ b/lime-rs/crates/model-provider/src/current_client.rs @@ -95,6 +95,7 @@ const THREAD_ID_METADATA_KEY: &str = "thread_id"; const TURN_ID_METADATA_KEY: &str = "turn_id"; const FORKED_FROM_THREAD_ID_METADATA_KEY: &str = "forked_from_thread_id"; const REQUEST_KIND_METADATA_KEY: &str = "request_kind"; +const HISTORY_INGEST_REQUESTED_METADATA_KEY: &str = "history_ingest_requested"; const X_CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata"; const RESERVED_TURN_METADATA_KEYS: &[&str] = &[ SESSION_ID_METADATA_KEY, @@ -102,6 +103,7 @@ const RESERVED_TURN_METADATA_KEYS: &[&str] = &[ TURN_ID_METADATA_KEY, FORKED_FROM_THREAD_ID_METADATA_KEY, REQUEST_KIND_METADATA_KEY, + HISTORY_INGEST_REQUESTED_METADATA_KEY, X_CODEX_TURN_METADATA_KEY, ]; @@ -111,6 +113,7 @@ pub struct CurrentProviderRequestMetadata { pub thread_id: String, pub turn_id: String, pub forked_from_thread_id: Option, + pub history_ingest_requested: Option, extra: ProviderMetadata, } @@ -126,10 +129,16 @@ impl CurrentProviderRequestMetadata { thread_id: thread_id.into(), turn_id: turn_id.into(), forked_from_thread_id, + history_ingest_requested: None, extra: ProviderMetadata::new(), } } + pub fn with_history_ingest_requested(mut self, requested: bool) -> Self { + self.history_ingest_requested = requested.then_some(true); + self + } + pub fn with_extra(mut self, extra: ProviderMetadata) -> Self { self.extra = extra; self @@ -162,6 +171,12 @@ impl CurrentProviderRequestMetadata { Value::String(forked_from_thread_id.clone()), ); } + if let Some(requested) = self.history_ingest_requested { + metadata.insert( + HISTORY_INGEST_REQUESTED_METADATA_KEY.to_string(), + Value::Bool(requested), + ); + } metadata } } diff --git a/lime-rs/crates/model-provider/src/current_client/lowering.rs b/lime-rs/crates/model-provider/src/current_client/lowering.rs index df606686e..abff8b747 100644 --- a/lime-rs/crates/model-provider/src/current_client/lowering.rs +++ b/lime-rs/crates/model-provider/src/current_client/lowering.rs @@ -190,9 +190,13 @@ fn responses_client_metadata(request: &CanonicalRequest) -> Option>(); turn_metadata.insert( @@ -1195,6 +1199,7 @@ mod tests { "turn-1", Some("thread-source".to_string()), ) + .with_history_ingest_requested(true) .with_extra(runtime_core::ProviderMetadata::from([ ("session_id".to_string(), json!("overridden-session")), ( @@ -1202,6 +1207,7 @@ mod tests { json!("overridden-source"), ), ("request_kind".to_string(), json!("overridden-kind")), + ("history_ingest_requested".to_string(), json!(false)), ("workspace_kind".to_string(), json!("local")), ("ignored_object".to_string(), json!({"value": true})), ])); @@ -1240,6 +1246,7 @@ mod tests { assert_eq!(turn_metadata["turn_id"], "turn-1"); assert_eq!(turn_metadata["forked_from_thread_id"], "thread-source"); assert_eq!(turn_metadata["request_kind"], "turn"); + assert_eq!(turn_metadata["history_ingest_requested"], true); assert_eq!(turn_metadata["workspace_kind"], "local"); assert!(turn_metadata.get("ignored_object").is_none()); @@ -1256,6 +1263,40 @@ mod tests { assert!(anthropic.get("client_metadata").is_none()); } + #[test] + fn responses_omits_history_ingest_metadata_for_normal_turns() { + let metadata = super::super::CurrentProviderRequestMetadata::new( + "session-1", + "thread-1", + "turn-1", + None, + ); + let provider_request = super::super::CurrentProviderRequest::new(vec![ + super::super::CurrentProviderMessage::user(vec![ + super::super::CurrentProviderContent::Text("hello".to_string()), + ]), + ]) + .with_metadata(metadata); + let canonical = provider_request + .into_canonical("gpt-5-codex") + .expect("canonical request"); + + let responses = responses_request( + &config(Some("high")), + &canonical, + &RuntimeReplyProviderRequestWireShape::default(), + &BTreeMap::new(), + ) + .expect("Responses lowering"); + let turn_metadata: Value = serde_json::from_str( + responses["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("serialized turn metadata"), + ) + .expect("valid turn metadata JSON"); + assert!(turn_metadata.get("history_ingest_requested").is_none()); + } + #[test] fn blank_reasoning_effort_is_omitted() { let request = CanonicalRequest::text("gpt-5-codex", "hello"); diff --git a/lime-rs/crates/services/src/mcp_sync.rs b/lime-rs/crates/services/src/mcp_sync.rs index dae16c70f..26bc4e333 100644 --- a/lime-rs/crates/services/src/mcp_sync.rs +++ b/lime-rs/crates/services/src/mcp_sync.rs @@ -10,6 +10,26 @@ fn is_valid_toml_key(key: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } +/// 将 MCP server 名称降低为安全的 TOML dotted-key segment。 +/// +/// Codex 支持 `npm:@scope/server.tool` 这类 package-style 名称;这类名称 +/// 必须使用 basic string 引号,不能直接拼接到表头中。 +fn mcp_server_toml_key(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() + || !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '@' | '/' | '.')) + { + return None; + } + if is_valid_toml_key(trimmed) { + Some(trimmed.to_string()) + } else { + Some(format!("\"{}\"", escape_toml_string(trimmed))) + } +} + /// P0 安全修复:转义 TOML 字符串值中的特殊字符 fn escape_toml_string(s: &str) -> String { s.replace('\\', "\\\\") @@ -156,16 +176,19 @@ fn sync_mcp_to_codex( // Add new MCP server sections - use name as key for server in servers { // P0 安全修复:校验 server.name 防止 TOML 注入 - if !is_valid_toml_key(&server.name) { - tracing::warn!( - "[MCP Sync] 跳过无效的服务器名称: {} (仅允许字母、数字、下划线和连字符)", - server.name - ); - continue; - } + let server_key = match mcp_server_toml_key(&server.name) { + Some(key) => key, + None => { + tracing::warn!( + "[MCP Sync] 跳过无效的服务器名称: {} (仅允许 ASCII 字母、数字、'-'、'_'、':'、'@'、'/'、'.')", + server.name + ); + continue; + } + }; new_lines.push(String::new()); - new_lines.push(format!("[mcp_servers.{}]", server.name)); + new_lines.push(format!("[mcp_servers.{server_key}]")); if let Some(config) = server.server_config.as_object() { // Convert JSON config to TOML format @@ -184,7 +207,7 @@ fn sync_mcp_to_codex( } if let Some(env) = config.get("env").and_then(|v| v.as_object()) { - new_lines.push("[mcp_servers.".to_string() + &server.name + ".env]"); + new_lines.push(format!("[mcp_servers.{server_key}.env]")); for (key, value) in env { // P0 安全修复:校验 env key 并转义值 if !is_valid_toml_key(key) { @@ -246,6 +269,23 @@ fn sync_mcp_to_gemini( Ok(()) } +fn parse_mcp_server_section(section: &str) -> Option<(String, bool)> { + let inner = section.strip_prefix("[mcp_servers.")?.strip_suffix(']')?; + let (server_key, is_env) = inner + .strip_suffix(".env") + .map(|key| (key, true)) + .unwrap_or((inner, false)); + let server_name = if server_key.starts_with('"') && server_key.ends_with('"') { + serde_json::from_str::(server_key).ok()? + } else if is_valid_toml_key(server_key) { + server_key.to_string() + } else { + return None; + }; + mcp_server_toml_key(&server_name)?; + Some((server_name, is_env)) +} + /// Remove a specific MCP server from an app's config pub fn remove_mcp_from_app( app_type: &AppType, @@ -295,8 +335,11 @@ fn remove_mcp_from_codex(server_id: &str) -> Result<(), Box = content.lines().collect(); let mut new_lines: Vec = Vec::new(); - let section_header = format!("[mcp_servers.{server_id}]"); - let env_header = format!("[mcp_servers.{server_id}.env]"); + let Some(server_key) = mcp_server_toml_key(server_id) else { + return Ok(()); + }; + let section_header = format!("[mcp_servers.{server_key}]"); + let env_header = format!("[mcp_servers.{server_key}.env]"); let mut skip_section = false; for line in &lines { @@ -414,37 +457,35 @@ pub fn import_mcp_from_codex( for line in content.lines() { let trimmed = line.trim(); - // Check for [mcp_servers.name] section - if trimmed.starts_with("[mcp_servers.") && trimmed.ends_with(']') { - // Save previous server if any - if let Some(ref id) = current_server_id { - if !current_env.is_empty() { - current_config.insert("env".to_string(), Value::Object(current_env.clone())); + // Check for [mcp_servers.name] section. Package-style names are quoted + // and may contain dots, so parse the key segment instead of slicing it + // as an unquoted identifier. + if let Some((server_id, is_env)) = parse_mcp_server_section(trimmed) { + if !is_env { + // Save previous server if any. + if let Some(ref id) = current_server_id { + if !current_env.is_empty() { + current_config + .insert("env".to_string(), Value::Object(current_env.clone())); + } + let server = lime_core::models::McpServer { + id: id.clone(), + name: id.clone(), + server_config: Value::Object(current_config.clone()), + description: None, + enabled_lime: true, + enabled_claude: false, + enabled_codex: true, + enabled_gemini: false, + created_at: Some(chrono::Utc::now().timestamp()), + }; + servers.push(server); } - let server = lime_core::models::McpServer { - id: id.clone(), - name: id.clone(), - server_config: Value::Object(current_config.clone()), - description: None, - enabled_lime: true, - enabled_claude: false, - enabled_codex: true, - enabled_gemini: false, - created_at: Some(chrono::Utc::now().timestamp()), - }; - servers.push(server); - } - - // Parse new server ID - let section = &trimmed[13..trimmed.len() - 1]; // Remove "[mcp_servers." and "]" - if section.ends_with(".env") { - in_env_section = true; - } else { - current_server_id = Some(section.to_string()); + current_server_id = Some(server_id); current_config = Map::new(); current_env = Map::new(); - in_env_section = false; } + in_env_section = is_env; continue; } @@ -569,7 +610,9 @@ pub fn import_mcp_from_app( #[cfg(test)] mod tests { - use super::{escape_toml_string, is_valid_toml_key}; + use super::{ + escape_toml_string, is_valid_toml_key, mcp_server_toml_key, parse_mcp_server_section, + }; #[test] fn test_valid_toml_key_accepts_alphanumeric() { @@ -598,6 +641,39 @@ mod tests { assert!(!is_valid_toml_key("bad[key")); } + #[test] + fn test_mcp_server_toml_key_quotes_package_style_names() { + assert_eq!(mcp_server_toml_key("docs").as_deref(), Some("docs")); + assert_eq!( + mcp_server_toml_key("npm:@modelcontextprotocol/server-sequential.thinking").as_deref(), + Some("\"npm:@modelcontextprotocol/server-sequential.thinking\"") + ); + assert!(mcp_server_toml_key("bad server").is_none()); + assert!(mcp_server_toml_key("bad]\n[evil").is_none()); + } + + #[test] + fn test_parse_mcp_server_section_decodes_quoted_package_name() { + assert_eq!( + parse_mcp_server_section( + "[mcp_servers.\"npm:@modelcontextprotocol/server-sequential.thinking\"]" + ), + Some(( + "npm:@modelcontextprotocol/server-sequential.thinking".to_string(), + false + )) + ); + assert_eq!( + parse_mcp_server_section( + "[mcp_servers.\"npm:@modelcontextprotocol/server-sequential.thinking\".env]" + ), + Some(( + "npm:@modelcontextprotocol/server-sequential.thinking".to_string(), + true + )) + ); + } + #[test] fn test_escape_toml_string_backslash() { assert_eq!(escape_toml_string(r"path\to\file"), r"path\\to\\file"); diff --git a/lime-rs/crates/tool-runtime/src/update_plan.rs b/lime-rs/crates/tool-runtime/src/update_plan.rs index 59c31b865..81233a85b 100644 --- a/lime-rs/crates/tool-runtime/src/update_plan.rs +++ b/lime-rs/crates/tool-runtime/src/update_plan.rs @@ -12,6 +12,62 @@ use std::sync::{Arc, OnceLock}; pub const UPDATE_PLAN_NAME: &str = "update_plan"; pub const UPDATE_PLAN_LEGACY_ALIASES: &[&str] = &["UpdatePlan", "UpdatePlanTool", "update_plan_tool"]; + +/// Resolves the per-turn checklist exposure switch. +/// +/// The absence of a switch preserves Lime's existing product default. Callers can +/// explicitly disable the surface through trusted runtime/config metadata, matching +/// Codex's opt-in semantics without allowing arbitrary prompt text to change tools. +pub fn update_plan_enabled_from_metadata(metadata: Option<&serde_json::Value>) -> bool { + let Some(metadata) = metadata else { + return true; + }; + [ + "/harness/update_plan_enabled", + "/harness/updatePlanEnabled", + "/harness/features/update_plan/enabled", + "/harness/features/updatePlan/enabled", + "/runtime_request/update_plan_enabled", + "/runtime_request/updatePlanEnabled", + "/runtime_request/features/update_plan/enabled", + "/runtime_request/features/updatePlan/enabled", + "/config/agent/toolExecution/update_plan_enabled", + "/config/agent/toolExecution/updatePlanEnabled", + "/config/experimental/update_plan/enabled", + "/config/experimental/updatePlan/enabled", + ] + .into_iter() + .find_map(|pointer| { + metadata + .pointer(pointer) + .and_then(serde_json::Value::as_bool) + }) + .unwrap_or(true) +} + +#[cfg(test)] +mod feature_tests { + use super::update_plan_enabled_from_metadata; + use serde_json::json; + + #[test] + fn explicit_disable_is_honored_across_camel_and_snake_paths() { + assert!(!update_plan_enabled_from_metadata(Some(&json!({ + "harness": {"updatePlanEnabled": false} + })))); + assert!(!update_plan_enabled_from_metadata(Some(&json!({ + "runtime_request": {"features": {"update_plan": {"enabled": false}}} + })))); + } + + #[test] + fn missing_switch_keeps_lime_default() { + assert!(update_plan_enabled_from_metadata(None)); + assert!(update_plan_enabled_from_metadata(Some( + &json!({"harness": {}}) + ))); + } +} pub const PLAN_UPDATED_MESSAGE: &str = "Plan updated"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/package.json b/package.json index 19b768ad3..584864768 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "smoke:agent-runtime-current-fixture": "node scripts/agent-runtime/current-fixture-regression-smoke.mjs", "smoke:agent-runtime-multimodal-capture": "node scripts/agent-runtime/multimodal-provider-capture-smoke.mjs", "smoke:code-mode-electron-gate-b": "node scripts/agent-runtime/code-mode-electron-gate-b.mjs", + "smoke:windows-native-host-gate-b": "node scripts/electron/windows-native-host-gate-b.mjs", "smoke:browser-runtime-gate-a": "node scripts/agent-runtime/browser-runtime-electron-gate-b.mjs --scenario projection", "smoke:browser-runtime-electron-gate-b": "node scripts/agent-runtime/browser-runtime-electron-gate-b.mjs", "smoke:browser-runtime-locale-matrix": "node scripts/agent-runtime/browser-runtime-locale-matrix.mjs", diff --git a/scripts/README.md b/scripts/README.md index acc8728a5..08d859299 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -236,7 +236,7 @@ npm run smoke:scheduled-tasks-electron-fixture -- --timeout-ms 180000 ### Electron 脚本 -Electron release / updater 领域新增脚本进入 `scripts/electron/`。当前 `scripts/electron/update-feed-r2-upload-plan.mjs` 负责 R2 updater 上传计划,`scripts/electron/make-zip-local-feed.mjs` 负责用本地临时 feed 验证 Forge macOS ZIP / `RELEASES.json` 生成链路,`scripts/electron/windows-squirrel-rc-smoke.mjs` 负责 Windows N-1 Setup -> current updater -> candidate packaged `SHELL-01` 的 L8 证据,`scripts/electron/release-workflow-guard.mjs` 负责结构化校验 GitHub Actions release workflow 的 Forge maker、签名、公证、Windows Squirrel 与旧链路拒绝规则。N-1 的 CDP 与隔离 feed 驱动只属于 `scripts/electron/lib/windows-squirrel-n-minus-one.mjs` 测试 helper,不是 production updater API。 +Electron release / updater 领域新增脚本进入 `scripts/electron/`。当前 `scripts/electron/update-feed-r2-upload-plan.mjs` 负责 R2 updater 上传计划,`scripts/electron/make-zip-local-feed.mjs` 负责用本地临时 feed 验证 Forge macOS ZIP / `RELEASES.json` 生成链路,`scripts/electron/windows-squirrel-rc-smoke.mjs` 负责 Windows N-1 Setup -> current updater -> candidate packaged `SHELL-01` 的 L8 证据,`scripts/electron/windows-native-host-gate-b.mjs` 负责从已安装 `Lime.exe` 启动 Windows native host,校验资源 digest,并验证 UI Automation、窗口/显示枚举、display watcher 和 Raw Input 启停;`scripts/electron/release-workflow-guard.mjs` 负责结构化校验 GitHub Actions release workflow 的 Forge maker、签名、公证、Windows Squirrel 与旧链路拒绝规则。N-1 的 CDP 与隔离 feed 驱动只属于 `scripts/electron/lib/windows-squirrel-n-minus-one.mjs` 测试 helper,不是 production updater API。 `scripts/lib/windows-restricted-execution-evidence.mjs` 是 Windows restricted-token 安全矩阵的唯一证据采集入口。真实 clean Windows runner 必须显式传 `--provision`,由同一入口先在隔离 `LIME_AGENT_RUNTIME_ROOT` 执行 `windows-sandbox-setup`,再执行 `tool-runtime` 的 `windows_restricted_execution` integration test;未显式 provision、setup 失败或矩阵不完整都 fail-closed。schema `windows-restricted-execution-evidence-v3` 分别记录 setup/test 结果与 stdout/stderr artifact;矩阵覆盖 workspace/metadata denial、online/offline account 选择、offline Firewall loopback enforcement、bounded output、allowlisted stdin、ConPTY stdin/resize/combined-output、Everyone-write ACL audit 与 Job Object cleanup。setup 与 Cargo 各有固定超时,Windows ACL audit 对目录/总量/时限上限或 reparse 元数据错误会发出 `failedScan` warning。非 Windows 主机只输出 `evidence-pending` 并以非零退出,不能被当作平台通过。 diff --git a/scripts/check-vitest-layer-budget.mjs b/scripts/check-vitest-layer-budget.mjs index ba0e5c9e0..3bd336fcb 100644 --- a/scripts/check-vitest-layer-budget.mjs +++ b/scripts/check-vitest-layer-budget.mjs @@ -4,7 +4,7 @@ import process from "node:process"; import { buildVitestLayerReport } from "./lib/vitest-layer-report.mjs"; -const DEFAULT_MAX_COMPONENT_CANDIDATES = 37; +const DEFAULT_MAX_COMPONENT_CANDIDATES = 38; export function parseArgs(argv) { const options = { diff --git a/scripts/check-vitest-layer-budget.test.mjs b/scripts/check-vitest-layer-budget.test.mjs index 594873fdc..45b65de91 100644 --- a/scripts/check-vitest-layer-budget.test.mjs +++ b/scripts/check-vitest-layer-budget.test.mjs @@ -19,7 +19,7 @@ describe("check-vitest-layer-budget", () => { it("应解析 component 候选预算参数", () => { expect(parseArgs([])).toMatchObject({ json: false, - maxComponentCandidates: 37, + maxComponentCandidates: 38, }); expect( parseArgs(["--max-component-candidates", "8", "--json"]), diff --git a/scripts/electron/current-entrypoints.test.mjs b/scripts/electron/current-entrypoints.test.mjs index 3eed8d898..c6c059b29 100644 --- a/scripts/electron/current-entrypoints.test.mjs +++ b/scripts/electron/current-entrypoints.test.mjs @@ -161,6 +161,7 @@ function currentElectronEntrypointFiles() { "scripts/electron/run-preview.mjs", "scripts/electron/stage-release-assets.mjs", "scripts/electron/verify-package-resources.mjs", + "scripts/electron/windows-native-host-gate-b.mjs", "scripts/lib/electron-desktop-resources.mjs", "electron/native/macos/macos-native-host.swift", "electron/native/macos/macos-screen-capture.swift", diff --git a/scripts/electron/windows-native-host-gate-b.mjs b/scripts/electron/windows-native-host-gate-b.mjs index dfb0bcb46..65543fbae 100644 --- a/scripts/electron/windows-native-host-gate-b.mjs +++ b/scripts/electron/windows-native-host-gate-b.mjs @@ -62,7 +62,9 @@ export function resolveInstalledResourcesRoot(electronExecutable) { const executable = path.resolve(electronExecutable); const resourcesRoot = path.join(path.dirname(executable), "resources"); if (!existsSync(resourcesRoot) || !statSync(resourcesRoot).isDirectory()) { - throw new Error(`Installed Electron resources directory is missing: ${resourcesRoot}`); + throw new Error( + `Installed Electron resources directory is missing: ${resourcesRoot}`, + ); } return resourcesRoot; } @@ -74,7 +76,9 @@ function sha256(filePath) { function readInstalledHelper(resourcesRoot) { const manifestPath = path.join(resourcesRoot, RESOURCE_MANIFEST); if (!existsSync(manifestPath)) { - throw new Error(`Installed desktop resource manifest is missing: ${manifestPath}`); + throw new Error( + `Installed desktop resource manifest is missing: ${manifestPath}`, + ); } const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); if ( @@ -98,11 +102,15 @@ function readInstalledHelper(resourcesRoot) { (candidate) => candidate?.id === metadata.id, ); if (!resource || resource.path !== metadata.path) { - throw new Error("Installed Windows native helper resource is not registered"); + throw new Error( + "Installed Windows native helper resource is not registered", + ); } const helperPath = path.resolve(resourcesRoot, metadata.path); if (!existsSync(helperPath) || !statSync(helperPath).isFile()) { - throw new Error(`Installed Windows native helper is missing: ${helperPath}`); + throw new Error( + `Installed Windows native helper is missing: ${helperPath}`, + ); } const packagedSha256 = sha256(helperPath); if (packagedSha256 !== resource.sha256) { @@ -136,7 +144,9 @@ class NativeHostClient { this.#child.once("error", (error) => this.#failAll(error)); this.#child.once("exit", (code, signal) => { this.#failAll( - new Error(`Windows native host exited code=${code ?? ""} signal=${signal ?? ""}`), + new Error( + `Windows native host exited code=${code ?? ""} signal=${signal ?? ""}`, + ), ); }); } @@ -194,7 +204,9 @@ class NativeHostClient { this.#settle( response.id, null, - new Error(response.error?.message || "Windows native host request failed"), + new Error( + response.error?.message || "Windows native host request failed", + ), ); } } @@ -219,37 +231,69 @@ function assert(condition, message) { } export async function runWindowsNativeHostGateB(options) { - const resourcesRoot = resolveInstalledResourcesRoot(options.electronExecutable); + const resourcesRoot = resolveInstalledResourcesRoot( + options.electronExecutable, + ); const helper = readInstalledHelper(resourcesRoot); const client = new NativeHostClient(helper.helperPath, options.timeoutMs); const checks = []; let observedEventTypes = []; + let failure = null; try { - const capabilities = await client.invoke("windows.uiAutomation.capabilities"); + const capabilities = await client.invoke( + "windows.uiAutomation.capabilities", + ); assert(capabilities?.readOnly === true, "native helper must be read-only"); - assert(capabilities?.uiAutomation === "ready", "UI Automation COM is unavailable"); - assert(capabilities?.displayWatcher === "ready", "display watcher is unavailable"); + assert( + capabilities?.uiAutomation === "ready", + "UI Automation COM is unavailable", + ); + assert( + capabilities?.displayWatcher === "ready", + "display watcher is unavailable", + ); checks.push({ name: "capabilities", status: "passed" }); const windows = await client.invoke("windows.window.read"); - assert(Array.isArray(windows?.windows), "window enumeration returned no list"); + assert( + Array.isArray(windows?.windows), + "window enumeration returned no list", + ); const targetWindow = windows.windows.find( (window) => Number(window?.windowHandle) > 0, ); assert(targetWindow, "window enumeration returned no native HWND"); - checks.push({ name: "window.read", count: windows.windows.length, status: "passed" }); + checks.push({ + name: "window.read", + count: windows.windows.length, + status: "passed", + }); const displays = await client.invoke("windows.display.read"); - assert(Array.isArray(displays?.displays) && displays.displays.length > 0, "display enumeration returned no monitors"); - checks.push({ name: "display.read", count: displays.displays.length, status: "passed" }); + assert( + Array.isArray(displays?.displays) && displays.displays.length > 0, + "display enumeration returned no monitors", + ); + checks.push({ + name: "display.read", + count: displays.displays.length, + status: "passed", + }); const tree = await client.invoke("windows.uiAutomation.read", { windowHandle: Number(targetWindow.windowHandle), maxDepth: 2, maxNodes: 64, }); - assert(tree?.tree && Number(tree.nodeCount) >= 1, "UI Automation tree is empty"); - checks.push({ name: "uiAutomation.read", nodeCount: tree.nodeCount, status: "passed" }); + assert( + tree?.tree && Number(tree.nodeCount) >= 1, + "UI Automation tree is empty", + ); + checks.push({ + name: "uiAutomation.read", + nodeCount: tree.nodeCount, + status: "passed", + }); const displayWatcher = await client.invoke("windows.displayWatcher.start"); assert(displayWatcher?.started === true, "display watcher did not start"); @@ -260,6 +304,8 @@ export async function runWindowsNativeHostGateB(options) { assert(rawInput?.started === true, "Raw Input monitor did not start"); await client.invoke("windows.bareModifierMonitor.stop"); checks.push({ name: "rawInput.start-stop", status: "passed" }); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); } finally { observedEventTypes = [ ...new Set(client.events().map((event) => event.event)), @@ -267,11 +313,12 @@ export async function runWindowsNativeHostGateB(options) { await client.close(); } const summary = { - result: "passed", + result: failure ? "failed" : "passed", evidenceLevel: "gate-b", platform: "win32", arch: "x64", checks, + ...(failure ? { failure: failure.message } : {}), helper: { manifestSha256: helper.manifest.resources.find( (resource) => resource.id === "windows-native-host", @@ -286,6 +333,9 @@ export async function runWindowsNativeHostGateB(options) { await mkdir(options.evidenceDir, { recursive: true }); const summaryPath = path.join(options.evidenceDir, "summary.json"); await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + if (failure) { + throw failure; + } return { summary, summaryPath }; } diff --git a/scripts/electron/windows-squirrel-rc-smoke.test.mjs b/scripts/electron/windows-squirrel-rc-smoke.test.mjs index 8068ac2e5..80ffd84e6 100644 --- a/scripts/electron/windows-squirrel-rc-smoke.test.mjs +++ b/scripts/electron/windows-squirrel-rc-smoke.test.mjs @@ -344,6 +344,9 @@ describe("Windows Squirrel RC smoke", () => { const codeModeGate = steps.find( (step) => step.name === "Run installed Windows CodeMode Gate B", ); + const nativeHostGate = steps.find( + (step) => step.name === "Run installed Windows native host Gate B", + ); const upload = steps.find( (step) => step.name === "Upload Windows Squirrel RC evidence", ); @@ -356,6 +359,9 @@ describe("Windows Squirrel RC smoke", () => { const codeModeUpload = steps.find( (step) => step.name === "Upload Windows CodeMode Gate B evidence", ); + const nativeHostUpload = steps.find( + (step) => step.name === "Upload Windows native host Gate B evidence", + ); expect(download?.run).toContain("gh release download"); expect(download?.run).toContain("selectNMinusOneVersion"); @@ -414,6 +420,16 @@ describe("Windows Squirrel RC smoke", () => { ".lime/qc/gui-evidence/code-mode-electron-gate-b-windows", ); } + expect(nativeHostGate?.run).toContain( + "scripts/electron/windows-native-host-gate-b.mjs", + ); + expect(nativeHostGate?.run).toContain("--electron-executable"); + expect(nativeHostGate?.run).toContain( + ".lime/qc/gui-evidence/windows-native-host-gate-b", + ); + expect(nativeHostUpload?.with?.path).toContain( + "windows-native-host-gate-b", + ); } }); @@ -439,15 +455,17 @@ describe("Windows Squirrel RC smoke", () => { const codeModeGate = steps.find( (step) => step.name === "Run installed Windows CodeMode Gate B", ); + const nativeHostGate = steps.find( + (step) => step.name === "Run installed Windows native host Gate B", + ); expect(sherpa).toBeDefined(); expect(build).toBeDefined(); expect(installSmoke).toBeDefined(); expect(pluginGate).toBeDefined(); expect(codeModeGate).toBeDefined(); - expect(sherpa?.run).toContain( - "scripts/prepare-sherpa-onnx-runtime.mjs", - ); + expect(nativeHostGate).toBeDefined(); + expect(sherpa?.run).toContain("scripts/prepare-sherpa-onnx-runtime.mjs"); expect(sherpa?.run).toContain("x86_64-pc-windows-msvc"); expect(build?.run).toContain("electron-forge make --platform win32"); expect(build?.run).toContain("npm run electron:build"); @@ -462,6 +480,9 @@ describe("Windows Squirrel RC smoke", () => { expect(pluginGate?.run).toContain( "npm run smoke:plugin-package-electron-gate-b", ); + expect(nativeHostGate?.run).toContain( + "scripts/electron/windows-native-host-gate-b.mjs", + ); const orderedNames = steps.map((step) => step.name); expect(orderedNames.indexOf(sherpa.name)).toBeLessThan( @@ -476,5 +497,8 @@ describe("Windows Squirrel RC smoke", () => { expect(orderedNames.indexOf(pluginGate.name)).toBeLessThan( orderedNames.indexOf(codeModeGate.name), ); + expect(orderedNames.indexOf(codeModeGate.name)).toBeLessThan( + orderedNames.indexOf(nativeHostGate.name), + ); }); }); diff --git a/scripts/prepare-sherpa-onnx-runtime.mjs b/scripts/prepare-sherpa-onnx-runtime.mjs index ece12280b..bd89c242d 100644 --- a/scripts/prepare-sherpa-onnx-runtime.mjs +++ b/scripts/prepare-sherpa-onnx-runtime.mjs @@ -413,24 +413,42 @@ function downloadSherpaArchive(plan) { fs.renameSync(`${plan.archivePath}.part`, plan.archivePath); } -function extractSherpaArchive(plan) { +export function extractSherpaArchive( + plan, + { + platform = process.platform, + sevenZipCommands = WINDOWS_SEVEN_ZIP_COMMANDS, + removeDirectory = fs.rmSync, + run = runCommand, + missingLibraries = missingSherpaRuntimeLibraries, + } = {}, +) { console.log(`Removing previous sherpa-onnx runtime: ${plan.extractedDir}`); - fs.rmSync(plan.extractedDir, { recursive: true, force: true }); + removeDirectory(plan.extractedDir, { recursive: true, force: true }); console.log(`Removed previous sherpa-onnx runtime: ${plan.extractedDir}`); let lastError = null; - for (const extraction of buildSherpaArchiveExtractCommands(plan)) { - fs.rmSync(plan.extractedDir, { recursive: true, force: true }); + for (const extraction of buildSherpaArchiveExtractCommands(plan, { + platform, + sevenZipCommands, + })) { + removeDirectory(plan.extractedDir, { recursive: true, force: true }); console.log( `Extracting sherpa-onnx archive: ${plan.archivePath} (extractor=${extraction.command})`, ); try { - runCommand(extraction.command, extraction.args, { + run(extraction.command, extraction.args, { cwd: extraction.cwd, timeout: SHERPA_ARCHIVE_EXTRACT_TIMEOUT_MS, }); - console.log(`Extracted sherpa-onnx archive: ${plan.extractedDir}`); - return; + const missing = missingLibraries(plan); + if (missing.length === 0) { + console.log(`Extracted sherpa-onnx archive: ${plan.extractedDir}`); + return; + } + console.warn( + `Sherpa archive extractor ${extraction.command} completed without expected libraries: ${missing.join(", ")}`, + ); } catch (error) { lastError = error; console.warn( diff --git a/scripts/prepare-sherpa-onnx-runtime.test.mjs b/scripts/prepare-sherpa-onnx-runtime.test.mjs index f7008d08a..2e093b468 100644 --- a/scripts/prepare-sherpa-onnx-runtime.test.mjs +++ b/scripts/prepare-sherpa-onnx-runtime.test.mjs @@ -8,6 +8,7 @@ import { buildSherpaArchiveExtractCommand, buildSherpaArchiveExtractCommands, buildSherpaArchiveSevenZipExtractCommand, + extractSherpaArchive, MACOS_EXECUTABLE_RPATH, missingSherpaRuntimeLibraries, readMachORpaths, @@ -154,6 +155,33 @@ version = "1.13.0" expect(commands.map(({ command }) => command)).toEqual(["7z", "tar"]); }); + it("7-Zip 只解出 tar 时继续使用 tar 解压", () => { + const plan = { + archivePath: "D:\\a\\lime\\lime\\runtime.tar.bz2", + extractedDir: "D:\\a\\lime\\lime\\runtime", + prebuiltRoot: "D:\\a\\lime\\lime", + }; + const commands = []; + let librariesReady = false; + + extractSherpaArchive(plan, { + platform: "win32", + sevenZipCommands: ["7z"], + removeDirectory() {}, + run(command) { + commands.push(command); + if (command === "tar") { + librariesReady = true; + } + }, + missingLibraries() { + return librariesReady ? [] : ["onnxruntime.dll"]; + }, + }); + + expect(commands).toEqual(["7z", "tar"]); + }); + it("Windows Quality 为 sherpa 准备保留足够预算并记录清理/解压阶段", () => { const workflow = fs.readFileSync(".github/workflows/quality.yml", "utf8"); const windowsJob = workflow