Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release-windows-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,17 @@ jobs:
version="$(node -p "require('./apps/desktop/package.json').version")"
previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \
"$version" artifacts/windows-upgrade-baseline)"
previous_contract="$(node -p "require('./scripts/windows-upgrade-baseline.json').artifactContract")"
echo "exe=$previous_exe" >> "$GITHUB_OUTPUT"
echo "artifact_contract=$previous_contract" >> "$GITHUB_OUTPUT"

- name: Exercise pinned-version upgrade and uninstall
run: |
version="$(node -p "require('./apps/desktop/package.json').version")"
npm run verify:windows-installer -- \
"apps/desktop/release/Maka-${version}-win-x64.exe" \
"${{ steps.previous.outputs.exe }}"
"${{ steps.previous.outputs.exe }}" \
"${{ steps.previous.outputs.artifact_contract }}"

- name: Build the version-bumped autoupdate installer
run: npm run package:windows-autoupdate-next
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,17 @@ jobs:
run: |
previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \
"${{ needs.release-identity.outputs.version }}" artifacts/windows-upgrade-baseline)"
previous_contract="$(node -p "require('./scripts/windows-upgrade-baseline.json').artifactContract")"
echo "exe=$previous_exe" >> "$GITHUB_OUTPUT"
echo "artifact_contract=$previous_contract" >> "$GITHUB_OUTPUT"

- name: Exercise pinned Windows upgrade and uninstall
if: matrix.platform == 'windows'
run: |
npm run verify:windows-installer -- \
"apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \
"${{ steps.previous.outputs.exe }}"
"${{ steps.previous.outputs.exe }}" \
"${{ steps.previous.outputs.artifact_contract }}"

- name: Build the version-bumped autoupdate installer
if: matrix.platform == 'windows'
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down
50 changes: 48 additions & 2 deletions apps/desktop/src/main/__tests__/computer-use-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { chmod, copyFile, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { describe, it } from 'node:test';
import {
createComputerUseHost,
Expand Down Expand Up @@ -141,4 +141,50 @@ describe('Computer Use host health', () => {
}
});

it('resolves the Windows private helper entry with the same host hash gate', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-windows-host-'));
try {
const binaryPath = join(directory, 'maka-cu-windows.exe');
await copyFile(process.execPath, binaryPath);
const binaryBytes = await readFile(binaryPath);
const sidecarPath = join(directory, 'PresentationNative_cor3.dll');
const sidecarBytes = Buffer.from('native-sidecar');
await writeFile(sidecarPath, sidecarBytes);
const hash = createHash('sha256').update(binaryBytes).digest('hex');
const files = [
{ name: basename(binaryPath), sizeBytes: binaryBytes.byteLength, sha256: hash },
{
name: basename(sidecarPath),
sizeBytes: sidecarBytes.byteLength,
sha256: createHash('sha256').update(sidecarBytes).digest('hex'),
},
];
const manifestPath = join(directory, 'bundled-tools.json');
const writeManifest = (distributionReady: boolean) =>
writeFile(
manifestPath,
JSON.stringify({ windowsCu: { binarySha256: hash, files, distributionReady } }),
);
await writeManifest(false);
const development = createComputerUseHost({ isPackaged: false, resourcesPath: directory, manifestPath, binaryPath, platform: 'win32', physicalInputRecentlyActive: () => false });
assert.equal(development.selected.backendId, 'windows-native');
await writeManifest(true);
const packaged = createComputerUseHost({ isPackaged: true, resourcesPath: directory, manifestPath, binaryPath, platform: 'win32', physicalInputRecentlyActive: () => false });
assert.equal(packaged.selected.backendId, 'windows-native');
await rm(sidecarPath);
const missing = createComputerUseHost({ isPackaged: false, resourcesPath: directory, manifestPath, binaryPath, platform: 'win32', physicalInputRecentlyActive: () => false });
assert.equal(missing.selected.backendId, 'none');
await writeFile(sidecarPath, sidecarBytes);
await writeFile(join(directory, 'unexpected.dll'), 'unmanaged');
const extra = createComputerUseHost({ isPackaged: false, resourcesPath: directory, manifestPath, binaryPath, platform: 'win32', physicalInputRecentlyActive: () => false });
assert.equal(extra.selected.backendId, 'none');
await rm(join(directory, 'unexpected.dll'));
await writeFile(sidecarPath, 'tampered');
const tampered = createComputerUseHost({ isPackaged: false, resourcesPath: directory, manifestPath, binaryPath, platform: 'win32', physicalInputRecentlyActive: () => false });
assert.equal(tampered.selected.backendId, 'none');
} finally {
await rm(directory, { recursive: true, force: true });
}
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,14 @@ test('publishes the real Computer Use schema through the Client Capability proto
}),
);
const coordinateSchema = provider.offers()[0]?.tools[0]?.inputSchema.properties as
| Record<string, { items?: unknown }>
| Record<string, { type?: string; items?: unknown; minItems?: number; maxItems?: number }>
| undefined;
assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true);
assert.equal(coordinateSchema?.coordinate?.type, 'array');
assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), false);
assert.equal(typeof coordinateSchema?.coordinate?.items, 'object');
assert.notEqual(coordinateSchema?.coordinate?.items, null);
assert.equal(coordinateSchema?.coordinate?.minItems, 2);
assert.equal(coordinateSchema?.coordinate?.maxItems, 2);
});

