Skip to content
Merged
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
26 changes: 16 additions & 10 deletions .github/workflows/cli-package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ on:
- '.github/workflows/runtime-host-peer-admission.yml'
- 'deny.toml'
- 'native/runtime-host-peer/**'
- 'native/runtime-host-windows-task-launcher/**'
- 'package-lock.json'
- 'packages/cli/RUNTIME_HOST_PEER_*'
- 'packages/cli/src/cli-core.ts'
Expand Down Expand Up @@ -156,6 +157,11 @@ jobs:
MAKA_RUNTIME_HOST_PEER_CARGO_SUBCOMMAND: ${{ matrix.rust_target && 'zigbuild' || '' }}
MAKA_RUNTIME_HOST_PEER_CARGO_TARGET: ${{ matrix.rust_target }}
run: node native/runtime-host-peer/build.mjs
- name: Build the Windows task launcher
if: matrix.target == 'win32-x64'
run: |
cargo fmt --manifest-path native/runtime-host-windows-task-launcher/Cargo.toml --check
node native/runtime-host-windows-task-launcher/build.mjs
- name: Report Rust build cache
shell: bash
run: kache report --format github >> "$GITHUB_STEP_SUMMARY"
Expand All @@ -181,15 +187,15 @@ jobs:
const newer = versions.find(([major, minor]) => major > 2 || (major === 2 && minor > 28));
if (newer) throw new Error(`Direct-peer addon requires GLIBC_${newer.join('.')}`);
NODE
- name: Stage the platform addon
- name: Stage the platform native artifacts
env:
PEER_TARGET: ${{ matrix.target }}
run: node -e "const fs=require('node:fs'),p=require('node:path'); const d=p.join('peer-prebuilds',process.env.PEER_TARGET); fs.mkdirSync(d,{recursive:true}); fs.copyFileSync(p.join('native','runtime-host-peer','target','release','maka_runtime_host_peer.node'),p.join(d,'maka_runtime_host_peer.node'))"
- name: Upload the platform addon
NATIVE_TARGET: ${{ matrix.target }}
run: node -e "const fs=require('node:fs'),p=require('node:path'),t=process.env.NATIVE_TARGET,d=p.join('native-prebuilds',t); fs.mkdirSync(d,{recursive:true}); fs.copyFileSync(p.join('native','runtime-host-peer','target','release','maka_runtime_host_peer.node'),p.join(d,'maka_runtime_host_peer.node')); if(t==='win32-x64') fs.copyFileSync(p.join('native','runtime-host-windows-task-launcher','target','release','maka-runtime-host-task-launcher.exe'),p.join(d,'maka-runtime-host-task-launcher.exe'))"
- name: Upload the platform native artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: runtime-host-peer-${{ matrix.target }}
path: peer-prebuilds
name: runtime-host-native-${{ matrix.target }}
path: native-prebuilds
if-no-files-found: error
retention-days: 1

Expand All @@ -216,16 +222,16 @@ jobs:
uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2
with:
tool: cargo-deny@0.20.2
- name: Download direct-peer addons
- name: Download Runtime Host native artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: runtime-host-peer-*
path: ${{ runner.temp }}/runtime-host-peer-prebuilds
pattern: runtime-host-native-*
path: ${{ runner.temp }}/runtime-host-native-prebuilds
merge-multiple: true
- name: Build the release tarball once
env:
MAKA_CLI_NIGHTLY_VERSION: ${{ inputs.package_version }}
MAKA_RUNTIME_HOST_PEER_PREBUILDS: ${{ runner.temp }}/runtime-host-peer-prebuilds
MAKA_RUNTIME_HOST_NATIVE_PREBUILDS: ${{ runner.temp }}/runtime-host-native-prebuilds
run: npm run release:cli:pack
- name: Upload the immutable release candidate
id: release-candidate
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ deepseek.key
# Built only by the dedicated Gitoxide helper lane; normal workspace tests do not use Cargo.
/native/gitoxide-helper/target/
/native/runtime-host-peer/target/
/native/runtime-host-windows-task-launcher/target/

