fix(release): close v1.138.0 follow-up gates

This commit is contained in:
coso
2026-09-01 22:41:32 +08:00
parent f79830804d
commit ba9dbdaae0
32 changed files with 692 additions and 107 deletions
+4 -4
View File
@@ -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`.'
+1 -1
View File
@@ -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
+87 -6
View File
@@ -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();
@@ -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. 计划内证据和禁止混淆项
+11 -2
View File
@@ -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。
@@ -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` 测试。
@@ -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())
@@ -52,6 +52,8 @@ pub struct AgentSessionConfigurationRequest {
pub forked_from_thread_id: Option<String>,
pub max_turns: Option<u32>,
pub provider_token_budget: Option<u64>,
/// 标记本回合是否需要 provider 侧的历史摄取语义。
pub history_ingest_requested: bool,
pub rollout_budget_reminder_source: Option<RolloutBudgetReminderSourceHandle>,
pub system_prompt: Option<String>,
pub turn_context: Option<AgentTurnContext>,
@@ -67,6 +69,7 @@ pub struct AgentSessionConfig {
pub schedule_id: Option<String>,
pub max_turns: Option<u32>,
pub provider_token_budget: Option<u64>,
pub history_ingest_requested: bool,
pub rollout_budget_reminder_source: Option<RolloutBudgetReminderSourceHandle>,
pub system_prompt: Option<String>,
pub system_prompt_override: Option<bool>,
@@ -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<String>,
max_turns: Option<u32>,
provider_token_budget: Option<u64>,
history_ingest_requested: bool,
rollout_budget_reminder_source: Option<RolloutBudgetReminderSourceHandle>,
system_prompt: Option<String>,
system_prompt_override: Option<bool>,
@@ -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<String>) -> 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));
@@ -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::<Vec<_>>();
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::<Vec<_>>();
let current_tool_definitions = current_tool_definitions
.into_iter()
.filter(|definition| {
definition.name != tool_runtime::update_plan::UPDATE_PLAN_NAME || update_plan_enabled
})
.collect::<Vec<_>>();
let native_tools = build_native_inventory(
&current_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 {
@@ -201,6 +201,12 @@ fn tool_definitions(
agent_control_gateway: Option<&tool_runtime::agent_control::AgentControlGatewayHandle>,
) -> Result<(Vec<RuntimeToolDefinition>, Vec<DynamicToolRoute>), 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,
@@ -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),
@@ -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<u32> {
runtime_metadata
.and_then(|metadata| metadata.pointer(HARNESS_MAX_PROVIDER_STEPS_POINTER))
+1
View File
@@ -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::{
+1
View File
@@ -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));
@@ -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}' 已禁用")));
+2 -6
View File
@@ -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(),
+3 -13
View File
@@ -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(),
+53
View File
@@ -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()];
+33 -1
View File
@@ -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");
@@ -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<String>,
pub history_ingest_requested: Option<bool>,
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
}
}
@@ -190,9 +190,13 @@ fn responses_client_metadata(request: &CanonicalRequest) -> Option<Map<String, V
.metadata
.iter()
.filter_map(|(key, value)| {
value
.as_str()
.map(|value| (key.clone(), Value::String(value.to_string())))
if value.is_boolean() && key == "history_ingest_requested" {
Some((key.clone(), value.clone()))
} else {
value
.as_str()
.map(|value| (key.clone(), Value::String(value.to_string())))
}
})
.collect::<Map<String, Value>>();
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");
+115 -39
View File
@@ -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<String> {
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::<String>(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<dyn std::error::Erro
let content = std::fs::read_to_string(&config_path)?;
let lines: Vec<&str> = content.lines().collect();
let mut new_lines: Vec<String> = 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");
@@ -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)]
+1
View File
@@ -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",
+1 -1
View File
@@ -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` 并以非零退出,不能被当作平台通过。
+1 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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"]),
@@ -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",
+67 -17
View File
@@ -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 };
}
@@ -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),
);
});
});
+25 -7
View File
@@ -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(
@@ -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