From 710c0fbddd705b78a355fa081bc4726d5b0ab56c Mon Sep 17 00:00:00 2001 From: wy471x Date: Wed, 9 Sep 2026 21:33:53 +0800 Subject: [PATCH 1/4] feat(computer-use): add platform binding selection with typed unavailable results (#3896) Generated-by: Command Code --- .../main/__tests__/computer-use-host.test.ts | 47 +++++++++-- apps/desktop/src/main/computer-use-host.ts | 12 ++- packages/computer-use/README.md | 31 ++++++- .../src/__tests__/maka-cu-backend.test.ts | 5 +- .../select-backend-host-events.test.ts | 58 ++++++++++++- packages/computer-use/src/index.ts | 2 + packages/computer-use/src/maka-cu-service.ts | 38 ++++++--- packages/computer-use/src/select-backend.ts | 82 ++++++++++++++++--- packages/computer-use/src/stdio-json-rpc.ts | 19 ++--- 9 files changed, 247 insertions(+), 47 deletions(-) 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..85f708996a 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -78,17 +78,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 +101,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 +117,7 @@ describe('Computer Use host health', () => { }, })); const invalid = createComputerUseHost({ + platform: 'darwin', isPackaged: false, resourcesPath: directory, manifestPath, @@ -129,6 +129,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 +142,38 @@ 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`, + ); + } + } 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 aefef19cec..1408ae8953 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 }) }; } } 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 e2f2112594..680a96268b 100644 --- a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts +++ b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts @@ -1510,7 +1510,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 }), @@ -1521,6 +1520,7 @@ describe('maka-cu backend selection', () => { }; const selected = selectComputerUseBackend({ + platform: 'darwin', backendId: 'maka-cu', binaryPath: '/tmp/does-not-matter', expectedBinarySha256: 'deadbeef', @@ -1532,6 +1532,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, @@ -1545,10 +1546,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..eeae8f1641 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'; @@ -198,6 +205,15 @@ export interface MakaCuHandshake { export interface MakaCuServiceOptions { /** Absolute path to the `maka-cu` executable; spawned as a DIRECT child (§11). */ binaryPath: string; + /** + * Executor subcommand/argv after the executable path. + * + * The shipped Darwin executor is invoked as `maka-cu host`. A future + * platform binary behind the same `maka.cu/2` contract may need a different + * first argument or a platform launcher prefix; that difference belongs + * here, in the composition, not in a second child-process supervisor. + */ + childArgs?: readonly string[]; /** Host-owned image directory, purged before every spawn (§8, §11). */ imageDir: string; hostVersion: string; @@ -387,7 +403,7 @@ export class MakaCuService { // the host reported an exhausted restart budget rather than a wrong argv. // §11 said "spawns the executor as a direct child" and did not say with // what, so the two sides each picked, and disagreed. - const child = spawn(executablePath, ['host'], { + const child = spawn(executablePath, [...(this.opts.childArgs ?? ['host'])], { stdio: ['pipe', 'pipe', 'pipe'], // §13: no env-var behaviour switches. Everything behavioural is a // `host.hello` parameter, so the wire says what the executor will do. 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'; From 4d35d47368d25ec89bf32dde580797e9494d665a Mon Sep 17 00:00:00 2001 From: wy471x Date: Thu, 10 Sep 2026 23:27:43 +0800 Subject: [PATCH 2/4] refactor(computer-use): drop the unused childArgs seam from MakaCuService The option was declared on MakaCuServiceOptions but no caller could reach it. MakaCuBackendOptions never declared the field, and createMakaCuBackend never forwarded it, so neither the selector nor the Desktop composition could set it. That left an interface promise with a host default, no consumer, and no test able to tell a correct value from an empty argv or a doubled host argument. host is still required and still hardcoded at the spawn site, with its rationale unchanged. The seam comes back when a second platform actually needs a different argv, driven by a real requirement. Generated-by: Command Code --- packages/computer-use/src/maka-cu-service.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/computer-use/src/maka-cu-service.ts b/packages/computer-use/src/maka-cu-service.ts index eeae8f1641..682d86d023 100644 --- a/packages/computer-use/src/maka-cu-service.ts +++ b/packages/computer-use/src/maka-cu-service.ts @@ -205,15 +205,6 @@ export interface MakaCuHandshake { export interface MakaCuServiceOptions { /** Absolute path to the `maka-cu` executable; spawned as a DIRECT child (§11). */ binaryPath: string; - /** - * Executor subcommand/argv after the executable path. - * - * The shipped Darwin executor is invoked as `maka-cu host`. A future - * platform binary behind the same `maka.cu/2` contract may need a different - * first argument or a platform launcher prefix; that difference belongs - * here, in the composition, not in a second child-process supervisor. - */ - childArgs?: readonly string[]; /** Host-owned image directory, purged before every spawn (§8, §11). */ imageDir: string; hostVersion: string; @@ -403,7 +394,7 @@ export class MakaCuService { // the host reported an exhausted restart budget rather than a wrong argv. // §11 said "spawns the executor as a direct child" and did not say with // what, so the two sides each picked, and disagreed. - const child = spawn(executablePath, [...(this.opts.childArgs ?? ['host'])], { + const child = spawn(executablePath, ['host'], { stdio: ['pipe', 'pipe', 'pipe'], // §13: no env-var behaviour switches. Everything behavioural is a // `host.hello` parameter, so the wire says what the executor will do. From 9db8cd7cab32a9ecc5945890923392b1e27b4fc9 Mon Sep 17 00:00:00 2001 From: wy471x Date: Tue, 15 Sep 2026 10:55:31 +0800 Subject: [PATCH 3/4] fix(computer-use): carry the unavailable reason into capability reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection grew a typed `unavailableReason` so a capability surface could say why Computer Use is off, but no production path read it: the Desktop boot forwarded only `backendId` to `computerUseServiceHealth()`, which mapped every `none` to `cu_executor_undistributable`, and the capability's feature layer reported `cu_artifact_missing` for the same case. An unbound platform therefore reached the Permission/Health UI as a failed integrity check — the three states the reason distinguishes arrived as one copy. The reason now survives the whole path. `computerUseServiceHealth()` takes it as a third argument and projects `unsupported_platform` to the new `cu_platform_unsupported` code, `backend_failed` to `cu_backend_unavailable`, and `missing_executable` to `cu_executor_undistributable`; a `none` carrying no typed reason keeps the undistributable code it had. Both capability layers name that same cause, so the feature row no longer contradicts the probe row. The row projection lives in `computer-use-capability-reasons.ts` rather than in `capability-snapshot.ts` because the snapshot module imports Electron, which a `node --test` process cannot load; keeping the projection out of that import graph is what makes it assertable, the same reason `app-icon-ipc` injects Electron instead of importing it. macOS behaviour is unchanged: `darwin` still selects `maka-cu` with the same tools and the same binary/digest checks. Generated-by: Command Code Co-authored-by: CommandCodeBot --- apps/desktop/src/main/capability-snapshot.ts | 6 ++- .../main/computer-use-capability-reasons.ts | 47 +++++++++++++++++++ apps/desktop/src/main/computer-use-host.ts | 22 ++++++++- apps/desktop/src/main/runtime-host-boot.ts | 1 + .../locales/capability-reason-copy.ts | 3 ++ packages/core/src/capabilities.ts | 1 + 6 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/computer-use-capability-reasons.ts 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 1408ae8953..90ee322d3e 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -140,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. * @@ -150,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/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', From de34dc1f2c78117e98def1302cb6527deb637c35 Mon Sep 17 00:00:00 2001 From: wy471x Date: Tue, 15 Sep 2026 10:55:50 +0800 Subject: [PATCH 4/4] test(computer-use): assert the projected capability reasons, not the selection The typed reason is only worth carrying if the projection keeps the three ways to have no backend apart, so these tests assert what the capability surface shows. The first pins the reason-code mapping, including that a `none` with no typed reason keeps the undistributable code, and that a platform with no binding reaches the health projection as a platform fact. The second composes that projection with the row and asserts the three reasons arrive as three different codes, with the live executor and the never-assembled snapshot keeping their own artifact and probe reasons. Generated-by: Command Code Co-authored-by: CommandCodeBot --- .../computer-use-capability-reasons.test.ts | 76 +++++++++++++++++++ .../main/__tests__/computer-use-host.test.ts | 33 +++++++- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/computer-use-capability-reasons.test.ts 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 85f708996a..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 () => { @@ -170,6 +188,17 @@ describe('Computer Use host health', () => { '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 });