diff --git a/apps/desktop/bundled-tools.json b/apps/desktop/bundled-tools.json index be0156603b..9adbee30fe 100644 --- a/apps/desktop/bundled-tools.json +++ b/apps/desktop/bundled-tools.json @@ -12,5 +12,21 @@ "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", + "rustTarget": "x86_64-pc-windows-msvc", + "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..bfd1a9f2f4 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 { @@ -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,6 +155,7 @@ const baseDesktopBuilderConfig = { }, ...(process.platform === 'win32' ? [ + ...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 51c89b636e..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'; @@ -141,4 +141,46 @@ 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'); + + 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 5b5c964b74..8ec6e29ea4 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 { + const entries = readdirSync(dirname(binaryPath), { withFileTypes: true }); + if (entries.some((entry) => !entry.isFile())) return false; + actual = entries.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,36 +130,49 @@ 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') .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, @@ -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..ebdd8cb746 --- /dev/null +++ b/docs/exec-plans/active/20260903-windows-cu2-integration-replacement.md @@ -0,0 +1,65 @@ + + +# 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 `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. + +## 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, 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 + +- `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. +- 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 new file mode 100644 index 0000000000..ee0c01ffe7 --- /dev/null +++ b/docs/histories/2026-09/20260903-windows-cu2-integration-replacement.md @@ -0,0 +1,43 @@ + + +## [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 只在 `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 + +- `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..bf19fa9e4c 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,25 @@ 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` 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 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/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`, ); }); diff --git a/scripts/prepare-windows-cu-helper.mjs b/scripts/prepare-windows-cu-helper.mjs new file mode 100644 index 0000000000..a07593dae1 --- /dev/null +++ b/scripts/prepare-windows-cu-helper.mjs @@ -0,0 +1,193 @@ +#!/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', + rustTarget: 'x86_64-pc-windows-msvc', + 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() { + // 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) { + 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', + '--locked', + '--release', + '--target', + PUBLISH_CONTRACT.rustTarget, + '--manifest-path', + manifest, + ], + { cwd: sourceRoot }, + ); + 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); + 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(), + }; + 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..ba1917229c --- /dev/null +++ b/scripts/prepare-windows-cu-helper.test.mjs @@ -0,0 +1,95 @@ +/* + * 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. + */ + +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('caller-authored provenance can never enable distribution readiness', () => { + 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), false); + assert.equal( + resolveWindowsCuDistributionReady({ ...complete, artifactSha256: 'c'.repeat(64) }, hash), + false, + ); + assert.equal(resolveWindowsCuDistributionReady(undefined, 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.