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
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,75 @@ test('local setup forwards the exact development archive evidence', async (t) =>
assert.equal(environment?.[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV], integrity);
});

test('local setup scratch cleanup cannot replace its framed outcome', async (t) => {
const frames = [
{
schemaVersion: 1 as const,
sequence: 0,
kind: 'complete' as const,
version: '0.2.0',
serviceId: 'b'.repeat(64),
deploymentId: '00000000-0000-4000-8000-000000000001',
operator: OPERATOR,
rootPath: '/tmp/maka/root',
rootId: 'a'.repeat(64),
endpoint: 'ws://127.0.0.1:7443/runtime-host',
credentialId: 'credential-1',
credential: 'secret-access-token',
},
{
schemaVersion: 1 as const,
sequence: 0,
kind: 'error' as const,
error: { code: 'setup_failed', message: 'primary setup failure' },
},
];
let invocation = 0;
let cleanupCount = 0;
const spawnProcess = (() => {
const child = new EventEmitter() as ReturnType<typeof spawn>;
const stdout = new PassThrough();
const stderr = new PassThrough();
const frame = frames[invocation++];
Object.assign(child, { pid: 1234, stdout, stderr, kill: () => true });
process.nextTick(() => {
stdout.end(encodeRuntimeHostSetupFrame(frame));
stderr.end();
child.emit('close', frame.kind === 'complete' ? 0 : 1, null);
});
return child;
}) as typeof spawn;
const operator = createDesktopRuntimeHostLocalOperator({
environment: { PATH: process.env.PATH },
spawnProcess,
removeSetupWorkingDirectory: async (path) => {
cleanupCount += 1;
await rm(path, { recursive: true, force: true });
throw new Error('scratch cleanup failed');
},
});
t.after(() => operator.close());
const setup = {
setupPackage: { kind: 'npm' as const, 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),
},
};

const complete = await operator.runSetup(setup, () => undefined);
assert.equal(complete.kind, 'complete');
await assert.rejects(
operator.runSetup(setup, () => undefined),
/primary setup failure/u,
);
assert.equal(cleanupCount, 2);
});

