diff --git a/apps/desktop/src/main/__tests__/computer-use-capability-reasons.test.ts b/apps/desktop/src/main/__tests__/computer-use-capability-reasons.test.ts new file mode 100644 index 0000000000..4b5060e0f8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/computer-use-capability-reasons.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { describe, it } from 'node:test'; +import { computerUseServiceHealth } from '../computer-use-host.js'; +import { computerUseCapabilityReasons } from '../computer-use-capability-reasons.js'; + +describe('Computer Use capability row', () => { + const none = ( + reason: 'unsupported_platform' | 'missing_executable' | 'backend_failed' | undefined, + ) => + computerUseCapabilityReasons({ + backendId: 'none', + health: computerUseServiceHealth('none', undefined, reason), + }); + + it('reports an unbound platform as a platform fact, not an integrity failure', () => { + assert.deepEqual(none('unsupported_platform'), { + feature: 'cu_platform_unsupported', + probe: 'cu_platform_unsupported', + }); + }); + + it('keeps the three ways to have no backend apart', () => { + const rows = [ + none('unsupported_platform'), + none('missing_executable'), + none('backend_failed'), + none(undefined), + ]; + assert.deepEqual(rows, [ + { feature: 'cu_platform_unsupported', probe: 'cu_platform_unsupported' }, + { feature: 'cu_executor_undistributable', probe: 'cu_executor_undistributable' }, + { feature: 'cu_backend_unavailable', probe: 'cu_backend_unavailable' }, + { feature: 'cu_executor_undistributable', probe: 'cu_executor_undistributable' }, + ]); + }); + + it('states the artifact reason only for a snapshot that never reached Computer Use', () => { + assert.deepEqual(computerUseCapabilityReasons(undefined), { + feature: 'cu_artifact_missing', + probe: 'cu_backend_unavailable', + }); + }); + + it('reports the artifact and the probe separately while an executor is selected', () => { + assert.deepEqual( + computerUseCapabilityReasons({ + backendId: 'maka-cu', + health: computerUseServiceHealth('maka-cu', { + state: 'ready', + generation: 1, + restartAttempts: 0, + }), + }), + { feature: 'cu_backend_status', probe: 'cu_executor_ready' }, + ); + }); +}); 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 3fe5bd3a1e..6623796365 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -60,8 +60,26 @@ describe('Computer Use host health', () => { }); }); - it('reports a missing backend as unavailable', () => { - assert.equal(computerUseServiceHealth('none', undefined).state, 'not_available'); + it('keeps the three ways to have no backend apart in the projected reason', () => { + assert.deepEqual( + [ + computerUseServiceHealth('none', undefined, 'unsupported_platform'), + computerUseServiceHealth('none', undefined, 'missing_executable'), + computerUseServiceHealth('none', undefined, 'backend_failed'), + ], + [ + { state: 'not_available', reason: 'cu_platform_unsupported' }, + { state: 'not_available', reason: 'cu_executor_undistributable' }, + { state: 'not_available', reason: 'cu_backend_unavailable' }, + ], + ); + }); + + it('reports a missing backend with no typed reason as undistributable', () => { + assert.deepEqual(computerUseServiceHealth('none', undefined), { + state: 'not_available', + reason: 'cu_executor_undistributable', + }); }); it('constructs a backend only when the local artifact matches the manifest hash', async () => { @@ -78,17 +96,17 @@ describe('Computer Use host health', () => { })); const validForDevelopment = createComputerUseHost({ + platform: 'darwin', isPackaged: false, resourcesPath: directory, manifestPath, binaryPath, physicalInputRecentlyActive: () => false, }); - assert.equal(validForDevelopment.selected.backendId, process.platform === 'darwin' - ? 'maka-cu' - : 'none'); + assert.equal(validForDevelopment.selected.backendId, 'maka-cu'); const blockedForDistribution = createComputerUseHost({ + platform: 'darwin', isPackaged: true, resourcesPath: directory, manifestPath, @@ -101,15 +119,14 @@ describe('Computer Use host health', () => { makaCu: { binarySha256: hash, distributionReady: true }, })); const validForDistribution = createComputerUseHost({ + platform: 'darwin', isPackaged: true, resourcesPath: directory, manifestPath, binaryPath, physicalInputRecentlyActive: () => false, }); - assert.equal(validForDistribution.selected.backendId, process.platform === 'darwin' - ? 'maka-cu' - : 'none'); + assert.equal(validForDistribution.selected.backendId, 'maka-cu'); await writeFile(manifestPath, JSON.stringify({ makaCu: { @@ -118,6 +135,7 @@ describe('Computer Use host health', () => { }, })); const invalid = createComputerUseHost({ + platform: 'darwin', isPackaged: false, resourcesPath: directory, manifestPath, @@ -129,6 +147,7 @@ describe('Computer Use host health', () => { const linkedBinaryPath = join(directory, 'linked-maka-cu'); await symlink(binaryPath, linkedBinaryPath); const linked = createComputerUseHost({ + platform: 'darwin', isPackaged: false, resourcesPath: directory, manifestPath, @@ -141,4 +160,49 @@ describe('Computer Use host health', () => { } }); + it('fails closed on a platform with no executor binding even when a binary is pinned', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-cu-host-platform-')); + try { + const binaryPath = join(directory, 'maka-cu'); + const manifestPath = join(directory, 'bundled-tools.json'); + const bytes = Buffer.from('#!/bin/sh\nexit 0\n'); + await writeFile(binaryPath, bytes); + await chmod(binaryPath, 0o755); + const hash = createHash('sha256').update(bytes).digest('hex'); + await writeFile(manifestPath, JSON.stringify({ + makaCu: { binarySha256: hash, distributionReady: true }, + })); + + for (const platform of ['linux', 'win32', 'freebsd'] as const) { + const selected = createComputerUseHost({ + platform, + isPackaged: false, + resourcesPath: directory, + manifestPath, + binaryPath, + physicalInputRecentlyActive: () => false, + }); + assert.equal(selected.selected.backendId, 'none'); + assert.equal( + selected.selected.unavailableReason, + 'unsupported_platform', + `${platform} must report a typed unsupported selection`, + ); + // The same projection the Desktop boot feeds the capability snapshot: + // an unbound platform must not read as an integrity failure. + assert.equal( + computerUseServiceHealth( + selected.selected.backendId, + selected.selected.backend?.executorState?.(), + selected.selected.unavailableReason, + ).reason, + 'cu_platform_unsupported', + `${platform} must reach the capability surface as a platform fact`, + ); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + }); diff --git a/apps/desktop/src/main/capability-snapshot.ts b/apps/desktop/src/main/capability-snapshot.ts index 3fed88bb2f..679370bdb9 100644 --- a/apps/desktop/src/main/capability-snapshot.ts +++ b/apps/desktop/src/main/capability-snapshot.ts @@ -38,6 +38,7 @@ import { import { type AppSettings } from '@maka/core/settings'; import type { CuBackendId } from '@maka/computer-use'; import type { BotStatus } from '@maka/runtime/bots'; +import { computerUseCapabilityReasons } from './computer-use-capability-reasons.js'; import type { computerUseServiceHealth } from './computer-use-host.js'; import { mapMediaAccessStatus, @@ -132,6 +133,7 @@ function computerUseCapability( // read `not_available` for a machine that had a working backend, merely a // different one. const artifactAvailable = input !== undefined && input.backendId !== 'none'; + const reasons = computerUseCapabilityReasons(input); return staticCapability({ id: 'computer_use', label: 'Computer Use', @@ -139,7 +141,7 @@ function computerUseCapability( feature: { state: artifactAvailable ? 'enabled' : 'not_available', source: 'runtime', - reason: input === undefined || input.backendId === 'none' ? 'cu_artifact_missing' : 'cu_backend_status', + reason: reasons.feature, }, requiredPermissions: [ { id: 'accessibility', required: true, status: permissions.accessibility.status }, @@ -154,7 +156,7 @@ function computerUseCapability( state: input?.health.state ?? 'not_available', source: 'runtime_probe', lastCheckedAt: now, - reason: input?.health.reason ?? 'cu_backend_unavailable', + reason: reasons.probe, }, }); } diff --git a/apps/desktop/src/main/computer-use-capability-reasons.ts b/apps/desktop/src/main/computer-use-capability-reasons.ts new file mode 100644 index 0000000000..c1bb928f1f --- /dev/null +++ b/apps/desktop/src/main/computer-use-capability-reasons.ts @@ -0,0 +1,47 @@ +/* + * 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 { type CuBackendId } from '@maka/computer-use'; +import type { CapabilityReasonCode } from '@maka/core/capabilities'; + +interface ComputerUseCapabilityReasonInput { + backendId: CuBackendId | 'none'; + health: { reason: CapabilityReasonCode }; +} + +/** + * What the Computer Use capability row says, kept out of the snapshot module's + * Electron import graph so the projection that produces the copy is tested on + * every CI OS. + * + * While no executor is selected both layers name the same cause: a bare + * `cu_artifact_missing` made an unbound platform read as a failed integrity + * check, which is the distinction the selection's reason exists to preserve. + * `cu_artifact_missing` stays for a snapshot that never reached this capability + * at all. + */ +export function computerUseCapabilityReasons( + input: ComputerUseCapabilityReasonInput | undefined, +): { feature: CapabilityReasonCode; probe: CapabilityReasonCode } { + if (!input) return { feature: 'cu_artifact_missing', probe: 'cu_backend_unavailable' }; + if (input.backendId === 'none') { + return { feature: input.health.reason, probe: input.health.reason }; + } + return { feature: 'cu_backend_status', probe: input.health.reason }; +} diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index aefef19cec..90ee322d3e 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -60,6 +60,8 @@ export function createComputerUseHost(input: { resourcesPath: string; manifestPath?: string; binaryPath?: string; + /** Test/host seam; production defaults to Node's platform. */ + platform?: NodeJS.Platform; compressFrame?: ( base64: string, mimeType: string, @@ -70,6 +72,7 @@ export function createComputerUseHost(input: { onTrace?: MakaCuBackendOptions['onTrace']; overlay?: CuOverlayHook; }): ComputerUseHostState { + const platform = input.platform ?? process.platform; const manifestPath = input.manifestPath ?? (input.isPackaged ? join(input.resourcesPath, 'bundled-tools.json') : resolve( @@ -97,17 +100,17 @@ export function createComputerUseHost(input: { }; const expectedBinarySha256 = manifest.makaCu?.binarySha256; if (input.isPackaged && manifest.makaCu?.distributionReady !== true) { - return { selected: selectComputerUseBackend() }; + return { selected: selectComputerUseBackend({ platform }) }; } if (!expectedBinarySha256 || !/^[a-f0-9]{64}$/.test(expectedBinarySha256)) { - return { selected: selectComputerUseBackend() }; + 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, @@ -121,12 +124,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 }) }; } } @@ -136,6 +140,22 @@ export function createDesktopPhysicalInputGuard( return () => getSystemIdleTime() < 1; } +/** + * Why a `none` selection is none, as a code the capability surface can show. + * + * Three ways to have no executor used to project one word, so an unbound + * platform, a missing artifact and a backend that failed to construct all read + * as an integrity problem. The distinction is the point of carrying the reason + * this far; a `none` without one is still an undistributable artifact. + */ +function unavailableReasonCode( + reason: SelectedComputerUseBackend['unavailableReason'], +): CapabilityReasonCode { + if (reason === 'unsupported_platform') return 'cu_platform_unsupported'; + if (reason === 'backend_failed') return 'cu_backend_unavailable'; + return 'cu_executor_undistributable'; +} + /** * One executor, one state. * @@ -146,11 +166,15 @@ export function createDesktopPhysicalInputGuard( export function computerUseServiceHealth( backendId: SelectedComputerUseBackend['backendId'], state: MakaCuServiceSnapshot | undefined, + unavailableReason?: SelectedComputerUseBackend['unavailableReason'], ): { state: 'not_available' | 'not_run' | 'healthy' | 'degraded'; reason: CapabilityReasonCode; } { - if (backendId === 'none' || !state) { + if (backendId === 'none') { + return { state: 'not_available', reason: unavailableReasonCode(unavailableReason) }; + } + if (!state) { return { state: 'not_available', reason: 'cu_executor_undistributable' }; } switch (state.state) { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a2179ed2e3..2c2f8043f0 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1730,6 +1730,7 @@ function registerHostClientIpc( health: computerUseServiceHealth( native.computerUse.backendId, executorState, + native.computerUse.unavailableReason, ), }; }, diff --git a/apps/desktop/src/renderer/locales/capability-reason-copy.ts b/apps/desktop/src/renderer/locales/capability-reason-copy.ts index 1178ebb03d..e5f4b2dbee 100644 --- a/apps/desktop/src/renderer/locales/capability-reason-copy.ts +++ b/apps/desktop/src/renderer/locales/capability-reason-copy.ts @@ -31,6 +31,7 @@ const CAPABILITY_REASON_COPY = { cu_artifact_missing: '未找到通过完整性检查的 Computer Use 执行器 artifact。', cu_backend_status: 'maka-cu artifact 已通过本地完整性检查。', cu_backend_unavailable: 'Computer Use 后端当前不可用。', + cu_platform_unsupported: '当前平台不支持 Computer Use,未提供该平台的执行器。', cu_executor_undistributable: '未找到通过完整性检查且可分发的 maka-cu executor。', cu_executor_stopped: 'maka-cu executor 已停止。', cu_executor_start_failed: 'maka-cu executor 启动失败或已退出。', @@ -56,6 +57,7 @@ const CAPABILITY_REASON_COPY = { cu_artifact_missing: '找不到通過完整性檢查的 Computer Use 執行器 artifact。', cu_backend_status: 'maka-cu artifact 已通過本機完整性檢查。', cu_backend_unavailable: 'Computer Use 後端目前無法使用。', + cu_platform_unsupported: '目前平台不支援 Computer Use,未提供該平台的執行器。', cu_executor_undistributable: '找不到通過完整性檢查且可分發的 maka-cu executor。', cu_executor_stopped: 'maka-cu executor 已停止。', cu_executor_start_failed: 'maka-cu executor 啟動失敗或已退出。', @@ -81,6 +83,7 @@ const CAPABILITY_REASON_COPY = { cu_artifact_missing: 'No Computer Use executor artifact passed the integrity check.', cu_backend_status: 'The maka-cu artifact passed the local integrity check.', cu_backend_unavailable: 'The Computer Use backend is currently unavailable.', + cu_platform_unsupported: 'Computer Use is not supported on this platform, and no executor is provisioned for it.', cu_executor_undistributable: 'No distributable maka-cu executor passed the integrity check.', cu_executor_stopped: 'The maka-cu executor has stopped.', cu_executor_start_failed: 'The maka-cu executor failed to start or has exited.', diff --git a/packages/computer-use/README.md b/packages/computer-use/README.md index 9eb3be9f2c..65afe04d1f 100644 --- a/packages/computer-use/README.md +++ b/packages/computer-use/README.md @@ -55,8 +55,11 @@ hold: 3. the composition supplies the executable's expected SHA-256 digest. On another platform, with missing inputs, or when backend construction fails, -selection fails closed to `backendId: 'none'` with an empty tool set. This -package does not discover, download, or choose an unpinned executable. +selection fails closed to `backendId: 'none'` with an empty tool set and a +typed `unavailableReason` (`unsupported_platform`, `missing_executable`, or +`backend_failed`) so a capability UI can say *why* Computer Use is off rather +than silently treating three different states as the same absence. This package +does not discover, download, or choose an unpinned executable. The executable's build, provenance, signing, and distribution status are separate release concerns. See @@ -70,6 +73,30 @@ Cross-platform work is tracked separately: - [#3785](https://github.com/apache/maka/issues/3785) — Windows executor hardening and production evidence. +## Platform abstraction + +`CuDispatchBackend` (owned by Runtime) is the model-facing platform seam; +`selectComputerUseBackend()` is the selection seam. Selection consults +`CU_PLATFORM_BACKEND_BINDINGS`, a one-row-per-native-platform table mapping a +platform to the executor id it is allowed to run. The table is deliberately not +a per-platform backend catalogue: macOS, Windows and a future Linux executor +all speak the same `maka.cu/2` contract and are supervised by the same +`MakaCuService` lifecycle, so the platform's only job is to prove it has a +pinned native executable behind that contract. + +There are no Linux/Windows placeholder backends that succeed or no-op, and no +empty `maka.cu`-shaped stub is exported for an OS without an executor. A +platform without a binding selects `none` with +`unavailableReason: 'unsupported_platform'` — the capability is visibly absent, +never silently inert. Adding a platform means adding its native executor and +Desktop artifact provenance, then registering the binding; it never means +copying the supervisor or protocol adapter. + +`selectComputerUseBackend({ platform })` and +`createComputerUseHost({ platform })` are test seams. Production callers omit +them and Node's own platform is used, but tests inject `darwin` so selector and +host assertions run on every CI OS instead of being skipped off-macOS. + ## Protocol and lifecycle The host and executor communicate over line-delimited JSON-RPC using the diff --git a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts index 1c3c7b0265..701f9f5e60 100644 --- a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts +++ b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts @@ -1544,7 +1544,6 @@ describe('maka-cu backend', () => { describe('maka-cu backend selection', () => { it('is reached by being named, and refuses without a pinned digest', () => { - if (process.platform !== 'darwin') return; let made = 0; const stub = () => ({ preflight: async () => ({ accessibility: false, screenRecording: false }), @@ -1555,6 +1554,7 @@ describe('maka-cu backend selection', () => { }; const selected = selectComputerUseBackend({ + platform: 'darwin', backendId: 'maka-cu', binaryPath: '/tmp/does-not-matter', expectedBinarySha256: 'deadbeef', @@ -1566,6 +1566,7 @@ describe('maka-cu backend selection', () => { // No digest, no executor — and `'none'` rather than a backend that would // spawn whatever happens to be at that path. const unpinned = selectComputerUseBackend({ + platform: 'darwin', backendId: 'maka-cu', binaryPath: '/tmp/does-not-matter', createBackend, @@ -1579,10 +1580,10 @@ describe('maka-cu backend selection', () => { // "which executor runs" is a decision the selector owns, and a host that // names nothing must land on the same one the constant names. If the two // ever disagree, a machine runs an executor nobody chose. - if (process.platform !== 'darwin') return; assert.equal(DEFAULT_CU_BACKEND_ID, 'maka-cu'); let made = 0; const selected = selectComputerUseBackend({ + platform: 'darwin', binaryPath: '/tmp/does-not-matter', expectedBinarySha256: 'deadbeef', createBackend: () => { diff --git a/packages/computer-use/src/__tests__/select-backend-host-events.test.ts b/packages/computer-use/src/__tests__/select-backend-host-events.test.ts index 9dd5201bd3..39185adc9d 100644 --- a/packages/computer-use/src/__tests__/select-backend-host-events.test.ts +++ b/packages/computer-use/src/__tests__/select-backend-host-events.test.ts @@ -24,7 +24,6 @@ import type { MakaCuBackendOptions } from '../maka-cu-backend.js'; import { selectComputerUseBackend } from '../select-backend.js'; test('service invalidation producer advances Runtime to reobserve', async () => { - if (process.platform !== 'darwin') return; let invalidate: | ((input: { sessionId: string; reason: 'child_exit'; outcomeUnknown: boolean }) => void) | undefined; @@ -46,6 +45,7 @@ test('service invalidation producer advances Runtime to reobserve', async () => }, }; const selected = selectComputerUseBackend({ + platform: 'darwin', binaryPath: '/tmp/fake-executor', expectedBinarySha256: '0'.repeat(64), createBackend(options) { @@ -79,7 +79,6 @@ test('service invalidation producer advances Runtime to reobserve', async () => }); test('physical input policy is passed to the selected backend', () => { - if (process.platform !== 'darwin') return; const physicalInputRecentlyActive = () => true; let received: MakaCuBackendOptions['physicalInputRecentlyActive']; const backend: CuDispatchBackend = { @@ -91,6 +90,7 @@ test('physical input policy is passed to the selected backend', () => { }, }; selectComputerUseBackend({ + platform: 'darwin', binaryPath: '/tmp/fake-executor', expectedBinarySha256: '0'.repeat(64), physicalInputRecentlyActive, @@ -101,3 +101,57 @@ test('physical input policy is passed to the selected backend', () => { }); assert.equal(received, physicalInputRecentlyActive); }); + +test('platforms without a binding fail closed instead of no-oping', () => { + let made = 0; + for (const platform of ['linux', 'win32', 'freebsd'] as const) { + const selected = selectComputerUseBackend({ + platform, + binaryPath: '/tmp/fake-executor', + expectedBinarySha256: '0'.repeat(64), + createBackend: () => { + made += 1; + return { + preflight: async () => ({ accessibility: false, screenRecording: false }), + } as never; + }, + }); + assert.equal(selected.backendId, 'none'); + assert.equal(selected.backend, undefined); + assert.equal(selected.tools.length, 0); + assert.equal(selected.unavailableReason, 'unsupported_platform'); + } + assert.equal(made, 0, 'an unsupported platform must never reach backend construction'); +}); + +test('typed reasons separate a missing executable from a failed backend', () => { + const unpinned = selectComputerUseBackend({ platform: 'darwin' }); + assert.equal(unpinned.backendId, 'none'); + assert.equal(unpinned.unavailableReason, 'missing_executable'); + + const failed = selectComputerUseBackend({ + platform: 'darwin', + binaryPath: '/tmp/fake-executor', + expectedBinarySha256: '0'.repeat(64), + createBackend() { + throw new Error('construct failed'); + }, + }); + assert.equal(failed.backendId, 'none'); + assert.equal(failed.unavailableReason, 'backend_failed'); +}); + +test('the platform seam makes Darwin selection assertions run on every CI OS', () => { + const selected = selectComputerUseBackend({ + platform: 'darwin', + binaryPath: '/tmp/fake-executor', + expectedBinarySha256: '0'.repeat(64), + createBackend() { + return { + preflight: async () => ({ accessibility: true, screenRecording: true }), + } as never; + }, + }); + assert.equal(selected.backendId, 'maka-cu'); + assert.equal(selected.unavailableReason, undefined); +}); diff --git a/packages/computer-use/src/index.ts b/packages/computer-use/src/index.ts index 4503ccb61b..f5ce26167b 100644 --- a/packages/computer-use/src/index.ts +++ b/packages/computer-use/src/index.ts @@ -20,11 +20,13 @@ export { selectComputerUseBackend, CU_BACKEND_IDS, + CU_PLATFORM_BACKEND_BINDINGS, DEFAULT_CU_BACKEND_ID, } from './select-backend.js'; export type { ComputerUseBackendSelection, CuBackendId, + CuPlatformBackendBinding, MakaCuSelection, SelectedComputerUseBackend, } from './select-backend.js'; diff --git a/packages/computer-use/src/maka-cu-service.ts b/packages/computer-use/src/maka-cu-service.ts index 08e502d13d..682d86d023 100644 --- a/packages/computer-use/src/maka-cu-service.ts +++ b/packages/computer-use/src/maka-cu-service.ts @@ -17,17 +17,24 @@ * under the License. */ -// Supervises one `maka-cu` executor child and speaks `maka.cu/2` to it over -// line-delimited JSON-RPC 2.0 on stdio (`maka-cu`'s docs/HOST_PROTOCOL.md §1). +// The shared supervised-child lifecycle for every `maka.cu/2` executor the +// host runs: verified launch, bounded handshake, framed JSON-RPC requests with +// per-request stages, `$/cancel`-first cancellation and deadlines, bounded +// restart/backoff, generation invalidation on exit, graceful SIGTERM disposal, +// and host-owned work-directory cleanup. // -// The framing decoder and the lifecycle vocabulary are shared with the -// cua-driver service (stdio-json-rpc.ts). The supervision policy is not, and -// that is deliberate: this executor cancels with `$/cancel` and waits for its -// own answer instead of being killed (§7.2), shuts down on SIGTERM with a -// declared grace window (§11), owns its image directory (§8), and has one child -// rather than a role pair. Folding those into the cua-driver supervisor would -// mean a constructor flag per divergence, and every flag is a chance to run -// maka-cu's teardown against cua-driver. +// This is deliberately the ONLY implementation of that lifecycle in this +// package. Darwin, Windows and any future desktop platform point their own +// native `maka.cu/2` binary at this service; they must not copy spawn, +// handshake, pending-request, cancel, timeout, restart, generation or disposal +// authority into a second supervisor (see #3896). Platform-specific policy — +// permission prompts, window/element identity, capture, effect verification and +// artifact closure — stays in the backend and the Desktop composition, never in +// the process lifecycle. +// +// The framing decoder itself (stdio-json-rpc.ts) is shared with that same +// single lifecycle so the UTF-8 byte budget and the protocol-violation +// vocabulary are also written down exactly once. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; diff --git a/packages/computer-use/src/select-backend.ts b/packages/computer-use/src/select-backend.ts index 51a1a31bc4..885f0e0d3b 100644 --- a/packages/computer-use/src/select-backend.ts +++ b/packages/computer-use/src/select-backend.ts @@ -24,19 +24,57 @@ import type { MakaCuBackendOptions } from './maka-cu-backend.js'; import type { MakaCuServiceSnapshot } from './maka-cu-service.js'; /** - * One executor. + * One executor id, one supervised child contract. * * 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. + * + * A second id is deliberately not added for a new OS. macOS, Windows and any + * future desktop executor speak the same `maka.cu/2` contract and are + * supervised by the same service (`MakaCuService`); the platform differences + * are the native binary behind that contract and the Desktop composition that + * provisions it. See `CuPlatformBackendBinding` below. */ export const CU_BACKEND_IDS = ['maka-cu'] as const; export type CuBackendId = (typeof CU_BACKEND_IDS)[number]; export const DEFAULT_CU_BACKEND_ID: CuBackendId = 'maka-cu'; +/** + * The platform abstraction seam. + * + * Selection is the one place a platform names its executor. The bindings here + * say which native platform has a distributable executor behind the shared + * `CuDispatchBackend`/`MakaCuService` pair; they do not say anything about the + * model-facing action surface, which is platform-neutral by construction. + * + * An unsupported platform is a typed, fail-closed selection, never a backend + * that silently no-ops. A future platform is added by proving its native + * executor and Desktop provisioning, then registering it here (and in the + * Desktop manifest pipeline) — not by copying the supervisor. + */ +export type CuPlatformBackendBinding = { + readonly id: CuBackendId; + readonly platform: NodeJS.Platform; + /** + * Human label for capability reporting. `macOS` is the only shipped member + * today; the shared executor contract is what lets later members reuse the + * rest of this package without a second backend implementation. + */ + readonly platformLabel: string; +}; + +export const CU_PLATFORM_BACKEND_BINDINGS: readonly CuPlatformBackendBinding[] = [ + { + id: 'maka-cu', + platform: 'darwin', + platformLabel: 'macOS', + }, +]; + type DisposableBackend = CuDispatchBackend & { clearSession?: (sessionId: string) => void; dispose?: () => void; @@ -48,6 +86,13 @@ export interface SelectedComputerUseBackend { backend?: DisposableBackend; tools: ComputerUseToolSet; backendId: CuBackendId | 'none'; + /** + * Why no backend is selected, when that is the case. A missing reason means + * a backend is live. This makes "Computer Use is unavailable on this + * platform" a typed fact a capability UI can distinguish from a missing + * executable or a construction failure instead of three flavours of `none`. + */ + unavailableReason?: 'unsupported_platform' | 'missing_executable' | 'backend_failed'; } function emptyTools(): ComputerUseToolSet { @@ -68,11 +113,16 @@ function emptyTools(): ComputerUseToolSet { return tools; } -const NONE: SelectedComputerUseBackend = { - backend: undefined, - tools: emptyTools(), - backendId: 'none', -}; +function unavailable( + reason: NonNullable, +): SelectedComputerUseBackend { + return { + backend: undefined, + tools: emptyTools(), + backendId: 'none', + unavailableReason: reason, + }; +} export interface MakaCuSelection { /** Omitted means the default; see `DEFAULT_CU_BACKEND_ID`. */ @@ -93,13 +143,25 @@ export interface MakaCuSelection { overlay?: CuOverlayHook; onTrace?: MakaCuBackendOptions['onTrace']; createBackend?: (options: MakaCuBackendOptions) => DisposableBackend; + /** + * Test/host seam. Production callers omit it and Node's own platform is + * used; tests inject `darwin` so the same selection assertions run on every + * CI OS instead of being skipped off-macOS. + */ + platform?: NodeJS.Platform; } export type ComputerUseBackendSelection = MakaCuSelection; export function selectComputerUseBackend(deps?: MakaCuSelection): SelectedComputerUseBackend { - if (process.platform !== 'darwin') return NONE; - if (!deps?.binaryPath || !deps.expectedBinarySha256) return NONE; + const platform = deps?.platform ?? process.platform; + const binding = CU_PLATFORM_BACKEND_BINDINGS.find((candidate) => candidate.platform === platform); + if (!binding) { + return unavailable('unsupported_platform'); + } + if (!deps?.binaryPath || !deps.expectedBinarySha256) { + return unavailable('missing_executable'); + } const binaryPath = deps.binaryPath; const expectedBinarySha256 = deps.expectedBinarySha256; try { @@ -121,8 +183,8 @@ export function selectComputerUseBackend(deps?: MakaCuSelection): SelectedComput ...(deps.overlay ? { overlay: deps.overlay } : {}), ...(deps.screenLocked ? { screenLocked: deps.screenLocked } : {}), }); - return { backend, tools, backendId: DEFAULT_CU_BACKEND_ID }; + return { backend, tools, backendId: binding.id }; } catch { - return NONE; + return unavailable('backend_failed'); } } diff --git a/packages/computer-use/src/stdio-json-rpc.ts b/packages/computer-use/src/stdio-json-rpc.ts index 1883b4d4c2..7209c2c0a0 100644 --- a/packages/computer-use/src/stdio-json-rpc.ts +++ b/packages/computer-use/src/stdio-json-rpc.ts @@ -17,17 +17,16 @@ * under the License. */ -// Transport pieces shared by every stdio JSON-RPC executor the host supervises: -// trycua/cua-driver (MCP) and maka-cu (maka.cu/1). Both frame one JSON value per -// line over a direct child's stdio, so the decoder and the lifecycle vocabulary -// live here. +// Framing and lifecycle vocabulary for the one supervised child every +// `maka.cu/2` platform executor uses. One JSON value is framed per line over a +// direct child's stdio, the unparsed tail is bounded by a negotiated byte +// budget, and the host classifies where a request was when the child died. // -// What is deliberately NOT shared is the supervision policy above the framing: -// cua-driver kills the child to cancel a delivered request, maka-cu sends -// `$/cancel` and waits for the executor's own answer (maka.cu/1 §7.2), and the -// two handshakes and shutdown sequences have nothing in common. A single -// supervisor would carry a flag per divergence, which is how the behaviour that -// only one of the two executors needs ends up running against both. +// There is deliberately only one supervisor (`MakaCuService`); a platform +// backend adds a native executor and composition, never a second framing or +// lifecycle authority. cua-driver, the previous second executor with its own +// MCP-mode decoder and kill-to-cancel policy, was removed along with the role +// pair it belonged to. /** Where a request was when the child died — the input to death classification. */ export type HostRequestStage = 'queued' | 'writing' | 'delivered' | 'settled'; diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index 7fea001d47..504a5c5a38 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -118,6 +118,7 @@ export const CAPABILITY_REASON_CODES = [ 'cu_artifact_missing', 'cu_backend_status', 'cu_backend_unavailable', + 'cu_platform_unsupported', 'cu_executor_undistributable', 'cu_executor_stopped', 'cu_executor_start_failed',