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
Original file line number Diff line number Diff line change
@@ -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' },
);
});
});
80 changes: 72 additions & 8 deletions apps/desktop/src/main/__tests__/computer-use-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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,
Expand All @@ -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: {
Expand All @@ -118,6 +135,7 @@ describe('Computer Use host health', () => {
},
}));
const invalid = createComputerUseHost({
platform: 'darwin',
isPackaged: false,
resourcesPath: directory,
manifestPath,
Expand All @@ -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,
Expand All @@ -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 });
}
});

});
6 changes: 4 additions & 2 deletions apps/desktop/src/main/capability-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -132,14 +133,15 @@ 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',
now,
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 },
Expand All @@ -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,
},
});
}
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/computer-use-capability-reasons.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
34 changes: 29 additions & 5 deletions apps/desktop/src/main/computer-use-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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 }) };
}
}

Expand All @@ -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.
*
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,7 @@ function registerHostClientIpc(
health: computerUseServiceHealth(
native.computerUse.backendId,
executorState,
native.computerUse.unavailableReason,
),
};
},
Expand Down
Loading