mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-13 09:26:28 +08:00
fix(release): close v1.138.0 follow-up gates
This commit is contained in:
@@ -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`.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 计划内证据和禁止混淆项
|
||||
|
||||
@@ -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(
|
||||
¤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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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}' 已禁用")));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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` 并以非零退出,不能被当作平台通过。
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user