# Generated Computer Use executor binary; provenance metadata stays tracked.
apps/desktop/resources/bin/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import assert from 'node:assert/strict';
import type { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';
import {
Expand Down Expand Up @@ -130,6 +133,55 @@ test('local setup forwards the exact development archive evidence', async (t) =>
assert.equal(environment?.[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV], integrity);
});

test('Windows npm discovery cannot outlive setup cancellation', async (t) => {
const originalPlatform = process.platform;
const fixtureRoot = await mkdtemp(join(tmpdir(), 'maka-windows-npm-lookup-'));
const resolver = join(fixtureRoot, 'hang.cjs');
await writeFile(
resolver,
'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);\n',
);
Object.defineProperty(process, 'platform', { value: 'win32' });
t.after(async () => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
await rm(fixtureRoot, { recursive: true, force: true });
});

let setupSpawned = false;
const operator = createDesktopRuntimeHostLocalOperator({
environment: { PATH: process.env.PATH, NODE_OPTIONS: `--require=${resolver}` },
setupTimeoutMs: 60_000,
spawnProcess: (() => {
setupSpawned = true;
throw new Error('npm must not start after cancellation');
}) as typeof spawn,
});
t.after(() => operator.close());
const cancellation = new AbortController();
const startedAt = Date.now();
const setup = operator.runSetup(
{
setupPackage: { kind: 'npm', specifier: 'maka-agent@0.2.0' },
clientDataRoot: '/tmp/maka/client',
rootPath: '/tmp/maka/root',
principalId: 'desktop-owner:pairing',
expectedTarget: {
serviceId: 'b'.repeat(64),
rootPath: '/tmp/maka/root',
rootId: 'a'.repeat(64),
},
signal: cancellation.signal,
},
() => undefined,
);
await new Promise((resolve) => setTimeout(resolve, 50));
cancellation.abort(new Error('setup cancelled'));

await assert.rejects(setup, /setup cancelled/u);
assert.equal(setupSpawned, false);
assert.ok(Date.now() - startedAt < 1_000);
});