test('publishes every production Desktop-owned tool schema through the protocol', () => {
Expand Down
113 changes: 84 additions & 29 deletions apps/desktop/src/main/computer-use-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import {
fstatSync,
openSync,
readFileSync,
readdirSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { MakaCuBackendOptions } from '@maka/computer-use';
import type { MakaCuServiceSnapshot } from '@maka/computer-use';
import type { WindowsCuServiceState } from '@maka/computer-use';
import {
selectComputerUseBackend,
type SelectedComputerUseBackend,
Expand All @@ -42,6 +44,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 {
Expand All @@ -54,6 +65,39 @@ function readRegularFile(path: string): Buffer {
}
}

function hasPinnedWindowsHelperFiles(
binaryPath: string,
files: NonNullable<BundledToolManifest['windowsCu']>['files'],
): boolean {
if (!files || files.length === 0) return false;
const expected = new Map<string, { sizeBytes: number; sha256: string }>();
for (const file of files) {
const sizeBytes = file.sizeBytes;
const sha256 = file.sha256;
if (
!file.name || file.name !== file.name.split(/[\\/]/).pop() ||
typeof sizeBytes !== 'number' || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0 ||
typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256) || expected.has(file.name)
) return false;
expected.set(file.name, { sizeBytes, sha256 });
}
const directory = dirname(binaryPath);
const actual = readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name !== 'bundled-tools.json')
.map((entry) => entry.name);
if (actual.length !== expected.size || actual.some((name) => !expected.has(name))) return false;
for (const [name, pin] of expected) {
try {
const bytes = readRegularFile(join(directory, 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;
Expand All @@ -68,6 +112,7 @@ export function createComputerUseHost(input: {
screenLocked?: (context: { sessionId: string }) => boolean | Promise<boolean>;
onTrace?: MakaCuBackendOptions['onTrace'];
overlay?: CuOverlayHook;
platform?: NodeJS.Platform;
}): ComputerUseHostState {
const manifestPath = input.manifestPath ?? (input.isPackaged
? join(input.resourcesPath, 'bundled-tools.json')
Expand All @@ -77,36 +122,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')
.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,
Expand All @@ -120,12 +171,13 @@ export function createComputerUseHost(input: {
...(input.screenLocked ? { screenLocked: input.screenLocked } : {}),
...(input.onTrace ? { onTrace: input.onTrace } : {}),
...(input.overlay ? { overlay: input.overlay } : {}),
...(input.platform ? { platform: input.platform } : {}),
}),
binaryPath,
expectedBinarySha256,
};
} catch {
return { selected: selectComputerUseBackend() };
return { selected: selectComputerUseBackend({ platform }) };
}
}

Expand All @@ -144,28 +196,31 @@ export function createDesktopPhysicalInputGuard(
*/
export function computerUseServiceHealth(
backendId: SelectedComputerUseBackend['backendId'],
state: MakaCuServiceSnapshot | undefined,
state: MakaCuServiceSnapshot | { state: WindowsCuServiceState; generation: number } | undefined,
): {
state: 'not_available' | 'not_run' | 'healthy' | 'degraded';
reason: string;
} {
const label = backendId === 'windows-native' ? 'Windows native helper' : 'maka-cu executor';
if (backendId === 'none' || !state) {
return {
state: 'not_available',
reason: '未找到通过完整性检查且可分发的 maka-cu executor。',
reason: backendId === 'windows-native'
? '未找到通过完整性检查且可分发的 Windows native helper。'
: '未找到通过完整性检查且可分发的 maka-cu executor。',
};
}
switch (state.state) {
case 'disposed':
return { state: 'not_available', reason: 'maka-cu executor 已停止。' };
return { state: 'not_available', reason: `${label} 已停止。` };
case 'unavailable':
return { state: 'not_available', reason: 'maka-cu executor 启动失败或已退出。' };
return { state: 'not_available', reason: `${label} 启动失败或已退出。` };
case 'starting':
case 'backing_off':
return { state: 'degraded', reason: 'maka-cu executor 正在启动或恢复。' };
return { state: 'degraded', reason: `${label} 正在启动或恢复。` };
case 'ready':
return { state: 'healthy', reason: 'maka-cu executor 已就绪。' };
return { state: 'healthy', reason: `${label} 已就绪。` };
default:
return { state: 'not_run', reason: 'maka-cu 已可用,将在首次调用时启动。' };
return { state: 'not_run', reason: `${backendId === 'windows-native' ? 'Windows helper' : 'maka-cu'} 已可用,将在首次调用时启动。` };
}
}
29 changes: 28 additions & 1 deletion docs/windows-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@

# Windows support baseline

## Native Computer Use helper

Windows Computer Use uses the C#/.NET `maka.cu.windows/0` helper. The desktop
host starts it as a direct stdio child, verifies the SHA-256 and size pins for
the executable and every native companion recorded in
`apps/desktop/bundled-tools.json`, and invalidates observations whenever the
helper generation exits or restarts. The protocol is private to Windows and
does not reuse the macOS `maka.cu/2` executor.

The v1 surface is deliberately closed: enumerate and explicitly select one
running top level window, read its UI Automation tree, capture that window with
Windows Graphics Capture, and invoke `set_value` or `click_element` using a
single use snapshot token. Ambiguous app/window matches fail closed. Global
input, screen rectangle capture, post-message, and unsupported actions are not
fallbacks. A packaged build is enabled only when the Windows manifest entry
sets `distributionReady: true`; development artifacts are prepared with
`MAKA_CU_WINDOWS_SOURCE=<path-to-maka-cu> node scripts/prepare-windows-cu-helper.mjs`.
The script follows the native publish contract (`win-x64`, self-contained,
single-file, uncompressed, untrimmed, embedded debug), copies the executable
and required Windows Desktop native companion files together, rejects the
small framework-dependent apphost, and remains distribution-ineligible by
default.

Windows is an active enablement target, not a fully supported Maka platform yet. The CLI and Electron desktop application can run from source, and release workflows produce a verified unsigned Windows x64 preview. The x64 package includes an AppContainer sandbox for the managed filesystem-worker surface, with packaged lifecycle and adversarial evidence, and automatic updates are verified end to end in CI on the unsigned preview channel. Signing, the wider general-command sandbox tier, direct Credential Manager/DPAPI probes, independent security review, and computer-use guarantees remain incomplete. Progress is tracked in [GitHub issue #2142](https://github.com/apache/maka/issues/2142).

## Install the Windows x64 preview
Expand Down Expand Up @@ -227,7 +250,11 @@ The root test timeout is tracked separately from individual test failures. Phase
host named pipes, ambient environment, host registry values, parent tokens, and descendant
denial or AppContainer/Job inheritance. It does not claim UDP/DNS/SMB enforcement, local inbound-listener enforcement, the deferred
no-Win32k/window-station tier, or direct Credential Manager/DPAPI isolation.
- Computer-use has no Windows backend.
- Computer Use has a Windows native backend when a pinned helper digest is
present. Packaged builds also require `distributionReady: true`; source
development artifacts remain opt-in and do not establish full Windows
support. The product integration is tracked in [issue #4318](https://github.com/apache/maka/issues/4318),
with related executor hardening in [issue #3785](https://github.com/apache/maka/issues/3785).
- The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater →
NSIS handoff → relaunch) is verified end to end in CI against a loopback feed; the production
GitHub feed configuration is pinned by unit tests. Updates are not signature-verified until an
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"check:model-metadata": "node scripts/sync-model-metadata.mjs --check",
"generate:bundled-skills": "node scripts/gen-bundled-skill-catalog.mjs",
"computer-use": "node scripts/computer-use.mjs",
"prepare:windows-cu-helper": "node scripts/prepare-windows-cu-helper.mjs",
"windows:inventory": "node --test scripts/windows-test-inventory.test.mjs && node scripts/windows-test-inventory.mjs --check",
"windows:inventory:write": "node scripts/windows-test-inventory.mjs --write",
"smoke:windows": "npm run build && npm run smoke:windows:dist",
Expand Down
Loading
Loading