test('Windows npm discovery cannot outlive setup cancellation', async (t) => {
const originalPlatform = process.platform;
const fixtureRoot = await mkdtemp(join(tmpdir(), 'maka-windows-npm-lookup-'));
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/main/runtime-host-local-operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import {
const SETUP_TIMEOUT_MS = 10 * 60_000;
const SETUP_FRAME_PENDING_MAX = 20 * 1024;
const STDERR_MAX_BYTES = 64 * 1024;
const SETUP_CLEANUP_MAX_RETRIES = 10;
const SETUP_CLEANUP_RETRY_DELAY_MS = 100;
const WINDOWS_NPM_RESOLUTION_SCRIPT = String.raw`
const { statSync } = require('node:fs');
const path = require('node:path').win32;
Expand Down Expand Up @@ -199,6 +201,7 @@ export function createDesktopRuntimeHostLocalOperator(input: {
readonly spawnProcess?: typeof spawn;
readonly setupTimeoutMs?: number;
readonly terminateProcess?: typeof terminateChildProcessTree;
readonly removeSetupWorkingDirectory?: (path: string) => Promise<void>;
} = {}): {
runSetup(
setup: DesktopRuntimeHostLocalSetupInput,
Expand Down Expand Up @@ -267,6 +270,15 @@ export function createDesktopRuntimeHostLocalOperator(input: {
let closed = false;
const closing = new AbortController();
const terminate = input.terminateProcess ?? terminateChildProcessTree;
const removeSetupWorkingDirectory =
input.removeSetupWorkingDirectory ??
((path: string) =>
rm(path, {
recursive: true,
force: true,
maxRetries: SETUP_CLEANUP_MAX_RETRIES,
retryDelay: SETUP_CLEANUP_RETRY_DELAY_MS,
}));

return {
async runSetup(setup, onProgress) {
Expand Down Expand Up @@ -302,7 +314,12 @@ export function createDesktopRuntimeHostLocalOperator(input: {
active,
});
} finally {
await rm(workingDirectory, { recursive: true, force: true });
try {
await removeSetupWorkingDirectory(workingDirectory);
} catch {
// The private scratch directory is not part of the setup transaction.
// Its cleanup must not replace the operator's framed result or error.
}
}
},
runPeer(command) {
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/__tests__/runtime-host-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,19 @@ import {
acknowledgeRuntimeHostManagedDeploymentCleanup,
assertRuntimeHostManagedOperatorDeployment,
convergeRuntimeHostManagedOperator,
convergeRuntimeHostManagedWindowsTaskLauncher,
prepareRuntimeHostManagedPackageDeployment,
pruneRuntimeHostManagedPackages,
readRuntimeHostManagedDeploymentCleanupReceipt,
resolveRuntimeHostManagedControlRoot,
resolveRuntimeHostManagedDeploymentRoot,
verifyRuntimeHostManagedWindowsTaskLauncher,
} from '../runtime-host-managed-deployment.js';
import {
resolvePackagedRuntimeHostWindowsTaskLauncherPath,
resolveRuntimeHostWindowsTaskLauncherPath,
runtimeHostManagedWindowsTaskLauncherPath,
} from '../runtime-host-windows-task-launcher-artifact.js';
import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js';
import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js';
import { replaceRuntimeHostLifecycle } from '../runtime-host-lifecycle-transaction.js';
Expand Down Expand Up @@ -977,6 +984,75 @@ test('managed operator binds its Client Data Root and routes deployment cleanup'
assert.deepEqual(signalExit, { code: null, signal: 'SIGTERM' });
});

test('managed Windows task launcher is projected to a stable deployment path', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-windows-launcher-'));
t.after(() => rm(base, { recursive: true, force: true }));
const sourcePackageRoot = await createReleasePackage(base, '0.2.0');
const sourceLauncher = join(
sourcePackageRoot,
'native',
'runtime-host-windows-task-launcher',
'prebuilds',
'win32-x64',
'maka-runtime-host-task-launcher.exe',
);
await mkdir(dirname(sourceLauncher), { recursive: true });
await writeFile(sourceLauncher, 'launcher-v1');
const serviceId = 'a'.repeat(64);
const deployment = await prepareRuntimeHostManagedPackageDeployment(
{
serviceId,
clientDataRoot: join(base, 'client'),
sourcePackageRoot,
version: '0.2.0',
packageIntegrity: PACKAGE_INTEGRITY,
},
{
env: { XDG_DATA_HOME: join(base, 'data') },
homeDir: join(base, 'home'),
platform: 'linux',
},
);
const config: RuntimeHostManagedDeploymentConfig = {
schemaVersion: 1,
state: 'active',
deploymentId: '00000000-0000-4000-8000-000000000001',
configRevision: 1,
deploymentRoot: deployment.root,
root: { id: serviceId, path: join(base, 'state') },
projectDirectoryRoots: [],
launch: {
kind: 'exact_package',
nodePath: process.execPath,
package: { kind: 'npm_registry', version: '0.2.0', integrity: PACKAGE_INTEGRITY },
},
listeners: { localIpc: true },
lifecycle: { mode: 'supervised', provider: 'windows_task', availability: 'session' },
reconciliation: { trigger: 'scheduled', provider: 'windows_task_timer' },
};

await convergeRuntimeHostManagedWindowsTaskLauncher(config);
const projected = runtimeHostManagedWindowsTaskLauncherPath(
deployment.root,
Buffer.from('launcher-v1'),
);
assert.equal(await readFile(projected, 'utf8'), 'launcher-v1');
assert.equal(await resolveRuntimeHostWindowsTaskLauncherPath(deployment.cliPath), projected);

const packaged = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(deployment.cliPath);
await writeFile(packaged, 'launcher-v2');
await assert.rejects(
verifyRuntimeHostManagedWindowsTaskLauncher(config),
/does not match its deployment/u,
);
await convergeRuntimeHostManagedWindowsTaskLauncher(config);
await verifyRuntimeHostManagedWindowsTaskLauncher(config);
assert.notEqual(
runtimeHostManagedWindowsTaskLauncherPath(deployment.root, Buffer.from('launcher-v2')),
projected,
);
});

async function createReleasePackage(base: string, version: string): Promise<string> {
const root = join(base, `source-package-${version}`);
await mkdir(join(root, 'dist'), { recursive: true });
Expand Down
75 changes: 70 additions & 5 deletions packages/cli/src/runtime-host-managed-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ import {
type RuntimeHostManagedDeploymentAuthorityOptions,
type RuntimeHostManagedDeploymentConfig,
} from '@maka/runtime-host/operator';
import {
readRuntimeHostWindowsTaskLauncher,
resolvePackagedRuntimeHostWindowsTaskLauncherPath,
runtimeHostManagedWindowsTaskLauncherPath,
} from './runtime-host-windows-task-launcher-artifact.js';

export { RuntimeHostPackageDeploymentError as RuntimeHostManagedDeploymentError } from './runtime-host-package-deployment.js';

Expand All @@ -63,7 +68,6 @@ interface RuntimeHostManagedDeploymentCleanupReceipt {
}

const CLEANUP_RECEIPT_FILE = 'cleanup-approved.json';

export function resolveRuntimeHostManagedPackageCliPath(
deploymentRoot: string,
version: string,
Expand Down Expand Up @@ -641,15 +645,15 @@ async function writeOperatorLauncher(
managedRootId,
deploymentId,
);
await writeStableOperator(path, contents);
await writeStableArtifact(path, contents);
}

async function writeStableOperator(path: string, contents: string): Promise<void> {
async function writeStableArtifact(path: string, contents: string | Uint8Array): Promise<void> {
const temporaryPath = `${path}.${randomUUID()}.tmp`;
try {
const file = await open(temporaryPath, 'wx', 0o700);
try {
await file.writeFile(contents, 'utf8');
await file.writeFile(contents);
await file.sync();
} finally {
await file.close();
Expand Down Expand Up @@ -735,6 +739,9 @@ export async function convergeRuntimeHostManagedOperator(
desired.root.id,
desired.deploymentId,
);
if (process.platform === 'win32') {
await convergeRuntimeHostManagedWindowsTaskLauncher(desired);
}
await forwardLegacyOperatorIfPresent(
deployment.deploymentRoot,
desired.launch.nodePath,
Expand All @@ -761,7 +768,7 @@ async function forwardLegacyOperatorIfPresent(
},
);
if (exists) {
await writeStableOperator(path, legacyOperatorLauncherContents(nodePath, modulePath));
await writeStableArtifact(path, legacyOperatorLauncherContents(nodePath, modulePath));
}
}

Expand Down Expand Up @@ -811,6 +818,9 @@ export async function verifyRuntimeHostManagedOperator(
cause: error,
});
});
if (process.platform === 'win32') {
await verifyRuntimeHostManagedWindowsTaskLauncher(config, { allowAbsent: true });
}
const legacyOperatorPath = join(config.deploymentRoot, 'operator');
const legacyExpected = legacyOperatorLauncherContents(config.launch.nodePath, operatorPath);
const legacyExists = await access(legacyOperatorPath, constants.F_OK).then(
Expand Down Expand Up @@ -839,6 +849,61 @@ export async function verifyRuntimeHostManagedOperator(
}
}

export async function convergeRuntimeHostManagedWindowsTaskLauncher(
config: RuntimeHostManagedDeploymentConfig,
): Promise<void> {
const layout = resolveRuntimeHostNpmDeploymentLayout(
config.deploymentRoot,
config.launch.package.integrity,
);
const sourcePath = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(layout.cliPath);
const expected = await readRuntimeHostWindowsTaskLauncher(sourcePath);
const projectedPath = runtimeHostManagedWindowsTaskLauncherPath(config.deploymentRoot, expected);
const projectedExists = await access(projectedPath, constants.F_OK).then(
() => true,
(error: unknown) => {
if (isNodeError(error, 'ENOENT')) return false;
throw error;
},
);
if (projectedExists) {
const observed = await readRuntimeHostWindowsTaskLauncher(projectedPath);
if (!observed.equals(expected)) {
throw new Error('The managed Runtime Host Windows task launcher is invalid');
}
return;
}
await writeStableArtifact(projectedPath, expected);
}

export async function verifyRuntimeHostManagedWindowsTaskLauncher(
config: RuntimeHostManagedDeploymentConfig,
options: { readonly allowAbsent?: boolean } = {},
): Promise<void> {
const layout = resolveRuntimeHostNpmDeploymentLayout(
config.deploymentRoot,
config.launch.package.integrity,
);
const sourcePath = await resolvePackagedRuntimeHostWindowsTaskLauncherPath(layout.cliPath);
const expected = await readRuntimeHostWindowsTaskLauncher(sourcePath);
const projectedPath = runtimeHostManagedWindowsTaskLauncherPath(config.deploymentRoot, expected);
const projectedExists = await access(projectedPath, constants.F_OK).then(
() => true,
(error: unknown) => {
if (isNodeError(error, 'ENOENT')) return false;
throw error;
},
);
if (!projectedExists) {
if (options.allowAbsent) return;
throw new Error('The managed Runtime Host Windows task launcher does not match its deployment');
}
const observed = await readRuntimeHostWindowsTaskLauncher(projectedPath);
if (!observed.equals(expected)) {
throw new Error('The managed Runtime Host Windows task launcher does not match its deployment');
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/runtime-host-package-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await rm(path, { recursive: true, force: true, maxRetries: 100, retryDelay: 100 });
})().catch(() => { process.exitCode = 1; });`;
const cleanup = spawn(process.execPath, ['-e', script, path, String(process.pid)], {
cwd: dirname(process.execPath),
detached: true,
stdio: 'ignore',
windowsHide: true,
Expand Down
Loading
Loading