test('local update runs the selected package against the exact managed deployment', async (t) => {
let executable: string | undefined;
let args: readonly string[] | undefined;
Expand Down Expand Up @@ -181,6 +233,15 @@ test('local update runs the selected package against the exact managed deploymen
});
t.after(() => operator.close());
const deploymentId = '00000000-0000-4000-8000-000000000001';
const operatorArgs = () => {
if (process.platform !== 'win32') {
assert.equal(executable, 'npm');
return args;
}
assert.match(executable ?? '', /[\\/]node\.exe$/ui);
assert.match(args?.[0] ?? '', /[\\/]npm-cli\.js$/u);
return args?.slice(1);
};

await operator.runUpdate(
{
Expand All @@ -197,8 +258,7 @@ test('local update runs the selected package against the exact managed deploymen
(phase) => phases.push(phase),
);

assert.equal(executable, 'npm');
assert.deepEqual(args, [
assert.deepEqual(operatorArgs(), [
'exec', '--yes', '--package', 'maka-agent@0.3.0', '--',
'maka', 'runtime-host', 'service', 'update', '--framed',
'--target', '0.3.0',
Expand Down Expand Up @@ -236,7 +296,7 @@ test('local update runs the selected package against the exact managed deploymen
() => undefined,
);

assert.deepEqual(args, [
assert.deepEqual(operatorArgs(), [
'exec', '--yes', '--package', '/tmp/maka-agent-development.tgz', '--',
'maka', 'runtime-host', 'service', 'update', '--framed',
'--managed-root-id', 'a'.repeat(64),
Expand Down
122 changes: 117 additions & 5 deletions apps/desktop/src/main/runtime-host-local-operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
* under the License.
*/

import { spawn, type ChildProcess } from 'node:child_process';
import { execFile, spawn, type ChildProcess } from 'node:child_process';
import { mkdtemp, rm, rmdir, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { dirname, isAbsolute, join } from 'node:path';
import { redactSecrets } from '@maka/core/redaction';
import {
DEFAULT_PROCESS_TERMINATION_GRACE_MS,
Expand Down Expand Up @@ -62,6 +62,41 @@ import {
const SETUP_TIMEOUT_MS = 10 * 60_000;
const SETUP_FRAME_PENDING_MAX = 20 * 1024;
const STDERR_MAX_BYTES = 64 * 1024;
const WINDOWS_NPM_RESOLUTION_SCRIPT = String.raw`
const { statSync } = require('node:fs');
const path = require('node:path').win32;
const candidates = [];
const configuredNode = process.env.npm_node_execpath?.trim();
const configuredCli = process.env.npm_execpath?.trim();
if (configuredNode && configuredCli) candidates.push([configuredNode, configuredCli]);
const searchPath = Object.entries(process.env).find(([key]) => key.toUpperCase() === 'PATH')?.[1];
for (const entry of searchPath?.split(path.delimiter) ?? []) {
const directory = entry.replace(/^"|"$/g, '').trim();
if (!directory) continue;
candidates.push([
path.join(directory, 'node.exe'),
path.join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
]);
}
let resolved;
for (const [nodePath, cliPath] of candidates) {
try {
if (
path.isAbsolute(nodePath) &&
path.isAbsolute(cliPath) &&
statSync(nodePath).isFile() &&
statSync(cliPath).isFile()
) {
resolved = [nodePath, cliPath];
break;
}
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
if (resolved) process.stdout.write(JSON.stringify(resolved));
else process.exitCode = 1;
`;

type RuntimeHostSetupCompleteFrame = Extract<RuntimeHostSetupFrame, { kind: 'complete' }>;

Expand Down Expand Up @@ -662,6 +697,67 @@ function resolveLocalSetupPackage(
return { specifier: setupPackage.path, integrity: setupPackage.integrity };
}

async function resolveLocalNpmCommand(
command: DesktopRuntimeHostLocalSetupCommand,
environment: NodeJS.ProcessEnv,
signal: AbortSignal | undefined,
timeoutMs: number,
label: string,
): Promise<DesktopRuntimeHostLocalSetupCommand> {
if (process.platform !== 'win32' || command.executable !== 'npm') return command;

const timeout = AbortSignal.timeout(timeoutMs);
const lookupSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let npmCommand: readonly [string, string];
try {
npmCommand = await locateWindowsNpmCommand(environment, lookupSignal);
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (timeout.aborted) throw new Error(`${label} timed out`);
throw new Error(
'Local Runtime Host management requires a complete Node.js and npm installation',
{ cause: error },
);
}
return { executable: npmCommand[0], args: [npmCommand[1], ...command.args] };
}

function locateWindowsNpmCommand(
environment: NodeJS.ProcessEnv,
signal: AbortSignal,
): Promise<readonly [string, string]> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
['-e', WINDOWS_NPM_RESOLUTION_SCRIPT],
{
encoding: 'utf8',
env: { ...environment, ELECTRON_RUN_AS_NODE: '1' },
maxBuffer: 64 * 1024,
signal,
windowsHide: true,
},
(error, stdout) => {
if (error) return reject(error);
let command: unknown;
try {
command = JSON.parse(stdout);
} catch (parseError) {
return reject(parseError);
}
if (
!Array.isArray(command) ||
command.length !== 2 ||
!command.every((path) => typeof path === 'string' && isAbsolute(path))
) {
return reject(new Error('The npm resolver returned an invalid command'));
}
resolve([command[0], command[1]]);
},
);
});
}

function managedTargetArgs(target: DesktopRuntimeHostLocalServiceTarget): string[] {
return [
'--expected-service-id',
Expand Down Expand Up @@ -752,7 +848,7 @@ function runSetupProcess(input: {
});
}

function runFramedProcess<Frame, Result>(input: {
async function runFramedProcess<Frame, Result>(input: {
readonly command: DesktopRuntimeHostLocalSetupCommand;
readonly cwd?: string;
readonly prefix: string;
Expand All @@ -771,9 +867,22 @@ function runFramedProcess<Frame, Result>(input: {
readonly inputLine?: string;
readonly pendingMaxBytes?: number;
}): Promise<Result> {
const deadline = Date.now() + input.timeoutMs;
input.signal?.throwIfAborted();
const lookupTimeoutMs = deadline - Date.now();
if (lookupTimeoutMs <= 0) throw new Error(`${input.label} timed out`);
const command = await resolveLocalNpmCommand(
input.command,
input.environment,
input.signal,
lookupTimeoutMs,
input.label,
);
input.signal?.throwIfAborted();
const remainingTimeoutMs = deadline - Date.now();
if (remainingTimeoutMs <= 0) throw new Error(`${input.label} timed out`);
return new Promise((resolve, reject) => {
const child = input.spawnProcess(input.command.executable, [...input.command.args], {
const child = input.spawnProcess(command.executable, [...command.args], {
...(input.cwd ? { cwd: input.cwd } : {}),
detached: process.platform !== 'win32',
env: input.environment,
Expand Down Expand Up @@ -823,7 +932,10 @@ function runFramedProcess<Frame, Result>(input: {
);
};
const onAbort = () => stop(abortError(input.signal));
const timeout = setTimeout(() => stop(new Error(`${input.label} timed out`)), input.timeoutMs);
const timeout = setTimeout(
() => stop(new Error(`${input.label} timed out`)),
remainingTimeoutMs,
);
input.signal?.addEventListener('abort', onAbort, { once: true });
child.stdout?.on('data', (chunk: Buffer) => filter.push(chunk.toString('utf8')));
child.stderr?.on('data', (chunk: Buffer) => {
Expand Down
15 changes: 4 additions & 11 deletions apps/desktop/src/main/runtime-host-local-remote-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
try {
const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId);
managedService = lifecycle !== undefined && hasManagedServiceTarget(lifecycle);
if (!supported(input.directPeerAvailable)) {
if (!input.directPeerAvailable) {
return {
...unsupportedSnapshot(),
...(managedService ? { managedService: true as const } : {}),
Expand Down Expand Up @@ -289,7 +289,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
const enable = (value: unknown): Promise<DesktopLocalRuntimeHostRemoteAccessEnableResult> =>
serialize(async () => {
const request = requireEnableInput(value);
if (!supported(input.directPeerAvailable)) throw new Error(unsupportedSnapshot().message);
if (!input.directPeerAvailable) throw new Error(unsupportedSnapshot().message);
let lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId);
if (lifecycle?.state === 'uninstalling') {
const recovered = await finishUninstall(lifecycle);
Expand Down Expand Up @@ -895,7 +895,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
if (lifecycle.state === 'handoff' || lifecycle.state === 'setupPending') {
const committed = await adoptCommittedSetup(lifecycle);
if (committed.kind === 'managed') return;
if (!supported(input.directPeerAvailable)) return;
if (!input.directPeerAvailable) return;
if (lifecycle.state === 'handoff') await recoverLegacyHandoff(lifecycle);
else await finishSetup(lifecycle, 'recovery');
return;
Expand All @@ -914,20 +914,13 @@ function conflictReplacementError(pid: number, reason: string): Error {
return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`);
}

function supported(directPeerAvailable: boolean): boolean {
return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux');
}

function unsupportedSnapshot(): Extract<
DesktopLocalRuntimeHostRemoteAccessSnapshot,
{ state: 'unsupported' }
> {
return {
state: 'unsupported',
message:
process.platform === 'darwin' || process.platform === 'linux'
? 'This Desktop build does not include Direct peer support'
: 'Remote access to this computer currently requires macOS or Linux',
message: 'This Desktop build does not include Direct peer support',
};
}

Expand Down
Loading
Loading