From da664f8f992180bd710779710b09357c188b9041 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:28:31 +0800 Subject: [PATCH 1/5] feat(computer-use): add guarded Windows maka.cu integration --- apps/desktop/bundled-tools.json | 15 ++ apps/desktop/electron-builder.config.mjs | 6 + .../main/__tests__/computer-use-host.test.ts | 31 +++ apps/desktop/src/main/computer-use-host.ts | 109 ++++++++--- ...903-windows-cu2-integration-replacement.md | 40 ++++ ...903-windows-cu2-integration-replacement.md | 20 ++ packages/computer-use/README.md | 11 +- packages/computer-use/src/select-backend.ts | 13 +- scripts/computer-use.mjs | 1 + scripts/prepare-windows-cu-helper.mjs | 176 ++++++++++++++++++ scripts/prepare-windows-cu-helper.test.mjs | 81 ++++++++ 11 files changed, 475 insertions(+), 28 deletions(-) create mode 100644 docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md create mode 100644 docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md create mode 100644 scripts/prepare-windows-cu-helper.mjs create mode 100644 scripts/prepare-windows-cu-helper.test.mjs diff --git a/apps/desktop/bundled-tools.json b/apps/desktop/bundled-tools.json index be0156603b..1c389c18e0 100644 --- a/apps/desktop/bundled-tools.json +++ b/apps/desktop/bundled-tools.json @@ -12,5 +12,20 @@ "hardenedRuntime": false, "notarization": "missing", "distributionReady": false + }, + "windowsCu": { + "repo": "maka-agent/maka-cu", + "source": "apps/OpenComputerUseWindows/native", + "expectedProtocolVersion": "maka.cu/2", + "binaryName": "maka-cu-windows.exe", + "publishContract": { + "executor": "rust-native-windows", + "protocol": "maka.cu/2", + "runtimeIdentifier": "win-x64", + "cargoProfile": "release", + "lto": true, + "staticNativeDependencies": true + }, + "distributionReady": false } } diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 5893b1e875..ff34f1078a 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -138,6 +138,12 @@ const baseDesktopBuilderConfig = { }, ...(process.platform === 'win32' ? [ + ...(existsSync('resources/bin/maka-cu-windows/maka-cu-windows.exe') + ? [{ + from: 'resources/bin/maka-cu-windows', + to: 'bin/maka-cu-windows', + }] + : []), { from: 'resources/windows-sandbox/maka-windows-sandbox.exe', to: 'windows-sandbox/maka-windows-sandbox.exe', diff --git a/apps/desktop/src/main/__tests__/computer-use-host.test.ts b/apps/desktop/src/main/__tests__/computer-use-host.test.ts index 51c89b636e..4bc8c0d740 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -141,4 +141,35 @@ describe('Computer Use host health', () => { } }); + it('selects the shared maka.cu/2 backend for a pinned Windows helper', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-cu-host-windows-')); + try { + const binaryPath = join(directory, 'maka-cu-windows.exe'); + const manifestPath = join(directory, 'bundled-tools.json'); + const bytes = Buffer.from('windows-native-release-artifact'); + await writeFile(binaryPath, bytes); + await chmod(binaryPath, 0o755); + const hash = createHash('sha256').update(bytes).digest('hex'); + await writeFile(manifestPath, JSON.stringify({ + windowsCu: { + binarySha256: hash, + files: [{ name: 'maka-cu-windows.exe', sizeBytes: bytes.length, sha256: hash }], + distributionReady: false, + }, + })); + + const selected = createComputerUseHost({ + isPackaged: false, + resourcesPath: directory, + manifestPath, + binaryPath, + platform: 'win32', + physicalInputRecentlyActive: () => false, + }); + assert.equal(selected.selected.backendId, 'maka-cu'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + }); diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index 5b5c964b74..ebf5c3a4c9 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -25,6 +25,7 @@ import { fstatSync, openSync, readFileSync, + readdirSync, } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -42,6 +43,15 @@ export interface ComputerUseHostState { expectedBinarySha256?: string; } +type BundledToolManifest = { + makaCu?: { binarySha256?: string; distributionReady?: boolean }; + windowsCu?: { + binarySha256?: string; + distributionReady?: boolean; + files?: Array<{ name?: string; sizeBytes?: number; sha256?: string }>; + }; +}; + function readRegularFile(path: string): Buffer { const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { @@ -54,6 +64,47 @@ function readRegularFile(path: string): Buffer { } } +function hasPinnedWindowsHelperFiles( + binaryPath: string, + files: NonNullable['files'], +): boolean { + if (!Array.isArray(files) || files.length === 0) return false; + const expected = new Map(); + for (const file of files) { + if ( + typeof file?.name !== 'string' || + file.name.length === 0 || + file.name !== file.name.split(/[\\/]/).pop() || + typeof file.sizeBytes !== 'number' || + !Number.isSafeInteger(file.sizeBytes) || + file.sizeBytes < 0 || + typeof file.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(file.sha256) || + expected.has(file.name) + ) return false; + expected.set(file.name, { sizeBytes: file.sizeBytes, sha256: file.sha256 }); + } + let actual: string[]; + try { + actual = readdirSync(dirname(binaryPath), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name !== 'bundled-tools.json') + .map((entry) => entry.name); + } catch { + return false; + } + if (actual.length !== expected.size || actual.some((name) => !expected.has(name))) return false; + for (const [name, pin] of expected) { + try { + const bytes = readRegularFile(join(dirname(binaryPath), name)); + if (bytes.byteLength !== pin.sizeBytes) return false; + if (createHash('sha256').update(bytes).digest('hex') !== pin.sha256) return false; + } catch { + return false; + } + } + return expected.has(binaryPath.split(/[\\/]/).pop() ?? ''); +} + export function createComputerUseHost(input: { isPackaged: boolean; resourcesPath: string; @@ -68,6 +119,8 @@ export function createComputerUseHost(input: { screenLocked?: (context: { sessionId: string }) => boolean | Promise; onTrace?: MakaCuBackendOptions['onTrace']; overlay?: CuOverlayHook; + /** Test seam for Windows manifest selection. */ + platform?: NodeJS.Platform; }): ComputerUseHostState { const manifestPath = input.manifestPath ?? (input.isPackaged ? join(input.resourcesPath, 'bundled-tools.json') @@ -77,29 +130,42 @@ export function createComputerUseHost(input: { '..', 'bundled-tools.json', )); - const binaryPath = input.binaryPath ?? (input.isPackaged - ? join(input.resourcesPath, 'bin', 'maka-cu') - : resolve( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - 'resources', - 'bin', - 'maka-cu', - )); + const platform = input.platform ?? process.platform; + const windows = platform === 'win32'; + const binaryPath = input.binaryPath ?? (windows + ? (process.env.MAKA_WINDOWS_CU_HELPER_PATH ?? (input.isPackaged + ? join(input.resourcesPath, 'bin', 'maka-cu-windows', 'maka-cu-windows.exe') + : resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'resources', + 'bin', + 'maka-cu-windows', + 'maka-cu-windows.exe', + ))) + : (input.isPackaged + ? join(input.resourcesPath, 'bin', 'maka-cu') + : resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'resources', + 'bin', + 'maka-cu', + ))); try { - const manifest = JSON.parse(readRegularFile(manifestPath).toString('utf8')) as { - makaCu?: { - binarySha256?: string; - distributionReady?: boolean; - }; - }; - const expectedBinarySha256 = manifest.makaCu?.binarySha256; - if (input.isPackaged && manifest.makaCu?.distributionReady !== true) { - return { selected: selectComputerUseBackend() }; + const manifest = JSON.parse(readRegularFile(manifestPath).toString('utf8')) as BundledToolManifest; + const entry = windows ? manifest.windowsCu : manifest.makaCu; + const expectedBinarySha256 = entry?.binarySha256; + if (input.isPackaged && entry?.distributionReady !== true) { + return { selected: selectComputerUseBackend({ platform }) }; } if (!expectedBinarySha256 || !/^[a-f0-9]{64}$/.test(expectedBinarySha256)) { - return { selected: selectComputerUseBackend() }; + return { selected: selectComputerUseBackend({ platform }) }; + } + if (windows && !hasPinnedWindowsHelperFiles(binaryPath, manifest.windowsCu?.files)) { + return { selected: selectComputerUseBackend({ platform }) }; } accessSync(binaryPath, constants.R_OK | constants.X_OK); const actual = createHash('sha256') @@ -120,12 +186,13 @@ export function createComputerUseHost(input: { ...(input.screenLocked ? { screenLocked: input.screenLocked } : {}), ...(input.onTrace ? { onTrace: input.onTrace } : {}), ...(input.overlay ? { overlay: input.overlay } : {}), + platform, }), binaryPath, expectedBinarySha256, }; } catch { - return { selected: selectComputerUseBackend() }; + return { selected: selectComputerUseBackend({ platform }) }; } } diff --git a/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md new file mode 100644 index 0000000000..5b4334d74f --- /dev/null +++ b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md @@ -0,0 +1,40 @@ +# Windows `maka.cu/2` integration replacement + +## Objective + +Rebuild the Windows Computer Use integration on the current `apache/main` +baseline, consuming only a pinned, validated Rust helper artifact and the +existing shared `maka.cu/2` host service. + +## Scope + +- Add Windows platform selection to the existing protocol backend. +- Select the `windowsCu` manifest entry and verify every packaged helper file. +- Package the helper directory only when an artifact is present. +- Provide a preparation script whose release flag is evidence-derived and + defaults to `distributionReady: false`. +- Do not copy the old PR's generated browser JSON, raw outputs, experiments, + duplicate service, or compatibility input subsystem. + +## Evidence boundary + +The companion executor fix is pinned separately in `maka-cu#8`. This worktree +does not claim clean-machine validation, packaged conversation E2E, signing, +or distribution readiness. Those fields must be supplied by a release +qualification pipeline and must match the exact binary digest. + +## Progress + +- [x] Start from the current `apache/main` after #4497. +- [x] Reuse the existing `MakaCuService` and `maka.cu/2` backend. +- [x] Add Windows manifest, digest-set validation, and conditional packaging. +- [x] Add evidence-gated preparation script and focused tests. +- [ ] Run a real packaged Windows conversation E2E on the exact artifact. + +## Validation + +- `npm run build --workspace @maka/computer-use` — pass with shared checkout dependencies. +- `npm run typecheck --workspace @maka/desktop` — baseline failure unrelated to + this change; no diagnostic references the changed host or selector files. +- `node --test scripts/prepare-windows-cu-helper.test.mjs` — 4 passed. +- Windows packaged/clean-machine validation — not run in this environment. diff --git a/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md new file mode 100644 index 0000000000..fb9835447a --- /dev/null +++ b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md @@ -0,0 +1,20 @@ +## [2026-09-03] | Task: 重建 Windows `maka.cu/2` 集成 + +### Changes + +- 在最新 `apache/main` 上让现有 `MakaCuService`/`maka.cu/2` 后端复用到 + Windows;没有增加第二套 service 或 model-facing 协议。 +- Desktop 按 `windowsCu` manifest 选择 helper,并校验目录内文件集合、大小和 + SHA-256;electron-builder 仅在 helper 存在时打包它。 +- 增加 `prepare-windows` artifact 准备命令。`distributionReady` 不能由命令行 + 参数直接打开,只能由 exact digest、CI run、Authenticode、clean-machine 和 + packaged conversation 证据共同计算。 +- 未带回旧 PR 的 generated JSON、raw outputs、experiments 或兼容输入代码。 + +### Verification + +- `npm run build --workspace @maka/computer-use`:通过。 +- `node --test scripts/prepare-windows-cu-helper.test.mjs`:4 passed。 +- Desktop main typecheck 的本次文件无诊断;全量 typecheck 被主线既有的无关 + 类型错误阻断。 +- 未执行真实 Windows clean-machine/packaged conversation E2E,未提交或推送。 diff --git a/packages/computer-use/README.md b/packages/computer-use/README.md index 9eb3be9f2c..3c8a71e9a4 100644 --- a/packages/computer-use/README.md +++ b/packages/computer-use/README.md @@ -50,7 +50,8 @@ undeclared internal source paths. The shipped selector enables Computer Use only when all of these conditions hold: -1. the host platform is macOS (`process.platform === 'darwin'`); +1. the host platform is macOS or Windows (`process.platform === 'darwin'` or + `process.platform === 'win32'`); 2. the composition supplies a `maka-cu` executable path; and 3. the composition supplies the executable's expected SHA-256 digest. @@ -70,6 +71,14 @@ Cross-platform work is tracked separately: - [#3785](https://github.com/apache/maka/issues/3785) — Windows executor hardening and production evidence. +On Windows, Desktop reads the `windowsCu` entry from +`apps/desktop/bundled-tools.json`, verifies the complete helper directory +against its declared file digests, and uses the same `maka.cu/2` service. The +helper preparation script is `node scripts/computer-use.mjs prepare-windows`. +Local preparation always leaves `distributionReady: false`; release readiness +requires evidence tied to the exact CI artifact, Authenticode signature, clean +machine run, and packaged conversation run. + ## Protocol and lifecycle The host and executor communicate over line-delimited JSON-RPC using the diff --git a/packages/computer-use/src/select-backend.ts b/packages/computer-use/src/select-backend.ts index 51a1a31bc4..c9a2a73ed7 100644 --- a/packages/computer-use/src/select-backend.ts +++ b/packages/computer-use/src/select-backend.ts @@ -26,11 +26,9 @@ import type { MakaCuServiceSnapshot } from './maka-cu-service.js'; /** * One executor. * - * This was a two-member set while cua-driver was being replaced, and the - * selector took an overload per member. Keeping the id now that the second - * executor is gone is not ceremony: `backendId` is what the capability snapshot - * reports and what `'none'` is distinguished from, so it stays a named value - * rather than becoming a boolean nobody can read. + * The macOS and Windows executors speak the same protocol and use the same + * supervised service. Keeping one id here is intentional: `backendId` reports + * the protocol backend, while the host manifest chooses the platform binary. */ export const CU_BACKEND_IDS = ['maka-cu'] as const; export type CuBackendId = (typeof CU_BACKEND_IDS)[number]; @@ -93,12 +91,15 @@ export interface MakaCuSelection { overlay?: CuOverlayHook; onTrace?: MakaCuBackendOptions['onTrace']; createBackend?: (options: MakaCuBackendOptions) => DisposableBackend; + /** Test/host seam; production defaults to Node's platform. */ + platform?: NodeJS.Platform; } export type ComputerUseBackendSelection = MakaCuSelection; export function selectComputerUseBackend(deps?: MakaCuSelection): SelectedComputerUseBackend { - if (process.platform !== 'darwin') return NONE; + const platform = deps?.platform ?? process.platform; + if (platform !== 'darwin' && platform !== 'win32') return NONE; if (!deps?.binaryPath || !deps.expectedBinarySha256) return NONE; const binaryPath = deps.binaryPath; const expectedBinarySha256 = deps.expectedBinarySha256; diff --git a/scripts/computer-use.mjs b/scripts/computer-use.mjs index 04ae91337c..b089077f6c 100644 --- a/scripts/computer-use.mjs +++ b/scripts/computer-use.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; const commands = { prepare: { module: 'prepare.mjs' }, + 'prepare-windows': { module: '../prepare-windows-cu-helper.mjs' }, 'real-ax': { module: 'real-ax-launcher.mjs', options: { diff --git a/scripts/prepare-windows-cu-helper.mjs b/scripts/prepare-windows-cu-helper.mjs new file mode 100644 index 0000000000..21018dfa94 --- /dev/null +++ b/scripts/prepare-windows-cu-helper.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Prepare a Rust native Windows helper artifact for local validation. */ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const root = resolve( + process.env.MAKA_CU_WINDOWS_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), '..'), +); +const outputDirectory = resolve(root, 'apps/desktop/resources/bin/maka-cu-windows'); +const output = resolve(outputDirectory, 'maka-cu-windows.exe'); + +export const REQUIRED_NATIVE_FILES = []; + +const PUBLISH_CONTRACT = { + executor: 'rust-native-windows', + protocol: 'maka.cu/2', + runtimeIdentifier: 'win-x64', + cargoProfile: 'release', + lto: true, + staticNativeDependencies: true, +}; + +/** + * A local copy or a caller-provided boolean is never enough for distribution. + * Readiness is tied to the exact bytes and requires release evidence from CI, + * Authenticode, a clean machine, and a packaged conversation run. + */ +export function resolveWindowsCuDistributionReady(provenance, binarySha256) { + return Boolean( + provenance && + typeof provenance.executorCommit === 'string' && + /^[0-9a-f]{40}$/.test(provenance.executorCommit) && + typeof provenance.workflowRun === 'string' && + /^[1-9][0-9]*$/.test(provenance.workflowRun) && + provenance.artifactSha256 === binarySha256 && + typeof binarySha256 === 'string' && + /^[a-f0-9]{64}$/.test(binarySha256) && + provenance.signature === 'authenticode' && + provenance.cleanMachineE2e === true && + provenance.packagedConversationE2e === true, + ); +} + +export async function inspectWindowsCuArtifact(artifactDirectory) { + const entries = await readdir(artifactDirectory, { withFileTypes: true }); + if (entries.some((entry) => entry.isDirectory())) { + throw new Error(`Windows helper artifact must be flat: ${artifactDirectory}`); + } + const names = entries.filter((entry) => entry.isFile()).map((entry) => entry.name); + if (!names.includes('maka-cu-windows.exe')) { + throw new Error(`Windows helper artifact has no maka-cu-windows.exe: ${artifactDirectory}`); + } + const binary = await stat(resolve(artifactDirectory, 'maka-cu-windows.exe')); + if (binary.size < 256 * 1024) { + throw new Error( + `Windows helper is not a native release artifact (${binary.size} bytes); ` + + 'build the Rust executor with cargo build --release', + ); + } + const missing = REQUIRED_NATIVE_FILES.filter((name) => !names.includes(name)); + if (missing.length > 0) throw new Error(`Windows helper artifact is missing: ${missing.join(', ')}`); + return { + binaryPath: resolve(artifactDirectory, 'maka-cu-windows.exe'), + files: await Promise.all( + names.sort().map(async (name) => { + const bytes = await readFile(resolve(artifactDirectory, name)); + return { + name, + sizeBytes: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + ), + }; +} + +async function publishFromSource(sourceRoot) { + const manifest = resolve(sourceRoot, 'apps/OpenComputerUseWindows/native/Cargo.toml'); + if (!existsSync(manifest)) { + throw new Error(`No Rust Windows executor Cargo.toml found under ${sourceRoot}.`); + } + const dirty = (await exec('git', ['status', '--porcelain'], { cwd: sourceRoot })).stdout.trim(); + if (dirty) throw new Error(`Windows helper source is dirty: ${sourceRoot}; build from an immutable commit.`); + const artifact = resolve(sourceRoot, 'artifacts/windows-cu/win-x64'); + await rm(artifact, { recursive: true, force: true }); + await mkdir(artifact, { recursive: true }); + await exec(process.platform === 'win32' ? 'cargo.exe' : 'cargo', [ + 'build', '--release', '--manifest-path', manifest, + ], { cwd: sourceRoot }); + const built = resolve(dirname(manifest), 'target/release/maka-cu-windows-rust.exe'); + if (!existsSync(built)) throw new Error(`Rust release binary was not produced: ${built}`); + await cp(built, resolve(artifact, 'maka-cu-windows.exe')); + await inspectWindowsCuArtifact(artifact); + return artifact; +} + +export async function prepareWindowsCuHelper({ source = process.env.MAKA_CU_WINDOWS_SOURCE } = {}) { + let artifactDirectory = process.env.MAKA_CU_WINDOWS_ARTIFACT; + if (source) artifactDirectory = await publishFromSource(resolve(source)); + if (!artifactDirectory) artifactDirectory = outputDirectory; + artifactDirectory = resolve(artifactDirectory); + await inspectWindowsCuArtifact(artifactDirectory); + + if (artifactDirectory !== outputDirectory) { + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + await cp(artifactDirectory, outputDirectory, { recursive: true }); + } + const finalArtifact = await inspectWindowsCuArtifact(outputDirectory); + const bytes = await readFile(finalArtifact.binaryPath); + const hash = createHash('sha256').update(bytes).digest('hex'); + const provenancePath = process.env.MAKA_CU_WINDOWS_PROVENANCE; + const provenance = provenancePath + ? JSON.parse(await readFile(resolve(provenancePath), 'utf8')) + : { + executorCommit: source + ? (await exec('git', ['rev-parse', 'HEAD'], { cwd: resolve(source) })).stdout.trim() + : undefined, + workflowRun: undefined, + artifactSha256: undefined, + signature: 'unsigned', + cleanMachineE2e: false, + packagedConversationE2e: false, + }; + const manifestPath = resolve(root, 'apps/desktop/bundled-tools.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.windowsCu = { + repo: 'maka-agent/maka-cu', + source: source ? 'maka-cu/apps/OpenComputerUseWindows/native' : 'declared-artifact', + expectedProtocolVersion: 'maka.cu/2', + binaryName: 'maka-cu-windows.exe', + binarySizeBytes: bytes.length, + binarySha256: hash, + files: finalArtifact.files, + publishContract: PUBLISH_CONTRACT, + provenance, + // There is deliberately no --distribution-ready escape hatch. + distributionReady: resolveWindowsCuDistributionReady(provenance, hash), + }; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`Prepared ${output} (${hash}, ${bytes.length} bytes); distributionReady=${manifest.windowsCu.distributionReady}`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + await prepareWindowsCuHelper(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/scripts/prepare-windows-cu-helper.test.mjs b/scripts/prepare-windows-cu-helper.test.mjs new file mode 100644 index 0000000000..d161e43c7d --- /dev/null +++ b/scripts/prepare-windows-cu-helper.test.mjs @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { + inspectWindowsCuArtifact, + REQUIRED_NATIVE_FILES, + resolveWindowsCuDistributionReady, +} from './prepare-windows-cu-helper.mjs'; + +const temporaryDirectories = []; +after(async () => { + await Promise.all(temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +test('rejects a tiny artifact before it reaches Desktop resources', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-cu-helper-')); + temporaryDirectories.push(directory); + await writeFile(join(directory, 'maka-cu-windows.exe'), Buffer.alloc(151_552)); + await assert.rejects(inspectWindowsCuArtifact(directory), /not a native release artifact/); +}); + +test('accepts the native single-file artifact contract', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-cu-helper-')); + temporaryDirectories.push(directory); + await writeFile(join(directory, 'maka-cu-windows.exe'), Buffer.alloc(10 * 1024 * 1024)); + const inspected = await inspectWindowsCuArtifact(directory); + assert.equal(inspected.files.length, REQUIRED_NATIVE_FILES.length + 1); +}); + +test('local preparation never enables distribution readiness', async () => { + const artifact = await mkdtemp(join(tmpdir(), 'maka-cu-helper-artifact-')); + const outputRoot = await mkdtemp(join(tmpdir(), 'maka-cu-helper-root-')); + temporaryDirectories.push(artifact, outputRoot); + await writeFile(join(artifact, 'maka-cu-windows.exe'), Buffer.alloc(600 * 1024)); + await mkdir(join(outputRoot, 'apps', 'desktop'), { recursive: true }); + await writeFile(join(outputRoot, 'apps', 'desktop', 'bundled-tools.json'), '{}\n'); + const original = process.env.MAKA_CU_WINDOWS_ARTIFACT; + const originalRoot = process.env.MAKA_CU_WINDOWS_ROOT; + process.env.MAKA_CU_WINDOWS_ARTIFACT = artifact; + process.env.MAKA_CU_WINDOWS_ROOT = outputRoot; + try { + const module = await import(`./prepare-windows-cu-helper.mjs?test=${Date.now()}`); + await module.prepareWindowsCuHelper(); + } finally { + if (original === undefined) delete process.env.MAKA_CU_WINDOWS_ARTIFACT; + else process.env.MAKA_CU_WINDOWS_ARTIFACT = original; + if (originalRoot === undefined) delete process.env.MAKA_CU_WINDOWS_ROOT; + else process.env.MAKA_CU_WINDOWS_ROOT = originalRoot; + } + const manifest = JSON.parse(await readFile(join(outputRoot, 'apps', 'desktop', 'bundled-tools.json'), 'utf8')); + assert.equal(manifest.windowsCu.distributionReady, false); +}); + +test('distribution readiness requires evidence tied to the exact artifact', () => { + const hash = 'a'.repeat(64); + const complete = { + executorCommit: 'b'.repeat(40), + workflowRun: '4595', + artifactSha256: hash, + signature: 'authenticode', + cleanMachineE2e: true, + packagedConversationE2e: true, + }; + assert.equal(resolveWindowsCuDistributionReady(complete, hash), true); + assert.equal(resolveWindowsCuDistributionReady({ ...complete, artifactSha256: 'c'.repeat(64) }, hash), false); + assert.equal(resolveWindowsCuDistributionReady(undefined, hash), false); +}); From 81c9fd9ca072284e5b230b3b51b511a5d0ec7c02 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:29:07 +0800 Subject: [PATCH 2/5] fix(computer-use): restore packaging and CLI tests --- apps/desktop/electron-builder.config.mjs | 2 +- apps/desktop/src/main/computer-use-host.ts | 2 +- scripts/computer-use/lab-root.test.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ff34f1078a..2def88edb5 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -17,7 +17,7 @@ * under the License. */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index ebf5c3a4c9..03c04dbd0e 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -172,7 +172,7 @@ export function createComputerUseHost(input: { .update(readRegularFile(binaryPath)) .digest('hex'); if (actual !== expectedBinarySha256) { - return { selected: selectComputerUseBackend() }; + return { selected: selectComputerUseBackend({ platform }) }; } return { // No `backendId`: the host takes whatever `DEFAULT_CU_BACKEND_ID` names, diff --git a/scripts/computer-use/lab-root.test.mjs b/scripts/computer-use/lab-root.test.mjs index 945d5afd9a..75bb121ba2 100644 --- a/scripts/computer-use/lab-root.test.mjs +++ b/scripts/computer-use/lab-root.test.mjs @@ -46,7 +46,7 @@ test('Computer Use CLI advertises only the supported evidence commands', () => { assert.equal(result.status, 0, result.stderr); assert.equal( result.stdout, - `Usage: node scripts/computer-use.mjs [options]\n\nCommands:\n prepare\n real-ax\n real-model\n restart-soak\n provider-matrix\n`, + `Usage: node scripts/computer-use.mjs [options]\n\nCommands:\n prepare\n prepare-windows\n real-ax\n real-model\n restart-soak\n provider-matrix\n`, ); }); From 5a001dba2bc9ae053e20868dab49fad6944f7480 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:32:55 +0800 Subject: [PATCH 3/5] chore(computer-use): satisfy source header audit --- ...903-windows-cu2-integration-replacement.md | 19 +++++++++++++++++++ ...903-windows-cu2-integration-replacement.md | 19 +++++++++++++++++++ scripts/prepare-windows-cu-helper.mjs | 11 ++++++----- scripts/prepare-windows-cu-helper.test.mjs | 7 +++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md index 5b4334d74f..0f256e8a6a 100644 --- a/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md +++ b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md @@ -1,3 +1,22 @@ + + # Windows `maka.cu/2` integration replacement ## Objective diff --git a/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md index fb9835447a..dceba0bd87 100644 --- a/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md +++ b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md @@ -1,3 +1,22 @@ + + ## [2026-09-03] | Task: 重建 Windows `maka.cu/2` 集成 ### Changes diff --git a/scripts/prepare-windows-cu-helper.mjs b/scripts/prepare-windows-cu-helper.mjs index 21018dfa94..ad2515243e 100644 --- a/scripts/prepare-windows-cu-helper.mjs +++ b/scripts/prepare-windows-cu-helper.mjs @@ -10,11 +10,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ /* Prepare a Rust native Windows helper artifact for local validation. */ diff --git a/scripts/prepare-windows-cu-helper.test.mjs b/scripts/prepare-windows-cu-helper.test.mjs index d161e43c7d..4caa9a1e79 100644 --- a/scripts/prepare-windows-cu-helper.test.mjs +++ b/scripts/prepare-windows-cu-helper.test.mjs @@ -8,6 +8,13 @@ * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import assert from 'node:assert/strict'; From 0453cae83318d707aae0c49cfeb7de1fd9425f89 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:35:05 +0800 Subject: [PATCH 4/5] style(computer-use): format Windows helper scripts --- scripts/prepare-windows-cu-helper.mjs | 20 ++++++++++++++------ scripts/prepare-windows-cu-helper.test.mjs | 13 ++++++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/prepare-windows-cu-helper.mjs b/scripts/prepare-windows-cu-helper.mjs index ad2515243e..d171d59125 100644 --- a/scripts/prepare-windows-cu-helper.mjs +++ b/scripts/prepare-windows-cu-helper.mjs @@ -83,7 +83,8 @@ export async function inspectWindowsCuArtifact(artifactDirectory) { ); } const missing = REQUIRED_NATIVE_FILES.filter((name) => !names.includes(name)); - if (missing.length > 0) throw new Error(`Windows helper artifact is missing: ${missing.join(', ')}`); + if (missing.length > 0) + throw new Error(`Windows helper artifact is missing: ${missing.join(', ')}`); return { binaryPath: resolve(artifactDirectory, 'maka-cu-windows.exe'), files: await Promise.all( @@ -105,13 +106,18 @@ async function publishFromSource(sourceRoot) { throw new Error(`No Rust Windows executor Cargo.toml found under ${sourceRoot}.`); } const dirty = (await exec('git', ['status', '--porcelain'], { cwd: sourceRoot })).stdout.trim(); - if (dirty) throw new Error(`Windows helper source is dirty: ${sourceRoot}; build from an immutable commit.`); + if (dirty) + throw new Error( + `Windows helper source is dirty: ${sourceRoot}; build from an immutable commit.`, + ); const artifact = resolve(sourceRoot, 'artifacts/windows-cu/win-x64'); await rm(artifact, { recursive: true, force: true }); await mkdir(artifact, { recursive: true }); - await exec(process.platform === 'win32' ? 'cargo.exe' : 'cargo', [ - 'build', '--release', '--manifest-path', manifest, - ], { cwd: sourceRoot }); + await exec( + process.platform === 'win32' ? 'cargo.exe' : 'cargo', + ['build', '--release', '--manifest-path', manifest], + { cwd: sourceRoot }, + ); const built = resolve(dirname(manifest), 'target/release/maka-cu-windows-rust.exe'); if (!existsSync(built)) throw new Error(`Rust release binary was not produced: ${built}`); await cp(built, resolve(artifact, 'maka-cu-windows.exe')); @@ -163,7 +169,9 @@ export async function prepareWindowsCuHelper({ source = process.env.MAKA_CU_WIND distributionReady: resolveWindowsCuDistributionReady(provenance, hash), }; await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`Prepared ${output} (${hash}, ${bytes.length} bytes); distributionReady=${manifest.windowsCu.distributionReady}`); + console.log( + `Prepared ${output} (${hash}, ${bytes.length} bytes); distributionReady=${manifest.windowsCu.distributionReady}`, + ); } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; diff --git a/scripts/prepare-windows-cu-helper.test.mjs b/scripts/prepare-windows-cu-helper.test.mjs index 4caa9a1e79..520e62aec2 100644 --- a/scripts/prepare-windows-cu-helper.test.mjs +++ b/scripts/prepare-windows-cu-helper.test.mjs @@ -30,7 +30,9 @@ import { const temporaryDirectories = []; after(async () => { - await Promise.all(temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all( + temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })), + ); }); test('rejects a tiny artifact before it reaches Desktop resources', async () => { @@ -68,7 +70,9 @@ test('local preparation never enables distribution readiness', async () => { if (originalRoot === undefined) delete process.env.MAKA_CU_WINDOWS_ROOT; else process.env.MAKA_CU_WINDOWS_ROOT = originalRoot; } - const manifest = JSON.parse(await readFile(join(outputRoot, 'apps', 'desktop', 'bundled-tools.json'), 'utf8')); + const manifest = JSON.parse( + await readFile(join(outputRoot, 'apps', 'desktop', 'bundled-tools.json'), 'utf8'), + ); assert.equal(manifest.windowsCu.distributionReady, false); }); @@ -83,6 +87,9 @@ test('distribution readiness requires evidence tied to the exact artifact', () = packagedConversationE2e: true, }; assert.equal(resolveWindowsCuDistributionReady(complete, hash), true); - assert.equal(resolveWindowsCuDistributionReady({ ...complete, artifactSha256: 'c'.repeat(64) }, hash), false); + assert.equal( + resolveWindowsCuDistributionReady({ ...complete, artifactSha256: 'c'.repeat(64) }, hash), + false, + ); assert.equal(resolveWindowsCuDistributionReady(undefined, hash), false); }); From fbfba8d4aa31523014d9190585aa859f345b3b17 Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Fri, 4 Sep 2026 17:18:26 +0800 Subject: [PATCH 5/5] fix(computer-use): harden Windows release gates --- apps/desktop/bundled-tools.json | 1 + apps/desktop/electron-builder.config.mjs | 24 ++++++-- .../main/__tests__/computer-use-host.test.ts | 13 +++- apps/desktop/src/main/computer-use-host.ts | 6 +- ...903-windows-cu2-integration-replacement.md | 16 +++-- ...903-windows-cu2-integration-replacement.md | 12 ++-- packages/computer-use/README.md | 17 +++++- scripts/prepare-windows-cu-helper.mjs | 42 +++++++------ scripts/prepare-windows-cu-helper.test.mjs | 4 +- scripts/product-release.test.mjs | 29 +++++++++ scripts/verify-packaged-app.mjs | 61 +++++++++++++++++++ scripts/verify-packaged-app.test.mjs | 42 +++++++++++++ scripts/verify-windows-x64.mjs | 21 +++++++ 13 files changed, 247 insertions(+), 41 deletions(-) diff --git a/apps/desktop/bundled-tools.json b/apps/desktop/bundled-tools.json index 1c389c18e0..9adbee30fe 100644 --- a/apps/desktop/bundled-tools.json +++ b/apps/desktop/bundled-tools.json @@ -22,6 +22,7 @@ "executor": "rust-native-windows", "protocol": "maka.cu/2", "runtimeIdentifier": "win-x64", + "rustTarget": "x86_64-pc-windows-msvc", "cargoProfile": "release", "lto": true, "staticNativeDependencies": true diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 2def88edb5..bfd1a9f2f4 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -31,6 +31,23 @@ function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +export function windowsCuExtraResources({ + platform = process.platform, + manifest = readManifest('./bundled-tools.json'), + helperExists = existsSync('resources/bin/maka-cu-windows/maka-cu-windows.exe'), +} = {}) { + if (platform !== 'win32' || manifest.windowsCu?.distributionReady !== true) return []; + if (!helperExists) { + throw new Error( + 'windowsCu is distribution-ready but resources/bin/maka-cu-windows/maka-cu-windows.exe is missing', + ); + } + return [{ + from: 'resources/bin/maka-cu-windows', + to: 'bin/maka-cu-windows', + }]; +} + // Some license files below ship inside third-party packages that apps/desktop // depends on (electron, @fontsource-variable/geist*). Locate each package by // resolving its manifest rather than assuming its node_modules location: @@ -138,12 +155,7 @@ const baseDesktopBuilderConfig = { }, ...(process.platform === 'win32' ? [ - ...(existsSync('resources/bin/maka-cu-windows/maka-cu-windows.exe') - ? [{ - from: 'resources/bin/maka-cu-windows', - to: 'bin/maka-cu-windows', - }] - : []), + ...windowsCuExtraResources(), { from: 'resources/windows-sandbox/maka-windows-sandbox.exe', to: 'windows-sandbox/maka-windows-sandbox.exe', diff --git a/apps/desktop/src/main/__tests__/computer-use-host.test.ts b/apps/desktop/src/main/__tests__/computer-use-host.test.ts index 4bc8c0d740..866df7c7ec 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; @@ -167,6 +167,17 @@ describe('Computer Use host health', () => { physicalInputRecentlyActive: () => false, }); assert.equal(selected.selected.backendId, 'maka-cu'); + + await mkdir(join(directory, 'unexpected-directory')); + const withUnexpectedDirectory = createComputerUseHost({ + isPackaged: false, + resourcesPath: directory, + manifestPath, + binaryPath, + platform: 'win32', + physicalInputRecentlyActive: () => false, + }); + assert.equal(withUnexpectedDirectory.selected.backendId, 'none'); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index 03c04dbd0e..8ec6e29ea4 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -86,9 +86,9 @@ function hasPinnedWindowsHelperFiles( } let actual: string[]; try { - actual = readdirSync(dirname(binaryPath), { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name !== 'bundled-tools.json') - .map((entry) => entry.name); + const entries = readdirSync(dirname(binaryPath), { withFileTypes: true }); + if (entries.some((entry) => !entry.isFile())) return false; + actual = entries.map((entry) => entry.name); } catch { return false; } diff --git a/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md index 0f256e8a6a..ebdd8cb746 100644 --- a/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md +++ b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md @@ -29,9 +29,11 @@ existing shared `maka.cu/2` host service. - Add Windows platform selection to the existing protocol backend. - Select the `windowsCu` manifest entry and verify every packaged helper file. -- Package the helper directory only when an artifact is present. -- Provide a preparation script whose release flag is evidence-derived and - defaults to `distributionReady: false`. +- Package the helper directory only when `distributionReady` is true; fail the + build when readiness is true but the exact helper is missing. +- Keep local preparation permanently at `distributionReady: false`; a future + release qualification verifier must establish attestation, Authenticode, + clean-machine, and packaged-conversation evidence mechanically. - Do not copy the old PR's generated browser JSON, raw outputs, experiments, duplicate service, or compatibility input subsystem. @@ -46,8 +48,10 @@ qualification pipeline and must match the exact binary digest. - [x] Start from the current `apache/main` after #4497. - [x] Reuse the existing `MakaCuService` and `maka.cu/2` backend. -- [x] Add Windows manifest, digest-set validation, and conditional packaging. -- [x] Add evidence-gated preparation script and focused tests. +- [x] Add Windows manifest, exact digest-set validation, and readiness-gated packaging. +- [x] Make readiness fail closed instead of trusting caller-authored provenance booleans. +- [x] Require the packaged helper's exact file set/size/digests and a valid Authenticode status. +- [x] Use a locked explicit `x86_64-pc-windows-msvc` source build contract. - [ ] Run a real packaged Windows conversation E2E on the exact artifact. ## Validation @@ -56,4 +60,6 @@ qualification pipeline and must match the exact binary digest. - `npm run typecheck --workspace @maka/desktop` — baseline failure unrelated to this change; no diagnostic references the changed host or selector files. - `node --test scripts/prepare-windows-cu-helper.test.mjs` — 4 passed. +- Focused release/verifier assertions pass; unrelated full script tests still + require generated workspace build output and a working Bash/WSL path. - Windows packaged/clean-machine validation — not run in this environment. diff --git a/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md index dceba0bd87..ee0c01ffe7 100644 --- a/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md +++ b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md @@ -24,10 +24,14 @@ - 在最新 `apache/main` 上让现有 `MakaCuService`/`maka.cu/2` 后端复用到 Windows;没有增加第二套 service 或 model-facing 协议。 - Desktop 按 `windowsCu` manifest 选择 helper,并校验目录内文件集合、大小和 - SHA-256;electron-builder 仅在 helper 存在时打包它。 -- 增加 `prepare-windows` artifact 准备命令。`distributionReady` 不能由命令行 - 参数直接打开,只能由 exact digest、CI run、Authenticode、clean-machine 和 - packaged conversation 证据共同计算。 + SHA-256;electron-builder 只在 `distributionReady=true` 时打包它,并在此时 + helper 缺失则直接失败。 +- 增加 `prepare-windows` artifact 准备命令。该本地命令固定保持 + `distributionReady=false`,不再信任调用者写入 provenance JSON 的布尔字段。 + 未来只能由机械验证 exact digest/attestation、Authenticode、clean-machine 和 + packaged conversation 的发布流水线开启。 +- source build 使用 `--locked --target x86_64-pc-windows-msvc`;安装包验证器在 + ready 时校验完整文件集合/大小/hash,并要求 Authenticode 状态为 `Valid`。 - 未带回旧 PR 的 generated JSON、raw outputs、experiments 或兼容输入代码。 ### Verification diff --git a/packages/computer-use/README.md b/packages/computer-use/README.md index 3c8a71e9a4..bf19fa9e4c 100644 --- a/packages/computer-use/README.md +++ b/packages/computer-use/README.md @@ -75,9 +75,20 @@ On Windows, Desktop reads the `windowsCu` entry from `apps/desktop/bundled-tools.json`, verifies the complete helper directory against its declared file digests, and uses the same `maka.cu/2` service. The helper preparation script is `node scripts/computer-use.mjs prepare-windows`. -Local preparation always leaves `distributionReady: false`; release readiness -requires evidence tied to the exact CI artifact, Authenticode signature, clean -machine run, and packaged conversation run. +Local preparation always leaves `distributionReady: false` and cannot promote +it by accepting a caller-authored provenance file. Release readiness requires a +separately reviewed pipeline that mechanically verifies the exact CI artifact, +GitHub attestation, Authenticode signature, clean-machine run, and packaged +conversation run. When readiness is eventually true, a missing helper fails +packaging and the packaged verifier checks the exact file set, sizes, digests, +and Authenticode status. + +Windows Computer Use intentionally owns only native desktop applications. Web +content is routed to Browser Use/OpenCLI, which can use browser-native page, +DOM/accessibility, tab, navigation, and command state with stronger targeting +and verification. This separation prevents duplicate browser automation and +keeps coordinate/global-input/foreground fallbacks out of the strict +background-only desktop contract. ## Protocol and lifecycle diff --git a/scripts/prepare-windows-cu-helper.mjs b/scripts/prepare-windows-cu-helper.mjs index d171d59125..a07593dae1 100644 --- a/scripts/prepare-windows-cu-helper.mjs +++ b/scripts/prepare-windows-cu-helper.mjs @@ -40,6 +40,7 @@ const PUBLISH_CONTRACT = { executor: 'rust-native-windows', protocol: 'maka.cu/2', runtimeIdentifier: 'win-x64', + rustTarget: 'x86_64-pc-windows-msvc', cargoProfile: 'release', lto: true, staticNativeDependencies: true, @@ -50,20 +51,13 @@ const PUBLISH_CONTRACT = { * Readiness is tied to the exact bytes and requires release evidence from CI, * Authenticode, a clean machine, and a packaged conversation run. */ -export function resolveWindowsCuDistributionReady(provenance, binarySha256) { - return Boolean( - provenance && - typeof provenance.executorCommit === 'string' && - /^[0-9a-f]{40}$/.test(provenance.executorCommit) && - typeof provenance.workflowRun === 'string' && - /^[1-9][0-9]*$/.test(provenance.workflowRun) && - provenance.artifactSha256 === binarySha256 && - typeof binarySha256 === 'string' && - /^[a-f0-9]{64}$/.test(binarySha256) && - provenance.signature === 'authenticode' && - provenance.cleanMachineE2e === true && - provenance.packagedConversationE2e === true, - ); +export function resolveWindowsCuDistributionReady() { + // This local preparation command cannot verify GitHub artifact attestation, + // Authenticode trust, clean-machine execution, or packaged conversation E2E. + // Treating a caller-authored JSON field as proof would turn provenance into + // an unsafe boolean escape hatch. A release workflow must qualify and write + // the manifest through a separately reviewed verifier. + return false; } export async function inspectWindowsCuArtifact(artifactDirectory) { @@ -115,10 +109,24 @@ async function publishFromSource(sourceRoot) { await mkdir(artifact, { recursive: true }); await exec( process.platform === 'win32' ? 'cargo.exe' : 'cargo', - ['build', '--release', '--manifest-path', manifest], + [ + 'build', + '--locked', + '--release', + '--target', + PUBLISH_CONTRACT.rustTarget, + '--manifest-path', + manifest, + ], { cwd: sourceRoot }, ); - const built = resolve(dirname(manifest), 'target/release/maka-cu-windows-rust.exe'); + const built = resolve( + dirname(manifest), + 'target', + PUBLISH_CONTRACT.rustTarget, + 'release', + 'maka-cu-windows-rust.exe', + ); if (!existsSync(built)) throw new Error(`Rust release binary was not produced: ${built}`); await cp(built, resolve(artifact, 'maka-cu-windows.exe')); await inspectWindowsCuArtifact(artifact); @@ -166,7 +174,7 @@ export async function prepareWindowsCuHelper({ source = process.env.MAKA_CU_WIND publishContract: PUBLISH_CONTRACT, provenance, // There is deliberately no --distribution-ready escape hatch. - distributionReady: resolveWindowsCuDistributionReady(provenance, hash), + distributionReady: resolveWindowsCuDistributionReady(), }; await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log( diff --git a/scripts/prepare-windows-cu-helper.test.mjs b/scripts/prepare-windows-cu-helper.test.mjs index 520e62aec2..ba1917229c 100644 --- a/scripts/prepare-windows-cu-helper.test.mjs +++ b/scripts/prepare-windows-cu-helper.test.mjs @@ -76,7 +76,7 @@ test('local preparation never enables distribution readiness', async () => { assert.equal(manifest.windowsCu.distributionReady, false); }); -test('distribution readiness requires evidence tied to the exact artifact', () => { +test('caller-authored provenance can never enable distribution readiness', () => { const hash = 'a'.repeat(64); const complete = { executorCommit: 'b'.repeat(40), @@ -86,7 +86,7 @@ test('distribution readiness requires evidence tied to the exact artifact', () = cleanMachineE2e: true, packagedConversationE2e: true, }; - assert.equal(resolveWindowsCuDistributionReady(complete, hash), true); + assert.equal(resolveWindowsCuDistributionReady(complete, hash), false); assert.equal( resolveWindowsCuDistributionReady({ ...complete, artifactSha256: 'c'.repeat(64) }, hash), false, diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 73341b6432..6bf7ca3f0e 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -407,6 +407,35 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () ); }); +test('Windows Computer Use packaging is fail-closed and readiness-gated', async () => { + const { windowsCuExtraResources } = await import('../apps/desktop/electron-builder.config.mjs'); + assert.deepEqual( + windowsCuExtraResources({ + platform: 'win32', + manifest: { windowsCu: { distributionReady: false } }, + helperExists: true, + }), + [], + ); + assert.throws( + () => + windowsCuExtraResources({ + platform: 'win32', + manifest: { windowsCu: { distributionReady: true } }, + helperExists: false, + }), + /distribution-ready.*missing/, + ); + assert.deepEqual( + windowsCuExtraResources({ + platform: 'win32', + manifest: { windowsCu: { distributionReady: true } }, + helperExists: true, + }), + [{ from: 'resources/bin/maka-cu-windows', to: 'bin/maka-cu-windows' }], + ); +}); + test('packaged third-party license sources are resolved, not assumed hoisted', () => { // electron and @fontsource-variable/geist* are declared by apps/desktop, so // `../../node_modules/` only resolves when the installer hoists them to diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 9936628fb4..9f5ed7f2e2 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1095,6 +1095,67 @@ export async function assertPackagedResources( } } +export async function assertPackagedWindowsCuResources( + resourcesPath, + { forbidPath = assertMissing } = {}, +) { + const manifest = JSON.parse(await readFile(join(resourcesPath, 'bundled-tools.json'), 'utf8')); + const entry = manifest.windowsCu; + const helperDirectory = join(resourcesPath, 'bin', 'maka-cu-windows'); + if (entry?.distributionReady !== true) { + await forbidPath(helperDirectory); + return { required: false }; + } + if (!Array.isArray(entry.files) || entry.files.length === 0) { + throw new Error('distribution-ready windowsCu has no pinned file manifest'); + } + const expected = new Map(); + for (const file of entry.files) { + if ( + typeof file?.name !== 'string' || + file.name.length === 0 || + file.name !== file.name.split(/[\\/]/u).at(-1) || + !Number.isSafeInteger(file.sizeBytes) || + file.sizeBytes < 0 || + typeof file.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(file.sha256) || + expected.has(file.name) + ) { + throw new Error('distribution-ready windowsCu has an invalid pinned file manifest'); + } + expected.set(file.name, file); + } + const actualEntries = await readdir(helperDirectory, { withFileTypes: true }); + if (actualEntries.some((item) => !item.isFile())) { + throw new Error('packaged Windows Computer Use helper must contain regular files only'); + } + const actualNames = actualEntries.map((item) => item.name).sort(); + const expectedNames = [...expected.keys()].sort(); + if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { + throw new Error('packaged Windows Computer Use helper file set does not match its manifest'); + } + for (const [name, pin] of expected) { + const bytes = await readFile(join(helperDirectory, name)); + if (bytes.byteLength !== pin.sizeBytes) { + throw new Error(`packaged Windows Computer Use helper size mismatch: ${name}`); + } + if (createHash('sha256').update(bytes).digest('hex') !== pin.sha256) { + throw new Error(`packaged Windows Computer Use helper digest mismatch: ${name}`); + } + } + const binaryName = entry.binaryName; + const binaryPin = expected.get(binaryName); + if ( + typeof binaryName !== 'string' || + !binaryPin || + binaryPin.sha256 !== entry.binarySha256 || + binaryPin.sizeBytes !== entry.binarySizeBytes + ) { + throw new Error('packaged Windows Computer Use binary pin is inconsistent'); + } + return { required: true, binaryPath: join(helperDirectory, binaryName) }; +} + /** * Recursive content manifest of a directory tree: POSIX-normalized relative * paths, sorted, each with its file's SHA-256. Nothing is skipped — an install diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index e5ada284f9..4e19a403f1 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -27,8 +28,49 @@ import { asarLookupPath, assertPackagedDependencyClosure, assertPackagedResources, + assertPackagedWindowsCuResources, } from './verify-packaged-app.mjs'; +test('packaged Windows Computer Use resources are readiness-gated and exactly pinned', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-windows-cu-package-')); + const resources = join(root, 'resources'); + const helperDirectory = join(resources, 'bin', 'maka-cu-windows'); + roots.push(root); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, 'bundled-tools.json'), + JSON.stringify({ windowsCu: { distributionReady: false } }), + ); + await assertPackagedWindowsCuResources(resources); + + const bytes = Buffer.from('signed-static-windows-helper'); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + await mkdir(helperDirectory, { recursive: true }); + await writeFile(join(helperDirectory, 'maka-cu-windows.exe'), bytes); + await writeFile( + join(resources, 'bundled-tools.json'), + JSON.stringify({ + windowsCu: { + distributionReady: true, + binaryName: 'maka-cu-windows.exe', + binarySizeBytes: bytes.length, + binarySha256: sha256, + files: [{ name: 'maka-cu-windows.exe', sizeBytes: bytes.length, sha256 }], + }, + }), + ); + assert.deepEqual(await assertPackagedWindowsCuResources(resources), { + required: true, + binaryPath: join(helperDirectory, 'maka-cu-windows.exe'), + }); + + await writeFile(join(helperDirectory, 'unexpected.dll'), Buffer.from('unexpected')); + await assert.rejects( + () => assertPackagedWindowsCuResources(resources), + /file set does not match/, + ); +}); + test('packaged resources forbid the retired bundled Git distribution', async () => { const required = []; const forbidden = []; diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 9f14c0b333..b2e891ec90 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -33,6 +33,7 @@ import { assertMissing, assertPackagedDependencyClosure, assertPackagedResources, + assertPackagedWindowsCuResources, isolatedUserEnv, makePtyProbe, runCommand, @@ -159,6 +160,26 @@ export async function verifyPackagedWindowsApp( requireAppIconCatalog: requiresCurrentContract, requireDirectPeerArtifact: requiresCurrentContract, }); + const windowsCu = await assertPackagedWindowsCuResources(resources, { forbidPath }); + if (windowsCu.required) { + step('verifying Windows Computer Use Authenticode signature'); + const signature = await run( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '(Get-AuthenticodeSignature -LiteralPath $args[0]).Status.ToString()', + windowsCu.binaryPath, + ], + { cwd: workingDirectory }, + ); + if (signature.stdout.trim() !== 'Valid') { + throw new Error( + `Windows Computer Use helper Authenticode status must be Valid, found ${signature.stdout.trim() || 'empty'}`, + ); + } + } // The upgrade baseline is a build that shipped on its own channel, from its // own commit: its update feed and dependency closure are the ones that were // right for it, not the ones this checkout expects.