You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/26 13:42:43 UTC
(maka) branch main updated: feat: restructure system service management (#3858)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 6d8826e27 feat: restructure system service management (#3858)
6d8826e27 is described below
commit 6d8826e2711a5d8ee7b437dda6aba53a06d18d0f
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Aug 26 21:42:38 2026 +0800
feat: restructure system service management (#3858)
* feat(desktop): configure remote project roots
Generated-by: OpenAI Codex
* fix(desktop): make remote root policy authoritative
* fix(runtime-host): make project root changes transactional
* chore(runtime-host): declare compatible root helpers
* fix(runtime-host): migrate legacy project root deployments
---
.../main/__tests__/runtime-host-management.test.ts | 131 +++-
.../main/__tests__/runtime-host-onboarding.test.ts | 24 +-
.../runtime-host-project-directory-policy.test.ts | 45 ++
.../__tests__/runtime-host-ssh-terminal.test.ts | 68 ++
apps/desktop/src/main/runtime-host-management.ts | 160 +++--
apps/desktop/src/main/runtime-host-onboarding.ts | 7 +
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 30 +-
apps/desktop/src/preload/bridge-contract.d.ts | 13 +-
apps/desktop/src/preload/preload.ts | 14 +
.../src/renderer/locales/settings-projects-copy.ts | 53 +-
.../settings/runtime-host-management-dialog.tsx | 224 ++++++-
.../settings/runtime-host-onboarding-dialog.tsx | 33 +-
.../runtime-host-project-directory-editor.tsx | 80 +++
.../src/renderer/styles/settings/runtime-host.css | 30 +-
.../runtime-host-project-directory-policy.ts | 74 +++
docs/astryx-surface-file-inventory.md | 3 +-
docs/astryx-surface-file-inventory.paths | 1 +
docs/runtime-host-remote-access.md | 6 +-
.../runtime-host-launch-agent-service.test.ts | 164 ++++-
.../runtime-host-operator-command.test.ts | 40 ++
.../__tests__/runtime-host-service-manager.test.ts | 711 ++++++++++++++++++++-
.../cli/src/__tests__/runtime-host-setup.test.ts | 6 +-
.../runtime-host-update-reconciliation.test.ts | 7 +-
packages/cli/src/cli-core.ts | 18 +
packages/cli/src/runtime-host-cli.ts | 181 +++++-
.../cli/src/runtime-host-launch-agent-service.ts | 76 ++-
packages/cli/src/runtime-host-service-launch.ts | 16 +
.../src/runtime-host-service-management-command.ts | 57 +-
packages/cli/src/runtime-host-service-manager.ts | 526 ++++++++++++---
packages/cli/src/runtime-host-setup-command.ts | 4 +-
packages/cli/src/runtime-host-systemd-service.ts | 73 ++-
packages/cli/src/runtime-host-update-command.ts | 4 +-
packages/cli/src/runtime-host-update-discovery.ts | 2 +-
.../cli/src/runtime-host-update-reconciliation.ts | 9 +-
.../project-directory-root-spec.json | 5 +
.../src/__tests__/project-catalog-protocol.test.ts | 9 +
packages/runtime-host/src/operator/index.ts | 1 +
.../src/operator/service-management-frame.ts | 20 +-
.../runtime-host/src/protocol/project-catalog.ts | 36 ++
.../src/server/project-directory-authority.ts | 17 +-
40 files changed, 2659 insertions(+), 319 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index a7ca78abf..2c30c6da1 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -430,25 +430,132 @@ test('publishes update progress and waits for the managed profile to reconnect',
removeBindingAfterUpdate = true;
const changedProfile = await update({}, profile.id, false);
assert.equal(
- (changedProfile as { kind: string; error?: { message: string } }).error?.message,
- 'The Runtime Host update completed, but Desktop could not reconnect: ' +
+ (changedProfile as { reconnectError?: { message: string } }).reconnectError?.message,
+ 'The Runtime Host change was applied, but Desktop could not reconnect: ' +
'Runtime Host profile changed while its service was updating',
);
+ assert.equal((changedProfile as { kind: string }).kind, 'result');
bindingPresent = true;
removeBindingAfterUpdate = false;
failConnection = true;
const reconnectFailure = await update({}, profile.id, false);
- assert.deepEqual(reconnectFailure, {
- schemaVersion: 1,
- kind: 'error',
- action: 'update',
- error: {
+ assert.equal((reconnectFailure as { kind: string }).kind, 'result');
+ assert.deepEqual(
+ (reconnectFailure as { reconnectError: { code: string; message: string } }).reconnectError,
+ {
code: 'desktop_reconnect_failed',
message:
- 'The Runtime Host update completed, but Desktop could not reconnect: authentication required',
+ 'The Runtime Host change was applied, but Desktop could not reconnect: authentication required',
+ },
+ );
+});
+
+test('configures Project roots with CAS and reconnects only after a committed cutover', async () => {
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
+ const profile = {
+ id: 'office',
+ name: 'Office',
+ kind: 'remote' as const,
+ rootId: 'a'.repeat(64),
+ transport: {
+ kind: 'ssh' as const,
+ destination: 'operator@example.com',
+ remotePort: 7443,
+ websocketPath: '/runtime-host',
+ },
+ };
+ const service = {
+ id: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ };
+ const fingerprint = `sha256:${'c'.repeat(64)}`;
+ const inputs: DesktopRuntimeHostSshManagementInput[] = [];
+ const reconnects: unknown[][] = [];
+ let failReconnect = false;
+ createDesktopRuntimeHostManagement({
+ ...unusedUpdateDependencies(),
+ ipcMain: {
+ handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+ removeHandler: (channel) => handlers.delete(channel),
+ },
+ profiles: {
+ resolveManagedService: async () => ({ profile, service, state: 'active' as const }),
+ resolveManagedAccess: async () => undefined,
+ rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
+ markManagedServiceUninstalling: async (binding) => binding,
+ markManagedServiceCleanupPending: async (binding) => binding,
+ clearManagedServiceBinding: async () => undefined,
+ },
+ runServiceManagement: async (input) => {
+ inputs.push(input);
+ assert.equal(input.action, 'configure');
+ return {
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'configure',
+ service: {
+ ...serviceSummary('1.2.3'),
+ configurationFingerprint:
+ input.allowInterruptActiveTasks ? `sha256:${'d'.repeat(64)}` : fingerprint,
+ projectDirectoryRoots: [...(input.projectDirectoryRoots ?? [])],
+ },
+ configuration: {
+ kind: input.allowInterruptActiveTasks ? 'configured' : 'active_tasks',
+ },
+ };
+ },
+ runAccessManagement: async () => assert.fail('access management is not expected'),
+ cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
+ currentHostEpoch: () => 'host-before-configure',
+ awaitUpdatedConnection: async (...args) => {
+ reconnects.push(args);
+ if (failReconnect) throw new Error('authentication required');
},
});
+ const configure = handlers.get('runtime-host-management:configure-project-directories');
+ assert.ok(configure);
+ const roots = [{ label: 'Work', path: '/srv/work ' }];
+ const blocked = await configure({}, profile.id, roots, fingerprint, false);
+ assert.equal(
+ (blocked as { configuration: { kind: string } }).configuration.kind,
+ 'active_tasks',
+ );
+ assert.equal(reconnects.length, 0);
+
+ const configured = await configure({}, profile.id, roots, fingerprint, true);
+ assert.equal(
+ (configured as { configuration: { kind: string } }).configuration.kind,
+ 'configured',
+ );
+ assert.deepEqual(inputs.map((input) => ({
+ roots: input.projectDirectoryRoots,
+ fingerprint: input.expectedConfigFingerprint,
+ allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false,
+ })), [
+ { roots, fingerprint, allowInterruptActiveTasks: false },
+ { roots, fingerprint, allowInterruptActiveTasks: true },
+ ]);
+ assert.deepEqual(reconnects, [[profile.id, profile.rootId, 'host-before-configure', true]]);
+
+ failReconnect = true;
+ const committedWithoutReconnect = await configure({}, profile.id, roots, fingerprint, true);
+ assert.equal((committedWithoutReconnect as { kind: string }).kind, 'result');
+ assert.equal(
+ (committedWithoutReconnect as { service: { configurationFingerprint?: string } }).service
+ .configurationFingerprint,
+ `sha256:${'d'.repeat(64)}`,
+ );
+ assert.deepEqual(
+ (committedWithoutReconnect as { reconnectError?: { code: string; message: string } })
+ .reconnectError,
+ {
+ code: 'desktop_reconnect_failed',
+ message:
+ 'The Runtime Host change was applied, but Desktop could not reconnect: authentication required',
+ },
+ );
});
test('manages one Host update policy and reconciles it through the bound operator', async () => {
@@ -751,9 +858,11 @@ function serviceResult(
projectDirectoryRoots: [],
},
};
- return action === 'retire'
- ? { ...result, action, retirement: { kind: 'stopped' } }
- : { ...result, action };
+ if (action === 'retire') return { ...result, action, retirement: { kind: 'stopped' } };
+ if (action === 'configure') {
+ return { ...result, action, configuration: { kind: 'unchanged' } };
+ }
+ return { ...result, action };
}
function serviceSummary(installedVersion: string) {
diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
index 846c25ec0..c05f8b605 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
@@ -24,6 +24,7 @@ import type { DesktopRuntimeHostManagedService } from '../runtime-host-managed-s
import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js';
test('persists a verified SSH profile without projecting its credential', async () => {
+ let setupInput: unknown;
let saved:
| (DesktopRuntimeHostProfileAddInput & {
readonly managedService?: DesktopRuntimeHostManagedService;
@@ -36,7 +37,8 @@ test('persists a verified SSH profile without projecting its credential', async
return { profileId: input.profile.id };
},
},
- runSetup: async (_input, onProgress) => {
+ runSetup: async (input, onProgress) => {
+ setupInput = input;
onProgress({ phase: 'installing_service' });
return {
serviceId: 'b'.repeat(64),
@@ -52,6 +54,7 @@ test('persists a verified SSH profile without projecting its credential', async
const result = await harness.invoke('runtime-host-onboarding:start', {
name: 'Lab',
destination: 'operator@example.com',
+ projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }],
});
assert.equal((result as { kind?: string }).kind, 'complete');
@@ -68,6 +71,10 @@ test('persists a verified SSH profile without projecting its credential', async
operatorPath: '/home/operator/.local/share/maka/operator',
});
assert.equal(saved?.credential, 'secret-access-token');
+ assert.deepEqual(
+ (setupInput as { projectDirectoryRoots?: unknown }).projectDirectoryRoots,
+ [{ label: 'Work', path: '/srv/work' }],
+ );
assert.doesNotMatch(JSON.stringify(harness.events), /secret-access-token/u);
await harness.onboarding.close();
assert.equal(harness.handlers.size, 0);
@@ -92,6 +99,21 @@ test('projects invalid setup input as a recoverable failure', async () => {
await harness.onboarding.close();
});
+test('rejects relative remote Project roots before starting SSH setup', async () => {
+ const harness = createHarness();
+
+ const result = await harness.invoke('runtime-host-onboarding:start', {
+ destination: 'operator@example.com',
+ projectDirectoryRoots: [{ label: 'Work', path: 'srv/work' }],
+ });
+ assert.deepEqual(result, {
+ kind: 'failed',
+ message: 'Runtime Host Project directory is invalid',
+ revision: 1,
+ });
+ await harness.onboarding.close();
+});
+
test('finishes Host pairing after the cancellable SSH phase has completed', async () => {
let finishPairing!: (value: { profileId: string }) => void;
const pairing = new Promise<{ profileId: string }>((resolve) => {
diff --git a/apps/desktop/src/main/__tests__/runtime-host-project-directory-policy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-project-directory-policy.test.ts
new file mode 100644
index 000000000..58597427b
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-project-directory-policy.test.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { test } from 'node:test';
+import {
+ canonicalProjectDirectoryRoots,
+ projectDirectoryRootsValid,
+ requireProjectDirectoryRoots,
+} from '../../shared/runtime-host-project-directory-policy.js';
+
+test('preserves opaque remote paths while normalizing labels', () => {
+ const roots = [{ label: ' Work ', path: '/srv/work ' }];
+ assert.deepEqual(canonicalProjectDirectoryRoots(roots), [
+ { label: 'Work', path: '/srv/work ' },
+ ]);
+ assert.deepEqual(requireProjectDirectoryRoots(roots), [
+ { label: 'Work', path: '/srv/work ' },
+ ]);
+ assert.equal(projectDirectoryRootsValid(roots), true);
+});
+
+test('rejects paths that are not absolute on the remote POSIX Host', () => {
+ for (const path of ['srv/work', 'C:\\work']) {
+ const roots = [{ label: 'Work', path }];
+ assert.equal(projectDirectoryRootsValid(roots), false);
+ assert.throws(() => requireProjectDirectoryRoots(roots), /Project directory is invalid/u);
+ }
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
index 644cf8014..348247983 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
@@ -300,6 +300,74 @@ test('reads a framed service result without projecting it into the SSH terminal'
await harness.terminal.close();
});
+test('applies the complete remote Project root policy through the managed operator', async () => {
+ const harness = createHarness('pending');
+ const fingerprint = `sha256:${'c'.repeat(64)}`;
+ const management = harness.terminal.runServiceManagement({
+ destination: 'operator@example.com',
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ action: 'configure',
+ expectedTarget: {
+ serviceId: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ rootId: 'a'.repeat(64),
+ },
+ projectDirectoryRoots: [
+ { label: 'Work=Primary', path: '/srv/work trees' },
+ { label: 'Data', path: '/mnt/data' },
+ ],
+ expectedConfigFingerprint: fingerprint,
+ allowInterruptActiveTasks: true,
+ });
+ await waitFor(() => harness.pty.hasDataListener());
+ const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? '';
+ assert.match(remoteCommand, /operator.*configure/u);
+ assert.match(remoteCommand, /--project-root-json/u);
+ assert.match(remoteCommand, /Work=Primary/u);
+ assert.match(remoteCommand, /srv\/work trees/u);
+ assert.match(remoteCommand, /Data/u);
+ assert.match(remoteCommand, /mnt\/data/u);
+ assert.match(remoteCommand, /--expected-config-fingerprint/u);
+ assert.match(remoteCommand, /--allow-interrupt-active-tasks/u);
+ assert.match(
+ remoteCommand,
+ /MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST=1/u,
+ );
+ harness.pty.emitData(
+ encodeRuntimeHostServiceManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'configure',
+ service: {
+ platform: 'linux',
+ arch: 'x64',
+ osRelease: '6.8.0',
+ state: 'running',
+ pid: 43,
+ lastExitCode: 0,
+ installedVersion: '1.2.3',
+ configurationFingerprint: `sha256:${'d'.repeat(64)}`,
+ projectDirectoryRoots: [
+ { label: 'Work=Primary', path: '/srv/work trees' },
+ { label: 'Data', path: '/mnt/data' },
+ ],
+ },
+ configuration: { kind: 'configured' },
+ }),
+ );
+ harness.pty.exit(0);
+
+ const result = await management;
+ assert.equal(result.kind, 'result');
+ assert.equal(
+ result.kind === 'result' && result.action === 'configure'
+ ? result.configuration.kind
+ : undefined,
+ 'configured',
+ );
+ await harness.terminal.close();
+});
+
test('keeps a received management result when SSH teardown times out', async () => {
const harness = createHarness('pending', { managementTimeoutMs: 1 });
harness.pty.deferKill = true;
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index be6e26154..dbf777e71 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -36,6 +36,7 @@ import type {
} from '../preload/bridge-contract.js';
import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js';
import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js';
+import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js';
import type {
DesktopRuntimeHostSshCleanupInput,
DesktopRuntimeHostSshAccessInput,
@@ -240,12 +241,12 @@ export function createDesktopRuntimeHostManagement(input: {
};
};
- const updateTarget = async (profileIdValue: unknown) => {
+ const managedMutationTarget = async (profileIdValue: unknown) => {
const profileId = requireProfileId(profileIdValue);
const managed = await resolveManagedService(profileId);
const transport = managed.profile.transport;
if (managed.state !== 'active' || transport.kind !== 'ssh') {
- throw new Error('This Runtime Host profile is not available for managed updates');
+ throw new Error('This Runtime Host profile is not available for managed service changes');
}
return {
profileId,
@@ -266,7 +267,8 @@ export function createDesktopRuntimeHostManagement(input: {
if (typeof allowInterruptActiveTasksValue !== 'boolean') {
throw new Error('Runtime Host update interruption authority is invalid');
}
- const { profileId, managed, transport, expectedTarget } = await updateTarget(profileIdValue);
+ const { profileId, managed, transport, expectedTarget } =
+ await managedMutationTarget(profileIdValue);
const previousHostEpoch = input.currentHostEpoch(profileId);
input.sendProgress({ profileId, phase: 'preparing_cli' });
const setupPackage = await input.resolveUpdatePackage();
@@ -280,25 +282,19 @@ export function createDesktopRuntimeHostManagement(input: {
},
(phase) => input.sendProgress({ profileId, phase }),
);
- if (response.kind === 'result' && response.update.kind !== 'active_tasks') {
- const reconnectError = await reconnectUpdatedTarget(
+ const reconnectError =
+ response.kind === 'result' && response.update.kind !== 'active_tasks'
+ ? await reconnectUpdatedTarget(
profileId,
managed,
previousHostEpoch,
response.update.kind !== 'already_current',
- );
- if (reconnectError) {
- return {
- schemaVersion: 1,
- kind: 'error',
- action: 'update',
- error: reconnectError,
- };
- }
- }
+ )
+ : undefined;
return response.kind === 'result'
? {
...response,
+ ...(reconnectError ? { reconnectError } : {}),
accessManagementAvailable:
response.operatorCapabilities?.includes(
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
@@ -307,6 +303,59 @@ export function createDesktopRuntimeHostManagement(input: {
: response;
};
+ const configureProjectDirectories = async (
+ profileIdValue: unknown,
+ rootsValue: unknown,
+ expectedConfigFingerprintValue: unknown,
+ allowInterruptActiveTasksValue: unknown,
+ ): Promise<DesktopRuntimeHostManagementResponse> => {
+ const roots = requireProjectDirectoryRoots(rootsValue);
+ if (
+ typeof expectedConfigFingerprintValue !== 'string' ||
+ !/^sha256:[a-f0-9]{64}$/u.test(expectedConfigFingerprintValue)
+ ) {
+ throw new Error('Runtime Host service configuration fingerprint is invalid');
+ }
+ if (typeof allowInterruptActiveTasksValue !== 'boolean') {
+ throw new Error('Runtime Host configuration interruption authority is invalid');
+ }
+ const { profileId, managed, transport, expectedTarget } =
+ await managedMutationTarget(profileIdValue);
+ const previousHostEpoch = input.currentHostEpoch(profileId);
+ const response = await input.runServiceManagement({
+ destination: transport.destination,
+ ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }),
+ operatorPath: managed.service.operatorPath,
+ action: 'configure',
+ expectedTarget,
+ projectDirectoryRoots: roots,
+ expectedConfigFingerprint: expectedConfigFingerprintValue,
+ ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+ });
+ if (response.action !== 'configure') {
+ throw new Error('Remote Runtime Host returned a different management action');
+ }
+ const reconnectError =
+ response.kind === 'result' && response.configuration.kind === 'configured'
+ ? await reconnectUpdatedTarget(
+ profileId,
+ managed,
+ previousHostEpoch,
+ true,
+ )
+ : undefined;
+ return response.kind === 'result'
+ ? {
+ ...response,
+ ...(reconnectError ? { reconnectError } : {}),
+ accessManagementAvailable:
+ response.operatorCapabilities?.includes(
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ ) ?? false,
+ }
+ : { ...response, action: 'configure' };
+ };
+
const reconnectUpdatedTarget = async (
profileId: string,
managed: Awaited<ReturnType<typeof resolveManagedService>>,
@@ -329,7 +378,7 @@ export function createDesktopRuntimeHostManagement(input: {
return {
code: 'desktop_reconnect_failed',
message:
- 'The Runtime Host update completed, but Desktop could not reconnect: ' +
+ 'The Runtime Host change was applied, but Desktop could not reconnect: ' +
(error instanceof Error ? error.message : String(error)),
};
}
@@ -339,7 +388,7 @@ export function createDesktopRuntimeHostManagement(input: {
profileIdValue: unknown,
policyValue?: unknown,
): Promise<DesktopRuntimeHostUpdatePolicySnapshot> => {
- const { managed, transport, expectedTarget } = await updateTarget(profileIdValue);
+ const { managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue);
const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue);
const common = {
destination: transport.destination,
@@ -369,7 +418,8 @@ export function createDesktopRuntimeHostManagement(input: {
const reconcileUpdate = async (
profileIdValue: unknown,
): Promise<DesktopRuntimeHostUpdateReconciliationResponse> => {
- const { profileId, managed, transport, expectedTarget } = await updateTarget(profileIdValue);
+ const { profileId, managed, transport, expectedTarget } =
+ await managedMutationTarget(profileIdValue);
const previousHostEpoch = input.currentHostEpoch(profileId);
const response = await input.runUpdateReconciliation(
{
@@ -382,30 +432,19 @@ export function createDesktopRuntimeHostManagement(input: {
},
(phase) => input.sendProgress({ profileId, phase }),
);
- if (
+ const reconnectError =
response.kind === 'result' &&
(response.reconciliation.kind === 'updated' ||
response.reconciliation.kind === 'repaired')
- ) {
- const reconnectError = await reconnectUpdatedTarget(
- profileId,
- managed,
- previousHostEpoch,
- true,
- );
- if (reconnectError) {
- return {
- kind: 'error',
- error: reconnectError,
- };
- }
- }
+ ? await reconnectUpdatedTarget(profileId, managed, previousHostEpoch, true)
+ : undefined;
return response.kind === 'result'
? {
kind: 'result',
updatePolicy: projectUpdatePolicy(response),
reconciliation: response.reconciliation,
...(response.service ? { service: response.service } : {}),
+ ...(reconnectError ? { reconnectError } : {}),
}
: { kind: 'error', error: response.error };
};
@@ -525,42 +564,59 @@ export function createDesktopRuntimeHostManagement(input: {
);
};
- const channels = [
- 'runtime-host-management:run',
- 'runtime-host-management:update',
- 'runtime-host-management:list-credentials',
- 'runtime-host-management:rotate-credential',
- 'runtime-host-management:revoke-credential',
- 'runtime-host-management:get-update-policy',
- 'runtime-host-management:set-update-policy',
- 'runtime-host-management:reconcile-update',
- ] as const;
- input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) =>
+ const channels = {
+ run: 'runtime-host-management:run',
+ update: 'runtime-host-management:update',
+ configureProjectDirectories: 'runtime-host-management:configure-project-directories',
+ listCredentials: 'runtime-host-management:list-credentials',
+ rotateCredential: 'runtime-host-management:rotate-credential',
+ revokeCredential: 'runtime-host-management:revoke-credential',
+ getUpdatePolicy: 'runtime-host-management:get-update-policy',
+ setUpdatePolicy: 'runtime-host-management:set-update-policy',
+ reconcileUpdate: 'runtime-host-management:reconcile-update',
+ } as const;
+ input.ipcMain.handle(channels.run, (_event, profileId: unknown, action: unknown) =>
run(profileId, action));
input.ipcMain.handle(
- channels[1],
+ channels.update,
(_event, profileId: unknown, allowInterruptActiveTasks: unknown) =>
update(profileId, allowInterruptActiveTasks),
);
- input.ipcMain.handle(channels[2], (_event, profileId: unknown) =>
+ input.ipcMain.handle(
+ channels.configureProjectDirectories,
+ (
+ _event,
+ profileId: unknown,
+ roots: unknown,
+ expectedConfigFingerprint: unknown,
+ allowInterruptActiveTasks: unknown,
+ ) =>
+ configureProjectDirectories(
+ profileId,
+ roots,
+ expectedConfigFingerprint,
+ allowInterruptActiveTasks,
+ ),
+ );
+ input.ipcMain.handle(channels.listCredentials, (_event, profileId: unknown) =>
listCredentials(profileId));
- input.ipcMain.handle(channels[3], (_event, profileId: unknown) =>
+ input.ipcMain.handle(channels.rotateCredential, (_event, profileId: unknown) =>
rotateCredential(profileId));
input.ipcMain.handle(
- channels[4],
+ channels.revokeCredential,
(_event, profileId: unknown, credentialId: unknown) =>
revokeCredential(profileId, credentialId),
);
- input.ipcMain.handle(channels[5], (_event, profileId: unknown) =>
+ input.ipcMain.handle(channels.getUpdatePolicy, (_event, profileId: unknown) =>
updatePolicy(profileId));
- input.ipcMain.handle(channels[6], (_event, profileId: unknown, policy: unknown) =>
+ input.ipcMain.handle(channels.setUpdatePolicy, (_event, profileId: unknown, policy: unknown) =>
updatePolicy(profileId, policy));
- input.ipcMain.handle(channels[7], (_event, profileId: unknown) =>
+ input.ipcMain.handle(channels.reconcileUpdate, (_event, profileId: unknown) =>
reconcileUpdate(profileId));
return {
close() {
- for (const channel of channels) input.ipcMain.removeHandler(channel);
+ for (const channel of Object.values(channels)) input.ipcMain.removeHandler(channel);
},
};
}
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index 546e23a13..01b458b19 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -32,6 +32,7 @@ import type {
DesktopRuntimeHostSetupPackage,
DesktopRuntimeHostSshSetupInput,
} from './runtime-host-ssh-terminal.js';
+import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js';
type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapshot
? Snapshot extends DesktopRuntimeHostOnboardingSnapshot
@@ -123,6 +124,9 @@ export function createDesktopRuntimeHostOnboarding(input: {
...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }),
setupPackage,
principalId: `desktop:${input.clientInstanceId}`,
+ ...(request.projectDirectoryRoots
+ ? { projectDirectoryRoots: request.projectDirectoryRoots }
+ : {}),
signal,
},
(progress) => {
@@ -227,5 +231,8 @@ function requireOnboardingInput(value: unknown): DesktopRuntimeHostOnboardingInp
destination: input.destination,
...(input.name?.trim() ? { name: input.name.trim() } : {}),
...(input.sshPort === undefined ? {} : { sshPort: input.sshPort }),
+ ...(input.projectDirectoryRoots === undefined
+ ? {}
+ : { projectDirectoryRoots: requireProjectDirectoryRoots(input.projectDirectoryRoots) }),
};
}
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index a186f2637..beaca6e0c 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -40,6 +40,7 @@ import {
RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX,
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY,
RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
RUNTIME_HOST_SETUP_FRAME_PREFIX,
@@ -86,6 +87,7 @@ export interface DesktopRuntimeHostSshSetupInput {
readonly sshPort?: number;
readonly setupPackage: DesktopRuntimeHostSetupPackage;
readonly principalId: string;
+ readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[];
readonly signal?: AbortSignal;
}
@@ -105,6 +107,9 @@ export interface DesktopRuntimeHostSshManagementInput {
readonly rootPath?: string;
readonly websocketPort?: number;
readonly websocketPath?: string;
+ readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[];
+ readonly expectedConfigFingerprint?: string;
+ readonly allowInterruptActiveTasks?: boolean;
readonly retainManagedDeployment?: boolean;
readonly signal?: AbortSignal;
}
@@ -1037,7 +1042,7 @@ async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Prom
function runtimeHostSetupRemoteCommand(
setupPackage: PreparedSetupPackage,
- input: Pick<DesktopRuntimeHostSshSetupInput, 'principalId'>,
+ input: Pick<DesktopRuntimeHostSshSetupInput, 'principalId' | 'projectDirectoryRoots'>,
): string {
if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) {
throw new Error('Runtime Host setup principal is invalid');
@@ -1050,6 +1055,14 @@ function runtimeHostSetupRemoteCommand(
'--preset',
'desktop-client',
'--defer-pairing-commit',
+ ...(input.projectDirectoryRoots === undefined
+ ? []
+ : input.projectDirectoryRoots.length === 0
+ ? ['--no-project-roots']
+ : input.projectDirectoryRoots.flatMap(({ label, path }) => [
+ '--project-root-json',
+ JSON.stringify({ label, path }),
+ ])),
'--json',
]);
}
@@ -1066,10 +1079,23 @@ function runtimeHostServiceManagementRemoteCommand(
? []
: ['--websocket-port', String(input.websocketPort)]),
...(input.websocketPath ? ['--websocket-path', input.websocketPath] : []),
+ ...(input.projectDirectoryRoots === undefined
+ ? []
+ : input.projectDirectoryRoots.length === 0
+ ? ['--no-project-roots']
+ : input.projectDirectoryRoots.flatMap(({ label, path }) => [
+ '--project-root-json',
+ JSON.stringify({ label, path }),
+ ])),
+ ...(input.expectedConfigFingerprint
+ ? ['--expected-config-fingerprint', input.expectedConfigFingerprint]
+ : []),
+ ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
...(input.retainManagedDeployment ? ['--retain-managed-deployment'] : []),
...managedServiceTargetArgs(input.expectedTarget),
].map(quotePosix).join(' ');
const invocation =
+ `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` +
`${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` +
`${quotePosix(RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`;
return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`;
@@ -1092,6 +1118,7 @@ function runtimeHostUpdateRemoteCommand(
{
[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]:
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1',
},
);
}
@@ -1128,6 +1155,7 @@ function runtimeHostUpdateReconciliationRemoteCommand(
.map(quotePosix)
.join(' ');
const invocation =
+ `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` +
`${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` +
`${quotePosix(RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY)} exec ${command}`;
return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`;
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 1b2482273..c4bb909aa 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -398,6 +398,7 @@ export interface DesktopRuntimeHostOnboardingInput {
readonly name?: string;
readonly destination: string;
readonly sshPort?: number;
+ readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[];
}
export type DesktopRuntimeHostOnboardingPhase =
@@ -436,14 +437,15 @@ export type DesktopRuntimeHostManagementResult = Extract<
RuntimeHostServiceManagementFrame,
{ kind: 'result' }
> & {
- readonly action: DesktopRuntimeHostManagementAction | 'update';
+ readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update';
readonly accessManagementAvailable: boolean;
+ readonly reconnectError?: { readonly code: string; readonly message: string };
};
export type DesktopRuntimeHostManagementResponse =
| DesktopRuntimeHostManagementResult
| (Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> & {
- readonly action: DesktopRuntimeHostManagementAction | 'update';
+ readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update';
})
| {
readonly kind: 'uninstalled';
@@ -489,6 +491,7 @@ export type DesktopRuntimeHostUpdateReconciliationResponse =
readonly updatePolicy: DesktopRuntimeHostUpdatePolicySnapshot;
readonly reconciliation: DesktopRuntimeHostUpdateReconciliationOutcome;
readonly service?: NonNullable<RuntimeHostUpdateReconciliationResult['service']>;
+ readonly reconnectError?: { readonly code: string; readonly message: string };
};
export interface DesktopRuntimeHostAccessCredential {
@@ -620,6 +623,12 @@ export interface MakaBridge {
profileId: string,
allowInterruptActiveTasks: boolean,
): Promise<DesktopRuntimeHostManagementResponse>;
+ configureProjectDirectories(
+ profileId: string,
+ roots: readonly { readonly label: string; readonly path: string }[],
+ expectedConfigFingerprint: string,
+ allowInterruptActiveTasks: boolean,
+ ): Promise<DesktopRuntimeHostManagementResponse>;
subscribeProgress(
handler: (progress: DesktopRuntimeHostManagementProgress) => void,
): () => void;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index d3a689343..2154ea42b 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1266,6 +1266,20 @@ const makaBridge = {
allowInterruptActiveTasks,
);
},
+ configureProjectDirectories(
+ profileId: string,
+ roots: readonly { readonly label: string; readonly path: string }[],
+ expectedConfigFingerprint: string,
+ allowInterruptActiveTasks: boolean,
+ ): Promise<DesktopRuntimeHostManagementResponse> {
+ return ipcRenderer.invoke(
+ 'runtime-host-management:configure-project-directories',
+ profileId,
+ roots,
+ expectedConfigFingerprint,
+ allowInterruptActiveTasks,
+ );
+ },
subscribeProgress(handler: (progress: DesktopRuntimeHostManagementProgress) => void) {
const listener = (
_event: Electron.IpcRendererEvent,
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 54c1bd725..b8667e7c5 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -33,6 +33,7 @@ export type SettingsProjectsCopy = {
setupDescription: string;
setupName: string;
setupSshPort: string;
+ setupDirectoryRootsDescription: string;
setupConnect: string;
setupCancel: string;
setupRetry: string;
@@ -86,7 +87,20 @@ export type SettingsProjectsCopy = {
lastExitCode: string;
stateRoot: string;
directoryRoots: string;
+ directoryRootsDescription: string;
+ directoryRootsUnavailable: string;
+ directoryRootsChanged: string;
+ directoryRootsChangedDescription: string;
+ reloadDirectoryRoots: string;
noDirectoryRoots: string;
+ directoryRootLabel: string;
+ directoryRootPath: string;
+ addDirectoryRoot: string;
+ removeDirectoryRoot: string;
+ saveDirectoryRoots: string;
+ directoryRootsActiveTasks: string;
+ directoryRootsActiveTasksDescription: string;
+ configureDirectoriesInterrupt: string;
refresh: string;
startService: string;
restartService: string;
@@ -141,6 +155,7 @@ export type SettingsProjectsCopy = {
uninstallConfirm: string;
uninstallRetained(path: string): string;
managementActionFailed: string;
+ managementReconnectFailed: string;
manageAccess: string;
accessTitle: string;
noAccessCredentials: string;
@@ -225,6 +240,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
setupDescription: '通过 SSH 安装并连接 Runtime Host',
setupName: '显示名称(可选)',
setupSshPort: 'SSH 端口(可选)',
+ setupDirectoryRootsDescription: '留空时使用远端 Home。添加目录后,只有这些目录可用于浏览并添加项目。',
setupConnect: '连接',
setupCancel: '取消',
setupRetry: '重试',
@@ -292,8 +308,21 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
processId: '进程 ID',
lastExitCode: '上次退出码',
stateRoot: 'State Root',
- directoryRoots: '可用目录',
- noDirectoryRoots: '未配置额外目录',
+ directoryRoots: '可用于添加项目的目录',
+ directoryRootsDescription: '远程 Client 只能从这些目录浏览并添加新项目。移除目录不会删除已经添加的项目。',
+ directoryRootsUnavailable: '更新或修复这个 Host 后,即可在 Desktop 中管理这些目录。',
+ directoryRootsChanged: '这些目录已在其他位置更改',
+ directoryRootsChangedDescription: '你的编辑仍被保留。加载当前配置后再继续编辑。',
+ reloadDirectoryRoots: '加载当前配置',
+ noDirectoryRoots: '目录浏览和项目添加已禁用',
+ directoryRootLabel: '显示名称',
+ directoryRootPath: '远端绝对路径',
+ addDirectoryRoot: '添加目录',
+ removeDirectoryRoot: '移除',
+ saveDirectoryRoots: '应用目录',
+ directoryRootsActiveTasks: '这个 Host 仍有正在运行的任务',
+ directoryRootsActiveTasksDescription: '应用目录需要安全重启远端服务。只有明确确认后才会中断这些任务。',
+ configureDirectoriesInterrupt: '中断任务并应用',
refresh: '刷新',
startService: '启动',
restartService: '重启',
@@ -350,6 +379,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
uninstallConfirm: '卸载服务',
uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`,
managementActionFailed: '无法管理 Runtime Host 服务',
+ managementReconnectFailed: '更改已应用,但 Desktop 未能重新连接',
manageAccess: '管理访问权限',
accessTitle: '访问权限',
noAccessCredentials: '没有访问凭据',
@@ -432,6 +462,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
setupDescription: 'Install and connect Runtime Host over SSH',
setupName: 'Display name (optional)',
setupSshPort: 'SSH port (optional)',
+ setupDirectoryRootsDescription: 'Leave empty to use the remote Home directory. When directories are added, only those locations can be browsed to add projects.',
setupConnect: 'Connect',
setupCancel: 'Cancel',
setupRetry: 'Retry',
@@ -499,8 +530,21 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
processId: 'Process ID',
lastExitCode: 'Last exit code',
stateRoot: 'State Root',
- directoryRoots: 'Available directories',
- noDirectoryRoots: 'No additional directories configured',
+ directoryRoots: 'Directories for adding projects',
+ directoryRootsDescription: 'Remote Clients can browse and add new projects only from these directories. Removing one does not delete projects already added.',
+ directoryRootsUnavailable: 'Update or repair this Host to manage these directories in Desktop.',
+ directoryRootsChanged: 'These directories changed elsewhere',
+ directoryRootsChangedDescription: 'Your draft is preserved. Load the current configuration before continuing.',
+ reloadDirectoryRoots: 'Load current configuration',
+ noDirectoryRoots: 'Directory browsing and project registration are disabled',
+ directoryRootLabel: 'Display name',
+ directoryRootPath: 'Absolute path on remote computer',
+ addDirectoryRoot: 'Add directory',
+ removeDirectoryRoot: 'Remove',
+ saveDirectoryRoots: 'Apply directories',
+ directoryRootsActiveTasks: 'This Host still has running tasks',
+ directoryRootsActiveTasksDescription: 'Applying these directories requires a safe remote service restart. Tasks are interrupted only after explicit confirmation.',
+ configureDirectoriesInterrupt: 'Interrupt tasks and apply',
refresh: 'Refresh',
startService: 'Start',
restartService: 'Restart',
@@ -560,6 +604,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
uninstallConfirm: 'Uninstall service',
uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`,
managementActionFailed: 'Unable to manage the Runtime Host service',
+ managementReconnectFailed: 'Change applied, but Desktop could not reconnect',
manageAccess: 'Manage access',
accessTitle: 'Access',
noAccessCredentials: 'No active access credentials',
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
index cc401f6c1..3632c4876 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -45,11 +45,20 @@ import type {
DesktopRuntimeHostUpdateReconciliationResponse,
} from '../../preload/bridge-contract.js';
import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
+import {
+ canonicalProjectDirectoryRoots,
+ projectDirectoryRootsValid,
+} from '../../shared/runtime-host-project-directory-policy.js';
import { settingsActionErrorMessage } from './settings-error-copy.js';
+import {
+ RuntimeHostProjectDirectoryEditor,
+ type ProjectDirectoryRootDraft,
+} from './runtime-host-project-directory-editor.js';
type RuntimeHostManagementConfirmation =
| { readonly kind: 'uninstall' }
| { readonly kind: 'update' }
+ | { readonly kind: 'configureDirectories' }
| { readonly kind: 'rotate' }
| {
readonly kind: 'revoke';
@@ -57,7 +66,15 @@ type RuntimeHostManagementConfirmation =
};
type UpdatePolicyChoice = 'manual' | 'fixed' | 'latest' | 'next';
-
+type DirectoryPolicySnapshot = {
+ readonly roots: readonly { readonly label: string; readonly path: string }[];
+ readonly configurationFingerprint: string;
+};
+type DirectoryPolicyEdit = {
+ readonly baseline: DirectoryPolicySnapshot;
+ readonly draft: readonly ProjectDirectoryRootDraft[];
+ readonly conflict?: DirectoryPolicySnapshot;
+};
export function RuntimeHostManagementDialog(props: {
readonly profile: RemoteRuntimeHostProfile | undefined;
readonly onClose: () => void;
@@ -68,6 +85,7 @@ export function RuntimeHostManagementDialog(props: {
const [result, setResult] = useState<DesktopRuntimeHostManagementResult>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
+ const [reconnectWarning, setReconnectWarning] = useState<string>();
const [uninstalledRoot, setUninstalledRoot] = useState<string>();
const [access, setAccess] = useState<DesktopRuntimeHostAccessSnapshot>();
const [confirmation, setConfirmation] = useState<RuntimeHostManagementConfirmation>();
@@ -78,6 +96,8 @@ export function RuntimeHostManagementDialog(props: {
const [updatePolicyError, setUpdatePolicyError] = useState<string>();
const [lastUpdateOutcome, setLastUpdateOutcome] =
useState<DesktopRuntimeHostUpdateReconciliationOutcome>();
+ const [directoryPolicyEdit, setDirectoryPolicyEdit] = useState<DirectoryPolicyEdit>();
+ const nextDirectoryRootId = useRef(1);
const logsRef = useRef<HTMLPreElement>(null);
const profile = props.profile;
@@ -86,6 +106,7 @@ export function RuntimeHostManagementDialog(props: {
let disposed = false;
setResult(undefined);
setError(undefined);
+ setReconnectWarning(undefined);
setUninstalledRoot(undefined);
setAccess(undefined);
setConfirmation(undefined);
@@ -95,6 +116,7 @@ export function RuntimeHostManagementDialog(props: {
setFixedVersion('');
setUpdatePolicyError(undefined);
setLastUpdateOutcome(undefined);
+ setDirectoryPolicyEdit(undefined);
setLoading(true);
void (async () => {
let shouldLoadUpdatePolicy = false;
@@ -103,6 +125,7 @@ export function RuntimeHostManagementDialog(props: {
if (disposed) return;
if (response.kind === 'result') {
setResult(response);
+ reconcileDirectoryPolicy(response.service);
shouldLoadUpdatePolicy = response.service.state !== 'not_installed';
}
else if (response.kind === 'error') setError(response.error.message);
@@ -142,6 +165,7 @@ export function RuntimeHostManagementDialog(props: {
if (!profile) return;
setLoading(true);
setError(undefined);
+ setReconnectWarning(undefined);
setLastUpdateOutcome(undefined);
try {
const response = await window.maka.runtimeHostManagement.run(profile.id, action);
@@ -158,6 +182,7 @@ export function RuntimeHostManagementDialog(props: {
return;
}
setResult(response);
+ reconcileDirectoryPolicy(response.service);
if (response.service.state === 'not_installed') setUpdatePolicy(undefined);
else if (action !== 'logs') await reloadUpdatePolicy(profile.id);
} catch (failure) {
@@ -189,6 +214,7 @@ export function RuntimeHostManagementDialog(props: {
if (!profile) return;
setLoading(true);
setError(undefined);
+ setReconnectWarning(undefined);
setUpdatePhase('checking');
setLastUpdateOutcome(undefined);
try {
@@ -206,6 +232,8 @@ export function RuntimeHostManagementDialog(props: {
throw new Error('Runtime Host update returned an uninstall result');
}
setResult(response);
+ reconcileDirectoryPolicy(response.service);
+ applyReconnectWarning(response.reconnectError);
if (response.action === 'update') setLastUpdateOutcome(response.update);
setConfirmation(
response.action === 'update' && response.update.kind === 'active_tasks'
@@ -226,6 +254,86 @@ export function RuntimeHostManagementDialog(props: {
}
}
+ function draftDirectoryRoots(
+ roots: readonly { readonly label: string; readonly path: string }[],
+ ): readonly ProjectDirectoryRootDraft[] {
+ return roots.map((root) => ({ id: nextDirectoryRootId.current++, ...root }));
+ }
+
+ function directoryPolicySnapshot(
+ service: DesktopRuntimeHostManagementResult['service'],
+ ): DirectoryPolicySnapshot | undefined {
+ const configurationFingerprint = service.configurationFingerprint;
+ if (!configurationFingerprint) return undefined;
+ return {
+ roots: service.projectDirectoryRoots.map((root) => ({ ...root })),
+ configurationFingerprint,
+ };
+ }
+
+ function resetDirectoryPolicy(
+ service: DesktopRuntimeHostManagementResult['service'],
+ ): void {
+ const baseline = directoryPolicySnapshot(service);
+ setDirectoryPolicyEdit(
+ baseline ? { baseline, draft: draftDirectoryRoots(baseline.roots) } : undefined,
+ );
+ }
+
+ function reconcileDirectoryPolicy(
+ service: DesktopRuntimeHostManagementResult['service'],
+ ): void {
+ const observed = directoryPolicySnapshot(service);
+ setDirectoryPolicyEdit((current) => {
+ if (!observed) return undefined;
+ if (!current) return { baseline: observed, draft: draftDirectoryRoots(observed.roots) };
+ const draft = canonicalProjectDirectoryRoots(current.draft);
+ const dirty = JSON.stringify(draft) !== JSON.stringify(current.baseline.roots);
+ if (!dirty) return { baseline: observed, draft: draftDirectoryRoots(observed.roots) };
+ if (JSON.stringify(observed.roots) === JSON.stringify(current.baseline.roots)) {
+ return { baseline: observed, draft: current.draft };
+ }
+ return { ...current, conflict: observed };
+ });
+ }
+
+ async function configureDirectories(allowInterruptActiveTasks: boolean): Promise<void> {
+ if (!profile || !directoryPolicyEdit || directoryPolicyEdit.conflict) return;
+ setLoading(true);
+ setError(undefined);
+ setReconnectWarning(undefined);
+ try {
+ const response = await window.maka.runtimeHostManagement.configureProjectDirectories(
+ profile.id,
+ canonicalProjectDirectoryRoots(directoryPolicyEdit.draft),
+ directoryPolicyEdit.baseline.configurationFingerprint,
+ allowInterruptActiveTasks,
+ );
+ if (response.kind === 'error') {
+ setError(response.error.message);
+ toast.error(copy.managementActionFailed, response.error.message);
+ return;
+ }
+ if (response.kind === 'uninstalled' || response.action !== 'configure') {
+ throw new Error('Runtime Host configuration returned an unrelated result');
+ }
+ setResult(response);
+ applyReconnectWarning(response.reconnectError);
+ if (response.configuration.kind === 'active_tasks') {
+ setConfirmation({ kind: 'configureDirectories' });
+ } else {
+ setConfirmation(undefined);
+ resetDirectoryPolicy(response.service);
+ }
+ } catch (failure) {
+ const message = settingsActionErrorMessage(failure, locale);
+ setError(message);
+ toast.error(copy.managementActionFailed, message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
function applyUpdatePolicy(snapshot: DesktopRuntimeHostUpdatePolicySnapshot): void {
setUpdatePolicy(snapshot);
const policy = snapshot.policy;
@@ -278,6 +386,7 @@ export function RuntimeHostManagementDialog(props: {
if (!profile) return;
setLoading(true);
setError(undefined);
+ setReconnectWarning(undefined);
setUpdatePolicyError(undefined);
setUpdatePhase('checking');
setLastUpdateOutcome(undefined);
@@ -291,8 +400,10 @@ export function RuntimeHostManagementDialog(props: {
}
setLastUpdateOutcome(response.reconciliation);
applyUpdatePolicy(response.updatePolicy);
+ applyReconnectWarning(response.reconnectError);
const reconciledService = response.service;
if (reconciledService) {
+ reconcileDirectoryPolicy(reconciledService);
setResult((current) => current ? { ...current, service: reconciledService } : current);
}
} catch (failure) {
@@ -321,6 +432,15 @@ export function RuntimeHostManagementDialog(props: {
}
}
+ function applyReconnectWarning(
+ reconnectError: { readonly message: string } | undefined,
+ ): void {
+ setReconnectWarning(reconnectError?.message);
+ if (reconnectError) {
+ toast.warning(copy.managementReconnectFailed, reconnectError.message);
+ }
+ }
+
async function revokeCredential(): Promise<void> {
const revokeTarget = confirmation?.kind === 'revoke'
? confirmation.credential
@@ -356,6 +476,12 @@ export function RuntimeHostManagementDialog(props: {
updatePolicy.policy.version !== fixedVersion.trim());
const automaticPolicySelected = updatePolicyChoice !== 'manual';
const automaticUpdatesAvailable = updatePolicy?.schedulingState === 'ready';
+ const directoryRoots = directoryPolicyEdit?.draft ?? [];
+ const normalizedDirectoryRoots = canonicalProjectDirectoryRoots(directoryRoots);
+ const directoryRootsAreValid = projectDirectoryRootsValid(directoryRoots);
+ const directoryRootsDirty =
+ directoryPolicyEdit !== undefined &&
+ JSON.stringify(normalizedDirectoryRoots) !== JSON.stringify(directoryPolicyEdit.baseline.roots);
const updateOutcome = lastUpdateOutcome;
return (
<Dialog
@@ -387,6 +513,20 @@ export function RuntimeHostManagementDialog(props: {
</div>
) : null}
{error ? <Banner status="error" title={error} /> : null}
+ {reconnectWarning ? (
+ <Banner
+ status="warning"
+ title={copy.managementReconnectFailed}
+ description={reconnectWarning}
+ />
+ ) : null}
+ {confirmation?.kind === 'configureDirectories' ? (
+ <Banner
+ status="warning"
+ title={copy.directoryRootsActiveTasks}
+ description={copy.directoryRootsActiveTasksDescription}
+ />
+ ) : null}
{confirmation?.kind === 'uninstall' ? (
<Banner
status="warning"
@@ -577,17 +717,64 @@ export function RuntimeHostManagementDialog(props: {
) : null}
<div className="settingsRuntimeHostManagementDirectoryRoots">
<Text type="body" weight="semibold">{copy.directoryRoots}</Text>
- {service.projectDirectoryRoots.length > 0 ? (
- <ul className="settingsRuntimeHostManagementRoots">
- {service.projectDirectoryRoots.map((root) => (
- <li key={`${root.label}:${root.path}`}>
- <span>{root.label}</span>
- <code>{root.path}</code>
- </li>
- ))}
- </ul>
+ <Text type="supporting" color="secondary">
+ {copy.directoryRootsDescription}
+ </Text>
+ {directoryPolicyEdit ? (
+ <>
+ {directoryPolicyEdit.conflict ? (
+ <Banner
+ status="warning"
+ title={copy.directoryRootsChanged}
+ description={copy.directoryRootsChangedDescription}
+ endContent={
+ <Button
+ variant="secondary"
+ size="sm"
+ label={copy.reloadDirectoryRoots}
+ onClick={() => {
+ const baseline = directoryPolicyEdit.conflict;
+ if (!baseline) return;
+ setDirectoryPolicyEdit({
+ baseline,
+ draft: draftDirectoryRoots(baseline.roots),
+ });
+ }}
+ />
+ }
+ />
+ ) : null}
+ {directoryRoots.length === 0 ? (
+ <Text type="supporting" color="secondary">{copy.noDirectoryRoots}</Text>
+ ) : null}
+ <RuntimeHostProjectDirectoryEditor
+ roots={directoryRoots}
+ isDisabled={loading}
+ nextId={() => nextDirectoryRootId.current++}
+ copy={copy}
+ onChange={(draft) => setDirectoryPolicyEdit((current) =>
+ current ? { ...current, draft } : current
+ )}
+ />
+ <div className="settingsRuntimeHostManagementDirectoryRootActions">
+ <Button
+ variant="primary"
+ size="sm"
+ label={copy.saveDirectoryRoots}
+ isDisabled={
+ loading ||
+ directoryPolicyEdit.conflict !== undefined ||
+ !directoryRootsDirty ||
+ !directoryRootsAreValid
+ }
+ onClick={() => void configureDirectories(false)}
+ />
+ </div>
+ </>
) : (
- <Text type="supporting" color="secondary">{copy.noDirectoryRoots}</Text>
+ <Text type="supporting" color="secondary">
+ {copy.directoryRootsUnavailable}
+ </Text>
)}
</div>
{result.action === 'logs' ? (
@@ -715,6 +902,21 @@ export function RuntimeHostManagementDialog(props: {
onClick={() => void update(true)}
/>
</>
+ ) : confirmation?.kind === 'configureDirectories' ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.cancel}
+ isDisabled={loading}
+ onClick={() => setConfirmation(undefined)}
+ />
+ <Button
+ variant="destructive"
+ label={copy.configureDirectoriesInterrupt}
+ isDisabled={loading}
+ onClick={() => void configureDirectories(true)}
+ />
+ </>
) : confirmation?.kind === 'uninstall' ? (
<>
<Button
diff --git a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
index 0579535f9..c806c30b0 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
@@ -23,7 +23,15 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Text } from '@astryxdesign/core/Text';
import { Banner, Button, FormLayout, Spinner, TextInput, useUiLocale } from '@maka/ui';
import type { DesktopRuntimeHostOnboardingSnapshot } from '../../preload/bridge-contract.js';
+import {
+ canonicalProjectDirectoryRoots,
+ projectDirectoryRootsValid,
+} from '../../shared/runtime-host-project-directory-policy.js';
import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
+import {
+ RuntimeHostProjectDirectoryEditor,
+ type ProjectDirectoryRootDraft,
+} from './runtime-host-project-directory-editor.js';
export function RuntimeHostOnboardingDialog(props: {
readonly isOpen: boolean;
@@ -40,6 +48,10 @@ export function RuntimeHostOnboardingDialog(props: {
const [name, setName] = useState('');
const [destination, setDestination] = useState('');
const [sshPort, setSshPort] = useState('');
+ const [projectDirectoryRoots, setProjectDirectoryRoots] = useState<
+ readonly ProjectDirectoryRootDraft[]
+ >([]);
+ const nextProjectDirectoryRootId = useRef(1);
useEffect(() => {
if (!props.isOpen) return;
@@ -58,13 +70,19 @@ export function RuntimeHostOnboardingDialog(props: {
}, [props.isOpen]);
const running = snapshot.kind === 'running';
- const canStart = destination.trim().length > 0 && validOptionalPort(sshPort);
+ const canStart =
+ destination.trim().length > 0 &&
+ validOptionalPort(sshPort) &&
+ projectDirectoryRootsValid(projectDirectoryRoots);
async function start(): Promise<void> {
const next = await window.maka.runtimeHostOnboarding.start({
destination: destination.trim(),
...(name.trim() ? { name: name.trim() } : {}),
...(sshPort.trim() ? { sshPort: Number(sshPort) } : {}),
+ ...(projectDirectoryRoots.length === 0
+ ? {}
+ : { projectDirectoryRoots: canonicalProjectDirectoryRoots(projectDirectoryRoots) }),
});
if (next.revision > revision.current) {
revision.current = next.revision;
@@ -143,6 +161,19 @@ export function RuntimeHostOnboardingDialog(props: {
isDisabled={running}
onChange={setSshPort}
/>
+ <div className="settingsRuntimeHostManagementDirectoryRoots">
+ <Text type="body" weight="semibold">{copy.directoryRoots}</Text>
+ <Text type="supporting" color="secondary">
+ {copy.setupDirectoryRootsDescription}
+ </Text>
+ <RuntimeHostProjectDirectoryEditor
+ roots={projectDirectoryRoots}
+ isDisabled={running}
+ nextId={() => nextProjectDirectoryRootId.current++}
+ copy={copy}
+ onChange={setProjectDirectoryRoots}
+ />
+ </div>
</>
) : null}
</FormLayout>
diff --git a/apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx b/apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx
new file mode 100644
index 000000000..9e792a965
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 { Button, TextInput } from '@maka/ui';
+import { PROJECT_DIRECTORY_MAX_ROOTS } from '@maka/runtime-host/protocol';
+import type { SettingsProjectsCopy } from '../locales/settings-projects-copy.js';
+
+export interface ProjectDirectoryRootDraft {
+ readonly id: number;
+ readonly label: string;
+ readonly path: string;
+}
+
+export function RuntimeHostProjectDirectoryEditor(props: {
+ readonly roots: readonly ProjectDirectoryRootDraft[];
+ readonly isDisabled: boolean;
+ readonly nextId: () => number;
+ readonly copy: SettingsProjectsCopy['runtimeHost'];
+ readonly onChange: (roots: readonly ProjectDirectoryRootDraft[]) => void;
+}) {
+ return (
+ <>
+ <div className="settingsRuntimeHostManagementRootEditor">
+ {props.roots.map((root) => (
+ <div className="settingsRuntimeHostManagementRootRow" key={root.id}>
+ <TextInput
+ label={props.copy.directoryRootLabel}
+ value={root.label}
+ isDisabled={props.isDisabled}
+ onChange={(label) => props.onChange(props.roots.map((candidate) =>
+ candidate.id === root.id ? { ...candidate, label } : candidate))}
+ />
+ <TextInput
+ label={props.copy.directoryRootPath}
+ value={root.path}
+ isDisabled={props.isDisabled}
+ onChange={(path) => props.onChange(props.roots.map((candidate) =>
+ candidate.id === root.id ? { ...candidate, path } : candidate))}
+ />
+ <Button
+ variant="secondary"
+ size="sm"
+ label={props.copy.removeDirectoryRoot}
+ isDisabled={props.isDisabled}
+ onClick={() => props.onChange(
+ props.roots.filter((candidate) => candidate.id !== root.id),
+ )}
+ />
+ </div>
+ ))}
+ </div>
+ <Button
+ variant="secondary"
+ size="sm"
+ label={props.copy.addDirectoryRoot}
+ isDisabled={props.isDisabled || props.roots.length >= PROJECT_DIRECTORY_MAX_ROOTS}
+ onClick={() => props.onChange([
+ ...props.roots,
+ { id: props.nextId(), label: '', path: '' },
+ ])}
+ />
+ </>
+ );
+}
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index 2f8158fa8..f0e7db6ce 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -121,16 +121,30 @@
font: var(--maka-text-body);
}
-.settingsRuntimeHostManagementRoots {
+.settingsRuntimeHostManagementDirectoryRoots {
display: grid;
gap: var(--space-2);
- margin: var(--space-2) 0 0;
- padding: 0;
- list-style: none;
+ padding: var(--space-3);
+ border: var(--border-width-hairline) solid var(--border-soft);
+ border-radius: var(--radius-container);
+ background: var(--background-secondary);
}
-.settingsRuntimeHostManagementDirectoryRoots {
+.settingsRuntimeHostManagementRootEditor {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.settingsRuntimeHostManagementRootRow {
display: grid;
+ grid-template-columns: minmax(120px, 0.35fr) minmax(0, 1fr) auto;
+ align-items: end;
+ gap: var(--space-2);
+}
+
+.settingsRuntimeHostManagementDirectoryRootActions {
+ display: flex;
+ justify-content: flex-end;
gap: var(--space-2);
}
@@ -162,12 +176,6 @@
justify-content: flex-end;
}
-.settingsRuntimeHostManagementRoots li {
- display: grid;
- gap: var(--space-1);
-}
-
-.settingsRuntimeHostManagementRoots code,
.settingsRuntimeHostManagementLogs {
overflow-wrap: anywhere;
color: var(--foreground-secondary);
diff --git a/apps/desktop/src/shared/runtime-host-project-directory-policy.ts b/apps/desktop/src/shared/runtime-host-project-directory-policy.ts
new file mode 100644
index 000000000..aedb4df26
--- /dev/null
+++ b/apps/desktop/src/shared/runtime-host-project-directory-policy.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 {
+ canonicalProjectDirectoryRootSpec,
+ PROJECT_DIRECTORY_MAX_ROOTS,
+ projectDirectoryPosixRootSpecValid,
+ type ProjectDirectoryRootSpec,
+} from '@maka/runtime-host/protocol';
+
+export type RuntimeHostProjectDirectoryRootInput = ProjectDirectoryRootSpec;
+
+export function canonicalProjectDirectoryRoots(
+ roots: readonly RuntimeHostProjectDirectoryRootInput[],
+): readonly RuntimeHostProjectDirectoryRootInput[] {
+ return roots.map(canonicalProjectDirectoryRootSpec);
+}
+
+export function projectDirectoryRootsValid(
+ roots: readonly RuntimeHostProjectDirectoryRootInput[],
+): boolean {
+ if (roots.length > PROJECT_DIRECTORY_MAX_ROOTS) return false;
+ const canonical = canonicalProjectDirectoryRoots(roots);
+ return (
+ canonical.every(projectDirectoryPosixRootSpecValid) &&
+ new Set(canonical.map(({ label }) => label)).size === canonical.length
+ );
+}
+
+export function requireProjectDirectoryRoots(
+ value: unknown,
+): readonly RuntimeHostProjectDirectoryRootInput[] {
+ if (!Array.isArray(value) || value.length > PROJECT_DIRECTORY_MAX_ROOTS) {
+ throw new Error('Runtime Host Project directory policy is invalid');
+ }
+ const roots = value.map((candidate) => {
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
+ throw new Error('Runtime Host Project directory is invalid');
+ }
+ const root = candidate as Record<string, unknown>;
+ if (
+ Object.keys(root).length !== 2 ||
+ typeof root.label !== 'string' ||
+ typeof root.path !== 'string'
+ ) {
+ throw new Error('Runtime Host Project directory is invalid');
+ }
+ const canonical = canonicalProjectDirectoryRootSpec({ label: root.label, path: root.path });
+ if (!projectDirectoryPosixRootSpecValid(canonical)) {
+ throw new Error('Runtime Host Project directory is invalid');
+ }
+ return canonical;
+ });
+ if (new Set(roots.map(({ label }) => label)).size !== roots.length) {
+ throw new Error('Runtime Host Project directory labels must be unique');
+ }
+ return roots;
+}
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index acfdde61e..0625e99de 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 213 files — blocker 0, polish 1, aligned 212.
+**Totals:** 214 files — blocker 0, polish 1, aligned 213.
## Exclusions (explicit)
@@ -102,6 +102,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx` | settings-module | Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, Switch | aligned — uses Astryx (Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem) | aligned |
+| `apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx` | settings-module | Button | aligned — uses Astryx (Button) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent) | aligned |
| `apps/desktop/src/renderer/settings/settings-expandable-row.tsx` | settings-module | Button, HStack, Text | aligned — uses Astryx (Button, HStack, Text) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 338067576..d9f6a9a3c 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -74,6 +74,7 @@ apps/desktop/src/renderer/settings/runtime-host-interaction-boundary.tsx
apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx
apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx
apps/desktop/src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx
apps/desktop/src/renderer/settings/settings-expandable-row.tsx
diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index eebda6d31..004b1197d 100644
--- a/docs/runtime-host-remote-access.md
+++ b/docs/runtime-host-remote-access.md
@@ -55,7 +55,7 @@ npm --workspace maka-agent exec -- maka runtime-host project add /srv/projects/e
npm --workspace maka-agent exec -- maka runtime-host project list --root /srv/maka
```
-The Desktop directory picker publishes the service user's home directory by default. To publish a different allowlist, pass one or more named roots when starting the service:
+The Desktop directory picker publishes the service user's home directory by default. Managed services persist that default as an explicit Project root policy. To publish a different allowlist, pass one or more named roots when starting the service:
```sh
npm --workspace maka-agent exec -- maka runtime-host serve \
@@ -67,6 +67,10 @@ npm --workspace maka-agent exec -- maka runtime-host serve \
When any `--project-root <label>=<absolute-path>` option is present, only those roots are available to remote directory browsing. The option is repeatable up to eight times. Maka resolves every root at startup and keeps browsing and registration contained within the selected root.
+Pass `--no-project-roots` to publish an explicit empty policy. This disables directory browsing and registration without removing Projects that are already registered. Desktop-managed SSH Hosts expose the same complete policy in Host settings. Applying it uses the SSH management plane, refuses to interrupt active tasks without confirmation, and restarts the service only when the effective policy changes.
+
+For a managed service, the Host-owned service configuration is the single authority for this policy. The system service starts from that configuration instead of retaining a second copy of the roots in its launch definition.
+
Project paths stay on the Host. Issue a credential for each Client:
```sh
diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
index 7c13e65cf..8afb1149d 100644
--- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
@@ -30,7 +30,11 @@ import {
resolveLaunchAgentPath,
resolveLaunchAgentUpdatePath,
} from '../runtime-host-launch-agent-service.js';
-import type { RuntimeHostManagedServiceConfig } from '../runtime-host-service-manager.js';
+import type {
+ RuntimeHostManagedServiceConfig,
+ RuntimeHostServiceBackend,
+ RuntimeHostServiceDeployment,
+} from '../runtime-host-service-manager.js';
const SERVICE_ID = 'a'.repeat(64);
const UID = 501;
@@ -42,11 +46,15 @@ const UPDATE_TARGET = `${DOMAIN}/${UPDATE_LABEL}`;
test('renders the canonical Runtime Host command as a private persistent LaunchAgent', () => {
const config = fixtureConfig('/tmp/node & tool', '/tmp/maka <cli>', '/tmp/state > root');
- const plist = renderLaunchAgentPlist(config, {
- label: LABEL,
- stdoutPath: '/tmp/stdout & log',
- stderrPath: '/tmp/stderr < log',
- });
+ const plist = renderLaunchAgentPlist(
+ config,
+ {
+ label: LABEL,
+ stdoutPath: '/tmp/stdout & log',
+ stderrPath: '/tmp/stderr < log',
+ },
+ '/tmp/runtime-host-service.json',
+ );
assert.match(
plist,
@@ -56,8 +64,9 @@ test('renders the canonical Runtime Host command as a private persistent LaunchA
assert.match(plist, /<key>Umask<\/key>\n <integer>63<\/integer>/u);
assert.match(plist, /<string>\/tmp\/node & tool<\/string>/u);
assert.match(plist, /<string>\/tmp\/maka <cli><\/string>/u);
- assert.match(plist, /<string>\/tmp\/state > root<\/string>/u);
- assert.match(plist, /<string>workspace=\/tmp\/projects<\/string>/u);
+ assert.match(plist, /<string>--managed-service-config<\/string>/u);
+ assert.match(plist, /<string>\/tmp\/runtime-host-service\.json<\/string>/u);
+ assert.doesNotMatch(plist, /state > root|workspace=/u);
});
test('renders managed update reconciliation as a periodic one-shot LaunchAgent', () => {
@@ -81,6 +90,7 @@ test('renders managed update reconciliation as a periodic one-shot LaunchAgent',
test('installs and removes the update scheduler with a managed LaunchAgent', async () => {
await withFixture(async ({ homeDir, cliPath, launchctl }) => {
const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
+ serviceConfigPath: join(homeDir, 'runtime-host-service.json'),
homeDir,
uid: UID,
runLaunchctl: launchctl.run,
@@ -91,7 +101,7 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy
managedDeploymentRoot: join(homeDir, 'managed'),
};
- await backend.install(config);
+ await applyStagedDeployment(backend, config);
await backend.verifyDeployment(config);
const updatePath = resolveLaunchAgentUpdatePath(SERVICE_ID, homeDir);
assert.match(await readFile(updatePath, 'utf8'), /reconcile-update/u);
@@ -135,7 +145,7 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy
(error: unknown) =>
error instanceof Error && 'code' in error && error.code === 'target_mismatch',
);
- await backend.install(config);
+ await applyStagedDeployment(backend, config);
await backend.verifyDeployment(config);
await backend.stop();
@@ -151,7 +161,7 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy
await backend.verifyDeployment(config, { requireSchedulerReady: true });
const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = config;
- await backend.install(unmanagedConfig);
+ await applyStagedDeployment(backend, unmanagedConfig);
await backend.verifyDeployment(unmanagedConfig);
assert.equal(await fileExists(updatePath), false);
assert.equal(launchctl.updateLoaded, false);
@@ -169,6 +179,7 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser
await withFixture(async ({ homeDir, cliPath, launchctl }) => {
let processChecks = 0;
const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
+ serviceConfigPath: join(homeDir, 'runtime-host-service.json'),
homeDir,
uid: UID,
runLaunchctl: launchctl.run,
@@ -179,8 +190,8 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser
});
const config = fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'));
- await backend.preflightInstall();
- await backend.install(config);
+ await backend.preflightDeployment();
+ await applyStagedDeployment(backend, config);
await backend.verifyDeployment(config);
assert.deepEqual(await backend.status(), {
manager: 'launch_agent',
@@ -227,6 +238,43 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser
});
});
+test('recognizes and transactionally replaces the exact legacy LaunchAgent definition', async () => {
+ await withFixture(async ({ homeDir, cliPath, launchctl }) => {
+ const config = fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'));
+ const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir);
+ const legacyPlist = legacyLaunchAgentPlistFixture(config, homeDir);
+ await writeFile(plistPath, legacyPlist, { mode: 0o600 });
+ launchctl.loaded = true;
+ launchctl.running = true;
+ const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
+ serviceConfigPath: join(homeDir, 'runtime-host-service.json'),
+ homeDir,
+ uid: UID,
+ runLaunchctl: launchctl.run,
+ isProcessAlive: () => false,
+ });
+
+ await assert.rejects(
+ backend.verifyDeployment(config),
+ (error: unknown) =>
+ error instanceof Error && 'code' in error && error.code === 'target_mismatch',
+ );
+ await backend.verifyDeployment(config, { acceptLegacyConfigLaunch: true });
+
+ const deployment = await backend.stageDeployment();
+ await backend.retire();
+ await deployment.apply({ ...config, schemaVersion: 2 });
+ await backend.verifyDeployment({ ...config, schemaVersion: 2 });
+ assert.match(await readFile(plistPath, 'utf8'), /--managed-service-config/u);
+
+ await backend.retire();
+ await deployment.rollback();
+ assert.equal(await readFile(plistPath, 'utf8'), legacyPlist);
+ assert.equal(launchctl.loaded, true);
+ assert.equal(launchctl.running, true);
+ });
+});
+
test('restores the previous loaded LaunchAgent when deployment bootstrap fails', async () => {
for (const action of ['install', 'replace'] as const) {
await withFixture(async ({ homeDir, cliPath, launchctl }) => {
@@ -236,16 +284,27 @@ test('restores the previous loaded LaunchAgent when deployment bootstrap fails',
launchctl.loaded = true;
launchctl.failNextBootstrap = true;
const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
+ serviceConfigPath: join(homeDir, 'runtime-host-service.json'),
homeDir,
uid: UID,
runLaunchctl: launchctl.run,
isProcessAlive: () => false,
});
- await assert.rejects(
- backend[action](fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))),
- /Starting the Runtime Host LaunchAgent failed/u,
- );
+ const config = fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'));
+ if (action === 'install') {
+ const deployment = await backend.stageDeployment();
+ await assert.rejects(
+ deployment.apply(config),
+ /Starting the Runtime Host LaunchAgent failed/u,
+ );
+ await deployment.rollback();
+ } else {
+ await assert.rejects(
+ backend.replace(config),
+ /Starting the Runtime Host LaunchAgent failed/u,
+ );
+ }
assert.equal(await readFile(plistPath, 'utf8'), previousPlist);
assert.equal(launchctl.loaded, true);
});
@@ -346,6 +405,77 @@ function fixtureConfig(
};
}
+function legacyLaunchAgentPlistFixture(
+ config: RuntimeHostManagedServiceConfig,
+ homeDir: string,
+): string {
+ const escape = (value: string) =>
+ value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ const args = [
+ config.launch.nodePath,
+ config.launch.cliPath,
+ 'runtime-host',
+ 'serve',
+ '--root',
+ config.rootPath,
+ ...config.projectDirectoryRoots.flatMap(({ label, path }) => [
+ '--project-root',
+ `${label}=${path}`,
+ ]),
+ '--websocket-host',
+ config.websocket.host,
+ '--websocket-port',
+ String(config.websocket.port),
+ '--websocket-path',
+ config.websocket.path,
+ '--json',
+ ];
+ return [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">',
+ '<plist version="1.0">',
+ '<dict>',
+ ' <key>Label</key>',
+ ` <string>${LABEL}</string>`,
+ ' <key>ProgramArguments</key>',
+ ' <array>',
+ ...args.map((value) => ` <string>${escape(value)}</string>`),
+ ' </array>',
+ ' <key>KeepAlive</key>',
+ ' <dict>',
+ ' <key>SuccessfulExit</key>',
+ ' <false/>',
+ ' </dict>',
+ ' <key>ThrottleInterval</key>',
+ ' <integer>2</integer>',
+ ' <key>ExitTimeOut</key>',
+ ' <integer>45</integer>',
+ ' <key>Umask</key>',
+ ' <integer>63</integer>',
+ ' <key>StandardOutPath</key>',
+ ` <string>${escape(join(homeDir, 'Library', 'Logs', 'Maka', 'runtime-host-services', `${LABEL}.stdout.log`))}</string>`,
+ ' <key>StandardErrorPath</key>',
+ ` <string>${escape(join(homeDir, 'Library', 'Logs', 'Maka', 'runtime-host-services', `${LABEL}.stderr.log`))}</string>`,
+ '</dict>',
+ '</plist>',
+ '',
+ ].join('\n');
+}
+
+async function applyStagedDeployment(
+ backend: RuntimeHostServiceBackend,
+ config: RuntimeHostManagedServiceConfig,
+): Promise<RuntimeHostServiceDeployment> {
+ const deployment = await backend.stageDeployment();
+ await deployment.apply(config);
+ return deployment;
+}
+
async function withFixture(
operation: (fixture: {
homeDir: string;
diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
index 72f98ecfa..f320d6e3d 100644
--- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
@@ -69,6 +69,11 @@ describe('Runtime Host operator commands', () => {
prefer: true,
},
);
+ assert.deepEqual(parseRuntimeHostCommand(['serve', '--json', '--no-project-roots']), {
+ kind: 'runtime-host-serve',
+ json: true,
+ projectDirectoryRoots: [],
+ });
assert.deepEqual(
parseRuntimeHostCommand([
'serve',
@@ -87,6 +92,41 @@ describe('Runtime Host operator commands', () => {
],
},
);
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'serve',
+ '--project-root-json',
+ JSON.stringify({ label: 'Work=Primary', path: projectRootA }),
+ ]),
+ {
+ kind: 'runtime-host-serve',
+ json: false,
+ projectDirectoryRoots: [{ label: 'Work=Primary', path: projectRootA }],
+ },
+ );
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'serve',
+ '--managed-service-config',
+ '/config/Maka/runtime-host-service.json',
+ '--json',
+ ]),
+ {
+ kind: 'runtime-host-serve',
+ managedServiceConfigPath: '/config/Maka/runtime-host-service.json',
+ json: true,
+ },
+ );
+ assert.equal(
+ parseRuntimeHostCommand([
+ 'serve',
+ '--managed-service-config',
+ '/config/Maka/runtime-host-service.json',
+ '--root',
+ '/srv/maka',
+ ]).kind,
+ 'error',
+ );
assert.deepEqual(
parseRuntimeHostCommand([
'serve',
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 3d92256d5..4194f73ef 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -36,6 +36,7 @@ import {
decodeRuntimeHostServiceManagementFrame,
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
RUNTIME_HOST_SERVICE_LOG_MAX_BYTES,
type RuntimeHostOperatorCapability,
@@ -43,6 +44,7 @@ import {
} from '@maka/runtime-host/operator';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
+import { runtimeHostServiceLaunchArguments } from '../runtime-host-service-launch.js';
import {
openRuntimeHostManagedPackageDeployment,
prepareRuntimeHostManagedPackageDeployment,
@@ -54,10 +56,12 @@ import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-managem
import { runManagedRuntimeHostUpdateCli } from '../runtime-host-update-command.js';
import {
cleanupRuntimeHostManagedDeployment,
+ effectiveRuntimeHostProjectDirectoryRoots,
manageRuntimeHostService,
replaceRuntimeHostManagedService,
resolveRuntimeHostManagedServiceConfigPath,
resolveRuntimeHostManagedServiceId,
+ runtimeHostManagedServiceConfigFingerprint,
RuntimeHostServiceManagerError,
type RuntimeHostManagedServiceConfig,
type RuntimeHostManagedServiceResult,
@@ -79,6 +83,15 @@ import {
describe('managed Runtime Host service', () => {
it('parses the bounded managed service command surface', () => {
+ const nativeProjectRoot = process.platform === 'win32' ? 'C:\\projects' : '/srv/projects';
+ assert.deepEqual(
+ parseRuntimeHostCommand(['serve', '--project-root', `Native=${nativeProjectRoot}`]),
+ {
+ kind: 'runtime-host-serve',
+ json: false,
+ projectDirectoryRoots: [{ label: 'Native', path: nativeProjectRoot }],
+ },
+ );
assert.deepEqual(
parseRuntimeHostCommand([
'service',
@@ -100,6 +113,35 @@ describe('managed Runtime Host service', () => {
websocketPort: 7443,
},
);
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'service',
+ 'install',
+ '--project-root-json',
+ JSON.stringify({ label: 'Home=Primary', path: '/home/ada' }),
+ ]),
+ {
+ kind: 'runtime-host-service-manage',
+ action: 'install',
+ json: false,
+ projectDirectoryRoots: [{ label: 'Home=Primary', path: '/home/ada' }],
+ },
+ );
+ assert.equal(
+ parseRuntimeHostCommand([
+ 'service',
+ 'configure',
+ '--expected-config-fingerprint',
+ `sha256:${'c'.repeat(64)}`,
+ '--expected-service-id',
+ 'b'.repeat(64),
+ '--expected-root-path',
+ '/srv/maka',
+ '--expected-root-id',
+ 'a'.repeat(64),
+ ]).kind,
+ 'error',
+ );
assert.deepEqual(parseRuntimeHostCommand(['service', 'status', '--framed']), {
kind: 'runtime-host-service-manage',
action: 'status',
@@ -172,6 +214,33 @@ describe('managed Runtime Host service', () => {
},
);
assert.equal(parseRuntimeHostCommand(['service', 'retire', '--framed']).kind, 'error');
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'service',
+ 'configure',
+ '--no-project-roots',
+ '--expected-config-fingerprint',
+ `sha256:${'c'.repeat(64)}`,
+ '--expected-service-id',
+ 'b'.repeat(64),
+ '--expected-root-path',
+ '/srv/maka',
+ '--expected-root-id',
+ 'a'.repeat(64),
+ ]),
+ {
+ kind: 'runtime-host-service-manage',
+ action: 'configure',
+ json: false,
+ projectDirectoryRoots: [],
+ expectedConfigFingerprint: `sha256:${'c'.repeat(64)}`,
+ expectedTarget: {
+ serviceId: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ rootId: 'a'.repeat(64),
+ },
+ },
+ );
assert.deepEqual(
parseRuntimeHostCommand([
'service',
@@ -271,6 +340,520 @@ describe('managed Runtime Host service', () => {
);
});
+ it('applies Project roots as a compare-and-set transaction and restores failed changes', async (t) => {
+ const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-runtime-host-configure-')));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const homeDir = join(base, 'home');
+ const firstRoot = join(base, 'first');
+ const secondRoot = join(base, 'second');
+ const stateRoot = await resolveStorageRoot({
+ path: join(base, 'state'),
+ kind: 'interactive',
+ });
+ const clientDataRoot = join(base, 'config');
+ const cliPath = join(base, 'maka', 'dist', 'cli.js');
+ await Promise.all([
+ mkdir(homeDir, { recursive: true }),
+ mkdir(firstRoot, { recursive: true }),
+ mkdir(secondRoot, { recursive: true }),
+ mkdir(dirname(cliPath), { recursive: true }),
+ ]);
+ await writeFile(cliPath, '#!/usr/bin/env node\n', 'utf8');
+ let state: 'running' | 'stopped' = 'running';
+ let retireCalls = 0;
+ let failRetirement = false;
+ let verifyCalls = 0;
+ const startedPolicies: string[][] = [];
+ const startPersistedConfig = async () => {
+ const persisted = JSON.parse(
+ await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
+ ) as RuntimeHostManagedServiceConfig;
+ startedPolicies.push(persisted.projectDirectoryRoots.map(({ label }) => label));
+ state = 'running';
+ };
+ const backend: RuntimeHostServiceBackend = {
+ ...createUnusedBackend(),
+ preflightDeployment: async () => undefined,
+ stageDeployment: async () => ({
+ apply: startPersistedConfig,
+ rollback: async () => undefined,
+ }),
+ status: async () => ({
+ manager: 'systemd_user',
+ installed: true,
+ enabled: true,
+ active: state === 'running',
+ state,
+ pid: state === 'running' ? 42 : null,
+ lastExitCode: 0,
+ }),
+ retire: async () => {
+ retireCalls += 1;
+ if (failRetirement) throw new Error('service would not stop');
+ state = 'stopped';
+ },
+ verifyDeployment: async () => {
+ verifyCalls += 1;
+ },
+ start: startPersistedConfig,
+ };
+ const common = {
+ clientDataRoot,
+ defaultRootPath: stateRoot.canonicalPath,
+ nodePath: process.execPath,
+ cliPath,
+ } as const;
+ const deps = {
+ homeDir,
+ waitForReady: async () => undefined,
+ prepareRetirement: async () => ({ kind: 'active_tasks' as const }),
+ };
+ const installed = await manageRuntimeHostService(
+ { ...common, action: 'install' },
+ backend,
+ deps,
+ );
+ assert.equal(installed.service.config?.schemaVersion, 2);
+ assert.deepEqual(installed.service.config?.projectDirectoryRoots, [
+ { label: '~', path: await realpath(homeDir) },
+ ]);
+ const installedConfig = installed.service.config;
+ assert.ok(installedConfig);
+ startedPolicies.length = 0;
+ const legacyImplicitHome: RuntimeHostManagedServiceConfig = {
+ ...installedConfig,
+ schemaVersion: 1,
+ projectDirectoryRoots: [],
+ };
+ assert.deepEqual(effectiveRuntimeHostProjectDirectoryRoots(legacyImplicitHome, homeDir), [
+ { label: '~', path: await realpath(homeDir) },
+ ]);
+ const explicitEmpty: RuntimeHostManagedServiceConfig = {
+ ...installedConfig,
+ schemaVersion: 2,
+ projectDirectoryRoots: [],
+ };
+ assert.deepEqual(effectiveRuntimeHostProjectDirectoryRoots(explicitEmpty, homeDir), []);
+ assert.doesNotMatch(
+ runtimeHostServiceLaunchArguments(legacyImplicitHome, '/config/service.json').join(' '),
+ /project-root/u,
+ );
+ assert.doesNotMatch(
+ runtimeHostServiceLaunchArguments(explicitEmpty, '/config/service.json').join(' '),
+ /project-root/u,
+ );
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'install',
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ },
+ backend,
+ deps,
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'configuration_changed',
+ );
+ assert.deepEqual(
+ JSON.parse(await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'))
+ .projectDirectoryRoots,
+ installedConfig.projectDirectoryRoots,
+ );
+ const expectedTarget = {
+ serviceId: resolveRuntimeHostManagedServiceId(clientDataRoot),
+ rootPath: stateRoot.canonicalPath,
+ rootId: stateRoot.rootId,
+ };
+ const fingerprint = runtimeHostManagedServiceConfigFingerprint(installedConfig);
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: fingerprint,
+ projectDirectoryRoots: [{ label: 'Missing', path: join(base, 'missing') }],
+ allowInterruptActiveTasks: true,
+ },
+ backend,
+ deps,
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'invalid_config',
+ );
+ assert.equal(retireCalls, 0);
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: `sha256:${'0'.repeat(64)}`,
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ },
+ backend,
+ deps,
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'configuration_changed',
+ );
+ const blocked = await manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: fingerprint,
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ },
+ backend,
+ deps,
+ );
+ assert.equal(blocked.action, 'configure');
+ assert.deepEqual(blocked.configuration, { kind: 'active_tasks' });
+ assert.equal(retireCalls, 0);
+ assert.equal(verifyCalls, 1);
+
+ const configured = await manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: fingerprint,
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ allowInterruptActiveTasks: true,
+ },
+ backend,
+ {
+ ...deps,
+ prepareRetirement: async () => ({
+ kind: 'prepared' as const,
+ hostEpoch: 'host-1',
+ pid: 42,
+ }),
+ },
+ );
+ assert.equal(configured.action, 'configure');
+ assert.deepEqual(configured.configuration, { kind: 'configured' });
+ assert.equal(retireCalls, 1);
+ assert.equal(verifyCalls, 2);
+ assert.deepEqual(startedPolicies, [['First']]);
+ assert.deepEqual(configured.service.config?.projectDirectoryRoots, [
+ { label: 'First', path: await realpath(firstRoot) },
+ ]);
+
+ const configuredConfig = configured.service.config;
+ assert.ok(configuredConfig);
+ const unchanged = await manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: runtimeHostManagedServiceConfigFingerprint(configuredConfig),
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ },
+ backend,
+ deps,
+ );
+ assert.equal(unchanged.action, 'configure');
+ assert.deepEqual(unchanged.configuration, { kind: 'unchanged' });
+ assert.equal(retireCalls, 1);
+
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: runtimeHostManagedServiceConfigFingerprint(configuredConfig),
+ projectDirectoryRoots: [{ label: 'Second', path: secondRoot }],
+ allowInterruptActiveTasks: true,
+ },
+ backend,
+ {
+ ...deps,
+ prepareRetirement: async () => ({
+ kind: 'prepared' as const,
+ hostEpoch: 'host-2',
+ pid: 42,
+ }),
+ waitForReady: async (config) => {
+ if (config.projectDirectoryRoots[0]?.label === 'Second') {
+ throw new Error('new policy did not become ready');
+ }
+ },
+ },
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError &&
+ error.code === 'configuration_incomplete' &&
+ /previous configuration was restored/u.test(error.message),
+ );
+ const restored = JSON.parse(
+ await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
+ ) as RuntimeHostManagedServiceConfig;
+ assert.deepEqual(restored.projectDirectoryRoots, configuredConfig.projectDirectoryRoots);
+ assert.equal(state, 'running');
+ assert.equal(verifyCalls, 3);
+ assert.deepEqual(startedPolicies, [['First'], ['Second'], ['First']]);
+
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: runtimeHostManagedServiceConfigFingerprint(configuredConfig),
+ projectDirectoryRoots: [{ label: 'Second', path: secondRoot }],
+ allowInterruptActiveTasks: true,
+ },
+ backend,
+ {
+ ...deps,
+ prepareRetirement: async () => ({
+ kind: 'prepared' as const,
+ hostEpoch: 'host-3',
+ pid: 42,
+ }),
+ waitForReady: async (config) => {
+ if (config.projectDirectoryRoots[0]?.label === 'Second') {
+ failRetirement = true;
+ throw new Error('candidate policy did not become ready');
+ }
+ },
+ },
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError &&
+ error.code === 'configuration_incomplete' &&
+ /configuration was retained/u.test(error.message),
+ );
+ const retainedCandidate = JSON.parse(
+ await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
+ ) as RuntimeHostManagedServiceConfig;
+ assert.deepEqual(retainedCandidate.projectDirectoryRoots, [
+ { label: 'Second', path: await realpath(secondRoot) },
+ ]);
+ assert.equal(state, 'running');
+
+ failRetirement = false;
+ const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot);
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ ...common,
+ action: 'configure',
+ expectedTarget,
+ expectedConfigFingerprint: runtimeHostManagedServiceConfigFingerprint(retainedCandidate),
+ projectDirectoryRoots: [{ label: 'First', path: firstRoot }],
+ allowInterruptActiveTasks: true,
+ },
+ backend,
+ {
+ ...deps,
+ prepareRetirement: async () => ({
+ kind: 'prepared' as const,
+ hostEpoch: 'host-4',
+ pid: 42,
+ }),
+ waitForReady: async (config) => {
+ if (config.projectDirectoryRoots[0]?.label === 'First') {
+ await writeFile(configPath, '{"schemaVersion":999}\n', 'utf8');
+ throw new Error('candidate corrupted the persisted revision');
+ }
+ },
+ },
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError &&
+ error.code === 'configuration_incomplete' &&
+ /remains stopped for inspection/u.test(error.message),
+ );
+ assert.equal(state, 'stopped');
+ });
+
+ it('restores install config only after the candidate process is quiescent', async (t) => {
+ const base = await realpath(
+ await mkdtemp(join(tmpdir(), 'maka-runtime-host-install-rollback-')),
+ );
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const stateRoot = await resolveStorageRoot({
+ path: join(base, 'state'),
+ kind: 'interactive',
+ });
+ const projectRoot = join(base, 'projects');
+ const clientDataRoot = join(base, 'config');
+ const cliPath = join(base, 'maka', 'dist', 'cli.js');
+ await Promise.all([
+ mkdir(projectRoot, { recursive: true }),
+ mkdir(dirname(cliPath), { recursive: true }),
+ mkdir(clientDataRoot, { recursive: true }),
+ ]);
+ await writeFile(cliPath, '#!/usr/bin/env node\n', 'utf8');
+ const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot);
+ const previous: RuntimeHostManagedServiceConfig = {
+ schemaVersion: 2,
+ rootPath: stateRoot.canonicalPath,
+ projectDirectoryRoots: [{ label: 'Projects', path: await realpath(projectRoot) }],
+ websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' },
+ launch: { nodePath: await realpath(process.execPath), cliPath: await realpath(cliPath) },
+ };
+ await writeFile(configPath, `${JSON.stringify(previous, null, 2)}\n`, 'utf8');
+ let active = true;
+ let rollbackLoadedPort: number | undefined;
+ const backend: RuntimeHostServiceBackend = {
+ ...createReadyBackend(),
+ stageDeployment: async () => ({
+ apply: async () => {
+ active = true;
+ throw new Error('candidate deployment failed');
+ },
+ rollback: async () => {
+ assert.equal(active, false);
+ rollbackLoadedPort = (
+ JSON.parse(await readFile(configPath, 'utf8')) as RuntimeHostManagedServiceConfig
+ ).websocket.port;
+ active = true;
+ },
+ }),
+ status: async () => ({
+ manager: 'systemd_user',
+ installed: true,
+ enabled: true,
+ active,
+ state: active ? 'running' : 'stopped',
+ pid: active ? 42 : null,
+ lastExitCode: 0,
+ }),
+ retire: async () => {
+ active = false;
+ },
+ };
+
+ await assert.rejects(
+ manageRuntimeHostService(
+ {
+ action: 'install',
+ clientDataRoot,
+ defaultRootPath: stateRoot.canonicalPath,
+ websocketPort: 8443,
+ nodePath: process.execPath,
+ cliPath,
+ },
+ backend,
+ { waitForReady: async () => undefined },
+ ),
+ /candidate deployment failed/u,
+ );
+ assert.equal(rollbackLoadedPort, 7443);
+ assert.equal(
+ (JSON.parse(await readFile(configPath, 'utf8')) as RuntimeHostManagedServiceConfig).websocket
+ .port,
+ 7443,
+ );
+ assert.equal(active, true);
+ });
+
+ it('migrates and restores an exact legacy systemd deployment inside configure', async (t) => {
+ const base = await realpath(
+ await mkdtemp(join(tmpdir(), 'maka-runtime-host-legacy-configure-')),
+ );
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const clientDataRoot = join(base, 'config');
+ const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot);
+ const cliPath = join(base, 'maka', 'dist', 'cli.js');
+ const oldProjectRoot = join(base, 'old-projects');
+ const newProjectRoot = join(base, 'new-projects');
+ const root = await resolveStorageRoot({ path: join(base, 'state'), kind: 'interactive' });
+ await Promise.all([
+ mkdir(dirname(cliPath), { recursive: true }),
+ mkdir(dirname(configPath), { recursive: true }),
+ mkdir(oldProjectRoot, { recursive: true }),
+ mkdir(newProjectRoot, { recursive: true }),
+ ]);
+ await writeFile(cliPath, '#!/usr/bin/env node\n', 'utf8');
+ const legacyConfig: RuntimeHostManagedServiceConfig = {
+ schemaVersion: 1,
+ rootPath: root.canonicalPath,
+ projectDirectoryRoots: [{ label: 'Old', path: await realpath(oldProjectRoot) }],
+ websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' },
+ launch: { nodePath: await realpath(process.execPath), cliPath: await realpath(cliPath) },
+ };
+ await writeFile(configPath, `${JSON.stringify(legacyConfig, null, 2)}\n`, 'utf8');
+ const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
+ const unitPath = resolveSystemdUserRuntimeHostServicePath(serviceId, {
+ XDG_CONFIG_HOME: base,
+ });
+ await mkdir(dirname(unitPath), { recursive: true });
+ const legacyUnit = legacySystemdUnitFixture(legacyConfig);
+ await writeFile(unitPath, legacyUnit, 'utf8');
+ const systemd = createFakeSystemd(unitPath);
+ const unitName = basename(unitPath);
+ await systemd.run(['enable', unitName]);
+ await systemd.run(['start', unitName]);
+ const backend = () =>
+ createSystemdUserRuntimeHostService(serviceId, {
+ serviceConfigPath: configPath,
+ env: { XDG_CONFIG_HOME: base },
+ homeDir: base,
+ uid: 1000,
+ runSystemctl: systemd.run,
+ runLoginctl: async () => success('yes\n'),
+ });
+ await assert.rejects(
+ backend().verifyDeployment(legacyConfig),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
+ );
+ await backend().verifyDeployment(legacyConfig, { acceptLegacyConfigLaunch: true });
+ const input = {
+ action: 'configure' as const,
+ clientDataRoot,
+ defaultRootPath: root.canonicalPath,
+ nodePath: process.execPath,
+ cliPath,
+ expectedTarget: {
+ serviceId,
+ rootPath: root.canonicalPath,
+ rootId: root.rootId,
+ },
+ expectedConfigFingerprint: runtimeHostManagedServiceConfigFingerprint(legacyConfig),
+ projectDirectoryRoots: [{ label: 'New', path: newProjectRoot }],
+ allowInterruptActiveTasks: true,
+ } as const;
+ let failCandidate = true;
+ const deps = {
+ homeDir: base,
+ platform: 'linux' as const,
+ prepareRetirement: async (_config: RuntimeHostManagedServiceConfig, pid: number) => ({
+ kind: 'prepared' as const,
+ hostEpoch: 'legacy-host',
+ pid,
+ }),
+ waitForReady: async (config: RuntimeHostManagedServiceConfig) => {
+ if (config.schemaVersion === 2 && failCandidate) {
+ failCandidate = false;
+ throw new Error('migrated candidate failed readiness');
+ }
+ },
+ };
+
+ await assert.rejects(
+ manageRuntimeHostService(input, backend(), deps),
+ /previous configuration was restored/u,
+ );
+ assert.equal(await readFile(unitPath, 'utf8'), legacyUnit);
+ assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')), legacyConfig);
+
+ const configured = await manageRuntimeHostService(input, backend(), deps);
+ assert.equal(configured.action, 'configure');
+ if (configured.action !== 'configure') assert.fail('Expected configure result');
+ assert.deepEqual(configured.configuration, { kind: 'configured' });
+ assert.equal(configured.service.config?.schemaVersion, 2);
+ assert.match(await readFile(unitPath, 'utf8'), /--managed-service-config/u);
+ assert.doesNotMatch(await readFile(unitPath, 'utf8'), /--project-root/u);
+ });
+
it('installs, reports, and cleanly uninstalls while retaining the State Root', async (t) => {
const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-')));
t.after(() => rm(base, { recursive: true, force: true }));
@@ -279,7 +862,10 @@ describe('managed Runtime Host service', () => {
const rootPath = join(base, 'state root');
const projectPath = join(base, 'projects');
await writeFile(join(base, 'placeholder'), '', 'utf8');
- await mkdir(projectPath, { recursive: true });
+ await Promise.all([
+ mkdir(homeDir, { recursive: true }),
+ mkdir(projectPath, { recursive: true }),
+ ]);
const env = {
XDG_CONFIG_HOME: join(base, 'xdg-config'),
XDG_DATA_HOME: join(base, 'xdg-data'),
@@ -299,6 +885,7 @@ describe('managed Runtime Host service', () => {
const systemd = createFakeSystemd(unitPath);
const backend = () =>
createSystemdUserRuntimeHostService(serviceId, {
+ serviceConfigPath: configPath,
env,
homeDir,
uid: 1000,
@@ -367,7 +954,7 @@ describe('managed Runtime Host service', () => {
const repairBackend = backend();
const updateTimerName = basename(updateTimerPath);
systemd.setUnitDropInPaths(updateTimerName, ['/tmp/update-timer-override.conf']);
- await assert.rejects(repairBackend.install(managedConfig), /systemd drop-in overrides/u);
+ await assert.rejects(repairBackend.stageDeployment(), /systemd drop-in overrides/u);
systemd.setUnitDropInPaths(updateTimerName, []);
await writeFile(updateTimerPath, '[Timer]\n# stale\n', 'utf8');
await assert.rejects(
@@ -380,7 +967,7 @@ describe('managed Runtime Host service', () => {
(error: unknown) =>
error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
);
- await repairBackend.install(managedConfig);
+ await applyStagedDeployment(repairBackend, managedConfig);
await repairBackend.verifyDeployment(managedConfig);
const updateServiceName = basename(updateServicePath);
@@ -407,6 +994,7 @@ describe('managed Runtime Host service', () => {
await repairBackend.verifyDeployment(managedConfig, { requireSchedulerReady: true });
const diagnosticBackend = createSystemdUserRuntimeHostService(serviceId, {
+ serviceConfigPath: configPath,
env,
homeDir,
uid: 1000,
@@ -424,7 +1012,7 @@ describe('managed Runtime Host service', () => {
assert.ok(Buffer.byteLength(logs) <= RUNTIME_HOST_SERVICE_LOG_MAX_BYTES);
const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = managedConfig;
- await repairBackend.install(unmanagedConfig);
+ await applyStagedDeployment(repairBackend, unmanagedConfig);
await repairBackend.verifyDeployment(unmanagedConfig);
await assert.rejects(readFile(updateServicePath, 'utf8'), { code: 'ENOENT' });
await assert.rejects(readFile(updateTimerPath, 'utf8'), { code: 'ENOENT' });
@@ -654,6 +1242,7 @@ describe('managed Runtime Host service', () => {
unitPath,
systemd,
backend: createSystemdUserRuntimeHostService(serviceId, {
+ serviceConfigPath: resolveRuntimeHostManagedServiceConfigPath(clientDataRoot),
env,
homeDir,
uid: 1000,
@@ -736,9 +1325,8 @@ describe('managed Runtime Host service', () => {
cliPath: '/opt/Maka/$current/cli.js',
},
};
- const unit = renderSystemdUnit(config);
- assert.match(unit, /"\/srv\/Maka \$\$100%%"/u);
- assert.match(unit, /"Cash\$\$=\/home\/\$\$ada\/My Projects"/u);
+ const unit = renderSystemdUnit(config, '/srv/Maka $100%/runtime-host-service.json');
+ assert.match(unit, /"\/srv\/Maka \$\$100%%\/runtime-host-service\.json"/u);
assert.match(unit, /"\/opt\/\$\$Node 24\/bin\/node"/u);
assert.match(unit, /"\/opt\/Maka\/\$\$current\/cli\.js"/u);
assert.match(unit, /^Restart=on-failure$/mu);
@@ -915,13 +1503,22 @@ describe('managed Runtime Host service', () => {
it('projects requested operator capabilities without launch configuration', async (t) => {
const previousCapabilityRequest = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
+ const previousConfigurationRequest =
+ process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV];
delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
+ delete process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV];
t.after(() => {
if (previousCapabilityRequest === undefined) {
delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
} else {
process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = previousCapabilityRequest;
}
+ if (previousConfigurationRequest === undefined) {
+ delete process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV];
+ } else {
+ process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] =
+ previousConfigurationRequest;
+ }
});
const options = {
action: 'status' as const,
@@ -985,8 +1582,23 @@ describe('managed Runtime Host service', () => {
assert.equal(frame.service.installedVersion, '1.2.3');
assert.deepEqual(frame.operatorCapabilities, ['access-management-v1']);
assert.equal(frame.service.stateRoot, '/srv/maka');
+ assert.equal(frame.service.configurationFingerprint, undefined);
assert.doesNotMatch(JSON.stringify(frame), /secret/u);
+ process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] = '1';
+ const configurationFrame = decodeRuntimeHostServiceManagementFrame(await run());
+ if (configurationFrame?.kind !== 'result' || configurationFrame.action !== 'status') {
+ assert.fail('Expected a configuration-capable service status result frame');
+ }
+ assert.deepEqual(configurationFrame.operatorCapabilities, [
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ ]);
+ assert.match(
+ configurationFrame.service.configurationFingerprint ?? '',
+ /^sha256:[a-f0-9]{64}$/u,
+ );
+ delete process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV];
+
process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] =
RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY;
const lockFrame = decodeRuntimeHostServiceManagementFrame(await run());
@@ -1384,6 +1996,7 @@ describe('managed Runtime Host service', () => {
const systemd = createFakeSystemd(unitPath);
const backend = () =>
createSystemdUserRuntimeHostService(serviceId, {
+ serviceConfigPath: resolveRuntimeHostManagedServiceConfigPath(clientDataRoot),
env: { XDG_CONFIG_HOME: base },
homeDir: base,
uid: 1000,
@@ -1421,7 +2034,7 @@ describe('managed Runtime Host service', () => {
]);
const status = await manageRuntimeHostService({ ...input, action: 'status' }, backend());
assert.equal(status.service.config?.websocket.port, 41_001);
- assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
+ assert.match(await readFile(unitPath, 'utf8'), /--managed-service-config/u);
assert.equal(status.service.active, true);
systemd.failNext('restart');
@@ -1431,7 +2044,7 @@ describe('managed Runtime Host service', () => {
}),
/Starting the Runtime Host service failed/u,
);
- assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
+ assert.match(await readFile(unitPath, 'utf8'), /--managed-service-config/u);
const replacementBackend = backend();
await replacementBackend.stop();
@@ -1445,7 +2058,7 @@ describe('managed Runtime Host service', () => {
replacementBackend.replace(replacementConfig),
/Starting the Runtime Host service failed/u,
);
- assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
+ assert.match(await readFile(unitPath, 'utf8'), /--managed-service-config/u);
assert.equal((await replacementBackend.status()).state, 'stopped');
});
@@ -2087,6 +2700,7 @@ describe('managed Runtime Host service', () => {
const backend = createSystemdUserRuntimeHostService(
resolveRuntimeHostManagedServiceId('/config/Maka'),
{
+ serviceConfigPath: '/config/Maka/runtime-host-service.json',
runSystemctl: async () => ({
exitCode: 1,
stdout: '',
@@ -2117,9 +2731,11 @@ describe('managed Runtime Host service', () => {
});
const backend: RuntimeHostServiceBackend = {
...createReadyBackend(),
- install: async () => {
- markInstallStarted();
- return { rollback: async () => undefined };
+ stageDeployment: async () => {
+ return {
+ apply: async () => markInstallStarted(),
+ rollback: async () => undefined,
+ };
},
};
const input = {
@@ -2145,6 +2761,54 @@ describe('managed Runtime Host service', () => {
});
});
+function legacySystemdUnitFixture(config: RuntimeHostManagedServiceConfig): string {
+ const args = [
+ config.launch.nodePath,
+ config.launch.cliPath,
+ 'runtime-host',
+ 'serve',
+ '--root',
+ config.rootPath,
+ ...config.projectDirectoryRoots.flatMap(({ label, path }) => [
+ '--project-root',
+ `${label}=${path}`,
+ ]),
+ '--websocket-host',
+ config.websocket.host,
+ '--websocket-port',
+ String(config.websocket.port),
+ '--websocket-path',
+ config.websocket.path,
+ '--json',
+ ];
+ const quote = (value: string) =>
+ `"${value
+ .replaceAll('\\', '\\\\')
+ .replaceAll('"', '\\"')
+ .replaceAll('%', '%%')
+ .replaceAll('$', '$$')}"`;
+ return [
+ '[Unit]',
+ 'Description=Maka Runtime Host',
+ 'After=network.target',
+ 'StartLimitIntervalSec=60s',
+ 'StartLimitBurst=5',
+ '',
+ '[Service]',
+ 'Type=simple',
+ `ExecStart=${args.map(quote).join(' ')}`,
+ 'Restart=on-failure',
+ 'RestartSec=2s',
+ 'KillMode=mixed',
+ 'TimeoutStopSec=45s',
+ 'UMask=0077',
+ '',
+ '[Install]',
+ 'WantedBy=default.target',
+ '',
+ ].join('\n');
+}
+
function createFakeSystemd(unitPath: string): {
readonly failNext: (command: string) => void;
readonly activateUnitWhenStopping: (triggerUnit: string, unitToActivate: string) => void;
@@ -2258,8 +2922,8 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
throw new Error('Backend should not be used by this test');
};
return {
- preflightInstall: unexpected,
- install: unexpected,
+ preflightDeployment: unexpected,
+ stageDeployment: unexpected,
replace: unexpected,
verifyReplacementPreconditions: unexpected,
verifyDeployment: unexpected,
@@ -2276,7 +2940,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
function createPreparedUnusedBackend(): RuntimeHostServiceBackend {
return {
...createUnusedBackend(),
- preflightInstall: async () => undefined,
+ preflightDeployment: async () => undefined,
};
}
@@ -2291,8 +2955,11 @@ function createReadyBackend(): RuntimeHostServiceBackend {
lastExitCode: 0,
});
return {
- preflightInstall: async () => undefined,
- install: async () => ({ rollback: async () => undefined }),
+ preflightDeployment: async () => undefined,
+ stageDeployment: async () => ({
+ apply: async () => undefined,
+ rollback: async () => undefined,
+ }),
replace: async () => undefined,
verifyReplacementPreconditions: async () => undefined,
verifyDeployment: async () => undefined,
@@ -2306,6 +2973,14 @@ function createReadyBackend(): RuntimeHostServiceBackend {
};
}
+async function applyStagedDeployment(
+ backend: RuntimeHostServiceBackend,
+ config: RuntimeHostManagedServiceConfig,
+): Promise<void> {
+ const deployment = await backend.stageDeployment();
+ await deployment.apply(config);
+}
+
function success(stdout = ''): { exitCode: number; stdout: string; stderr: string } {
return { exitCode: 0, stdout, stderr: '' };
}
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 02efbbb5e..112d5f208 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -573,7 +573,7 @@ async function createReleasePackage(base: string, version: string): Promise<stri
}
function serviceResult(
- action: Exclude<RuntimeHostManagedServiceResult['action'], 'retire'>,
+ action: Exclude<RuntimeHostManagedServiceResult['action'], 'retire' | 'configure'>,
config: RuntimeHostManagedServiceConfig | null,
installedVersion: string | null,
): RuntimeHostManagedServiceResult {
@@ -596,8 +596,8 @@ function serviceResult(
function unusedBackend(): RuntimeHostServiceBackend {
return {
- preflightInstall: async () => undefined,
- install: async () => assert.fail('Backend is not expected'),
+ preflightDeployment: async () => undefined,
+ stageDeployment: async () => assert.fail('Backend is not expected'),
replace: async () => assert.fail('Backend is not expected'),
verifyReplacementPreconditions: async () => assert.fail('Backend is not expected'),
verifyDeployment: async () => assert.fail('Backend is not expected'),
diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts
index 7ad8c671e..bc98a7e99 100644
--- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts
@@ -519,8 +519,11 @@ function managedStatus(managedDeploymentRoot: string) {
function unusedBackend() {
return {
- preflightInstall: async () => undefined,
- install: async () => ({ rollback: async () => undefined }),
+ preflightDeployment: async () => undefined,
+ stageDeployment: async () => ({
+ apply: async () => undefined,
+ rollback: async () => undefined,
+ }),
replace: async () => undefined,
verifyReplacementPreconditions: async () => undefined,
verifyDeployment: async () => undefined,
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 1c5759b05..d47c51e0b 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -132,6 +132,7 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`,
` ${cliCommand} runtime-host setup --principal <id> --preset <desktop-client|terminal-client> [options]`,
` ${cliCommand} runtime-host service install [options]`,
+ ` ${cliCommand} runtime-host service configure (--project-root <label>=<path> ... | --no-project-roots) --expected-config-fingerprint <sha256:...> --expected-service-id <id> --expected-root-path <path> --expected-root-id <id> [--allow-interrupt-active-tasks]`,
` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`,
` ${cliCommand} runtime-host service retire --expected-service-id <id> --expected-root-path <path> --expected-root-id <id> [--allow-interrupt-active-tasks]`,
` ${cliCommand} runtime-host service check-update --target <latest|next|version> [--json]`,
@@ -165,6 +166,7 @@ function helpText(cliCommand: string): string {
'Runtime Host serve options:',
' --root <path> Select the canonical data root',
' --project-root <label>=<path> Publish an absolute project directory root (repeatable)',
+ ' --no-project-roots Disable remote project browsing and registration',
' --websocket-port <port> Enable an authenticated WebSocket listener',
' --websocket-host <host> Bind host (default: 127.0.0.1)',
' --websocket-path <path> Upgrade path (default: /runtime-host)',
@@ -181,6 +183,7 @@ function helpText(cliCommand: string): string {
'Managed Runtime Host service install options (Linux or macOS):',
' --root <path> Select the canonical data root',
' --project-root <label>=<path> Publish an absolute directory root (repeatable)',
+ ' --no-project-roots Disable remote project browsing and registration',
' --websocket-port <port> Persist a loopback port (chosen automatically by default)',
' --websocket-path <path> Persist the upgrade path (default: /runtime-host)',
' --json Emit a machine-readable result',
@@ -190,6 +193,7 @@ function helpText(cliCommand: string): string {
' --preset <name> Pair a desktop-client or terminal-client',
' --root <path> Select the canonical data root',
' --project-root <label>=<path> Publish an absolute directory root (repeatable)',
+ ' --no-project-roots Disable remote project browsing and registration',
' --websocket-port <port> Persist a loopback port (chosen automatically by default)',
' --websocket-path <path> Persist the upgrade path (default: /runtime-host)',
' --json Emit framed machine-readable progress and result records',
@@ -245,6 +249,17 @@ export async function runMakaCli(
}
case 'runtime-host-serve': {
const { runRuntimeHostServiceCli } = await import('./runtime-host-service-command.js');
+ if (command.managedServiceConfigPath) {
+ const { effectiveRuntimeHostProjectDirectoryRoots, readRuntimeHostManagedServiceConfig } =
+ await import('./runtime-host-service-manager.js');
+ const config = await readRuntimeHostManagedServiceConfig(command.managedServiceConfigPath);
+ return runRuntimeHostServiceCli({
+ rootPath: config.rootPath,
+ json: command.json,
+ projectDirectoryRoots: effectiveRuntimeHostProjectDirectoryRoots(config),
+ websocket: config.websocket,
+ });
+ }
return runRuntimeHostServiceCli({
rootPath: command.rootPath ?? dataRoots.workspaceRoot,
json: command.json,
@@ -297,6 +312,9 @@ export async function runMakaCli(
...(command.websocketPort === undefined ? {} : { websocketPort: command.websocketPort }),
...(command.websocketPath ? { websocketPath: command.websocketPath } : {}),
...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}),
+ ...(command.expectedConfigFingerprint
+ ? { expectedConfigFingerprint: command.expectedConfigFingerprint }
+ : {}),
...(command.retainManagedDeployment ? { retainManagedDeployment: true } : {}),
...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
});
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index 692973eae..258141806 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -21,9 +21,11 @@ import { isAbsolute } from 'node:path';
import { isProductReleaseVersion } from '@maka/runtime-host/operator';
import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator';
import {
+ canonicalProjectDirectoryRootSpec,
isCanonicalRuntimeHostWebSocketPath,
PROJECT_DIRECTORY_MAX_ROOTS,
- PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES,
+ projectDirectoryPosixRootSpecValid,
+ projectDirectoryRootSpecValid,
} from '@maka/runtime-host/protocol';
import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-manager.js';
@@ -37,6 +39,7 @@ export type RuntimeHostCliCommand =
| {
kind: 'runtime-host-serve';
rootPath?: string;
+ managedServiceConfigPath?: string;
json: boolean;
projectDirectoryRoots?: { label: string; path: string }[];
websocket?: {
@@ -69,7 +72,16 @@ export type RuntimeHostCliCommand =
}
| {
kind: 'runtime-host-service-manage';
- action: 'install' | 'status' | 'start' | 'stop' | 'restart' | 'retire' | 'logs' | 'uninstall';
+ action:
+ | 'install'
+ | 'configure'
+ | 'status'
+ | 'start'
+ | 'stop'
+ | 'restart'
+ | 'retire'
+ | 'logs'
+ | 'uninstall';
json: boolean;
framed?: true;
clientDataRoot?: string;
@@ -78,6 +90,7 @@ export type RuntimeHostCliCommand =
websocketPort?: number;
websocketPath?: string;
expectedTarget?: RuntimeHostManagedServiceTarget;
+ expectedConfigFingerprint?: string;
retainManagedDeployment?: true;
allowInterruptActiveTasks?: true;
}
@@ -272,6 +285,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
}
if (
action !== 'install' &&
+ action !== 'configure' &&
action !== 'status' &&
action !== 'start' &&
action !== 'stop' &&
@@ -287,7 +301,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
return error(
action
? `Unexpected runtime-host service command: ${action}`
- : 'runtime-host service requires install, status, start, stop, restart, retire, check-update, update, update-policy, reconcile-update, logs, or uninstall',
+ : 'runtime-host service requires install, configure, status, start, stop, restart, retire, check-update, update, update-policy, reconcile-update, logs, or uninstall',
);
}
@@ -295,6 +309,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
let allowInterruptActiveTasks = false;
let clientDataRoot: string | undefined;
let updateTarget: string | undefined;
+ let expectedConfigFingerprint: string | undefined;
const flagOptions: Readonly<Record<string, () => void | RuntimeHostCliError>> =
action === 'uninstall'
? {
@@ -303,7 +318,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
retainManagedDeployment = true;
},
}
- : action === 'retire' || action === 'update'
+ : action === 'retire' || action === 'update' || action === 'configure'
? {
'--allow-interrupt-active-tasks': () => {
if (allowInterruptActiveTasks) {
@@ -314,7 +329,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
}
: {};
const options = parseManagedServiceOptions(argv.slice(1), {
- allowConfiguration: action === 'install',
+ allowConfiguration: action === 'install' || action === 'configure',
allowFramed: true,
valueOptions: {
'--client-data-root': (value) => {
@@ -330,13 +345,37 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
},
}
: {}),
+ ...(action === 'configure'
+ ? {
+ '--expected-config-fingerprint': (value: string) => {
+ if (expectedConfigFingerprint !== undefined) {
+ return error('Duplicate --expected-config-fingerprint');
+ }
+ if (!/^sha256:[a-f0-9]{64}$/u.test(value)) {
+ return error(
+ '--expected-config-fingerprint must be a service configuration fingerprint',
+ );
+ }
+ expectedConfigFingerprint = value;
+ },
+ }
+ : {}),
},
flagOptions,
});
if ('kind' in options) return options;
- if ((action === 'retire' || action === 'update') && !options.expectedTarget) {
+ if (
+ (action === 'retire' || action === 'update' || action === 'configure') &&
+ !options.expectedTarget
+ ) {
return error(`runtime-host service ${action} requires an expected target`);
}
+ if (action === 'configure' && !expectedConfigFingerprint) {
+ return error('runtime-host service configure requires --expected-config-fingerprint');
+ }
+ if (action === 'configure' && options.projectDirectoryRoots === undefined) {
+ return error('runtime-host service configure requires --project-root or --no-project-roots');
+ }
if (action === 'update-policy') {
const policy = updateTarget === undefined ? undefined : parseUpdatePolicy(updateTarget);
if (policy && 'exitCode' in policy) return policy;
@@ -394,6 +433,12 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
...(clientDataRoot ? { clientDataRoot } : {}),
...(retainManagedDeployment ? { retainManagedDeployment: true } : {}),
...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
+ ...(action === 'configure'
+ ? {
+ expectedConfigFingerprint: expectedConfigFingerprint!,
+ projectDirectoryRoots: options.projectDirectoryRoots!,
+ }
+ : {}),
};
}
@@ -445,6 +490,7 @@ function parseManagedServiceOptions(
let expectedServiceId: string | undefined;
let expectedRootPath: string | undefined;
let expectedRootId: string | undefined;
+ let projectDirectoryPolicySpecified = false;
const projectDirectoryRoots: { label: string; path: string }[] = [];
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
@@ -466,6 +512,14 @@ function parseManagedServiceOptions(
if (optionError) return optionError;
continue;
}
+ if (argument === '--no-project-roots') {
+ if (input.allowConfiguration === false) return error(`Unexpected argument: ${argument}`);
+ if (projectDirectoryPolicySpecified) {
+ return error('--no-project-roots cannot be combined with --project-root');
+ }
+ projectDirectoryPolicySpecified = true;
+ continue;
+ }
const isTargetOption =
argument === '--expected-service-id' ||
argument === '--expected-root-path' ||
@@ -479,6 +533,7 @@ function parseManagedServiceOptions(
argument === '--websocket-port' ||
argument === '--websocket-path' ||
argument === '--project-root' ||
+ argument === '--project-root-json' ||
argument === '--expected-service-id' ||
argument === '--expected-root-path' ||
argument === '--expected-root-id' ||
@@ -492,8 +547,15 @@ function parseManagedServiceOptions(
else if (argument === '--expected-service-id') expectedServiceId = parsed;
else if (argument === '--expected-root-path') expectedRootPath = parsed;
else if (argument === '--expected-root-id') expectedRootId = parsed;
- else if (argument === '--project-root') {
- const root = parseProjectRoot(parsed);
+ else if (argument === '--project-root' || argument === '--project-root-json') {
+ if (projectDirectoryPolicySpecified && projectDirectoryRoots.length === 0) {
+ return error('--project-root cannot be combined with --no-project-roots');
+ }
+ projectDirectoryPolicySpecified = true;
+ const root =
+ argument === '--project-root'
+ ? parseProjectRoot(parsed, 'posix')
+ : parseProjectRootJson(parsed, 'posix');
if ('kind' in root) return root;
if (projectDirectoryRoots.length >= PROJECT_DIRECTORY_MAX_ROOTS) {
return error(
@@ -549,7 +611,7 @@ function parseManagedServiceOptions(
json,
...(framed ? { framed: true as const } : {}),
...(rootPath ? { rootPath } : {}),
- ...(projectDirectoryRoots.length > 0 ? { projectDirectoryRoots } : {}),
+ ...(projectDirectoryPolicySpecified ? { projectDirectoryRoots } : {}),
...(websocketPort === undefined ? {} : { websocketPort }),
...(websocketPath === undefined ? {} : { websocketPath }),
...(expectedServiceId === undefined
@@ -813,6 +875,7 @@ function parseCapabilityProviderCommand(argv: string[]): RuntimeHostCliCommand {
function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
let rootPath: string | undefined;
+ let managedServiceConfigPath: string | undefined;
let json = false;
let websocketHost = '127.0.0.1';
let websocketConfigured = false;
@@ -827,6 +890,7 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
const peerListenAddresses: string[] = [];
const peerCoordinationRelays: string[] = [];
const projectDirectoryRoots: { label: string; path: string }[] = [];
+ let projectDirectoryPolicySpecified = false;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--json') {
@@ -845,10 +909,30 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
index += 1;
continue;
}
- if (argument === '--project-root') {
+ if (argument === '--managed-service-config') {
+ if (managedServiceConfigPath !== undefined) {
+ return error('Duplicate --managed-service-config');
+ }
const parsed = optionValue(argv, index, argument);
if (typeof parsed !== 'string') return parsed;
- const root = parseProjectRoot(parsed);
+ if (!isSafeAbsolutePath(parsed)) {
+ return error('--managed-service-config must be an absolute path');
+ }
+ managedServiceConfigPath = parsed;
+ index += 1;
+ continue;
+ }
+ if (argument === '--project-root' || argument === '--project-root-json') {
+ if (projectDirectoryPolicySpecified && projectDirectoryRoots.length === 0) {
+ return error('--project-root cannot be combined with --no-project-roots');
+ }
+ projectDirectoryPolicySpecified = true;
+ const parsed = optionValue(argv, index, argument);
+ if (typeof parsed !== 'string') return parsed;
+ const root =
+ argument === '--project-root'
+ ? parseProjectRoot(parsed, 'native')
+ : parseProjectRootJson(parsed, 'native');
if ('kind' in root) return root;
if (projectDirectoryRoots.length >= PROJECT_DIRECTORY_MAX_ROOTS) {
return error(`--project-root may be provided at most ${PROJECT_DIRECTORY_MAX_ROOTS} times`);
@@ -860,6 +944,13 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
index += 1;
continue;
}
+ if (argument === '--no-project-roots') {
+ if (projectDirectoryPolicySpecified) {
+ return error('--no-project-roots cannot be combined with --project-root');
+ }
+ projectDirectoryPolicySpecified = true;
+ continue;
+ }
if (argument === '--websocket-host') {
const parsed = optionValue(argv, index, argument);
if (typeof parsed !== 'string') return parsed;
@@ -942,11 +1033,18 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
) {
return error('peer listener options require --peer-native-path and --peer-key');
}
+ if (
+ managedServiceConfigPath !== undefined &&
+ (rootPath !== undefined || projectDirectoryPolicySpecified || websocketConfigured)
+ ) {
+ return error('--managed-service-config cannot be combined with Runtime Host settings');
+ }
return {
kind: 'runtime-host-serve',
json,
+ ...(managedServiceConfigPath ? { managedServiceConfigPath } : {}),
...(rootPath ? { rootPath } : {}),
- ...(projectDirectoryRoots.length > 0 ? { projectDirectoryRoots } : {}),
+ ...(projectDirectoryPolicySpecified ? { projectDirectoryRoots } : {}),
...(websocketPort === undefined
? {}
: {
@@ -975,22 +1073,63 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
};
}
-function parseProjectRoot(value: string): { label: string; path: string } | RuntimeHostCliError {
+function parseProjectRoot(
+ value: string,
+ pathKind: 'native' | 'posix',
+): { label: string; path: string } | RuntimeHostCliError {
const separator = value.indexOf('=');
if (separator <= 0 || separator === value.length - 1) {
return error('--project-root must use <label>=<absolute-path>');
}
- const label = value.slice(0, separator).trim();
- const path = value.slice(separator + 1);
+ const root = canonicalProjectDirectoryRootSpec({
+ label: value.slice(0, separator),
+ path: value.slice(separator + 1),
+ });
+ if (!projectRootValid(root, pathKind)) {
+ return error(
+ `--project-root must use a valid label and absolute ${pathKind === 'posix' ? 'POSIX' : 'Host'} path`,
+ );
+ }
+ return root;
+}
+
+function parseProjectRootJson(
+ value: string,
+ pathKind: 'native' | 'posix',
+): { label: string; path: string } | RuntimeHostCliError {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ return error('--project-root-json must be a JSON object with label and path');
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return error('--project-root-json must be a JSON object with label and path');
+ }
+ const root = parsed as Record<string, unknown>;
if (
- label.length === 0 ||
- Buffer.byteLength(label, 'utf8') > PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES ||
- /[\u0000-\u001f\u007f]/u.test(label)
+ Object.keys(root).length !== 2 ||
+ typeof root.label !== 'string' ||
+ typeof root.path !== 'string'
) {
- return error('--project-root label is invalid');
+ return error('--project-root-json must be a JSON object with label and path');
}
- if (!isAbsolute(path)) return error('--project-root path must be absolute');
- return { label, path };
+ const canonical = canonicalProjectDirectoryRootSpec({ label: root.label, path: root.path });
+ if (!projectRootValid(canonical, pathKind)) {
+ return error(
+ `--project-root-json must use a valid label and absolute ${pathKind === 'posix' ? 'POSIX' : 'Host'} path`,
+ );
+ }
+ return canonical;
+}
+
+function projectRootValid(
+ root: { readonly label: string; readonly path: string },
+ pathKind: 'native' | 'posix',
+): boolean {
+ return pathKind === 'posix'
+ ? projectDirectoryPosixRootSpecValid(root)
+ : projectDirectoryRootSpecValid(root) && isAbsolute(root.path);
}
function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts
index 9916e2fad..45a8f0f9d 100644
--- a/packages/cli/src/runtime-host-launch-agent-service.ts
+++ b/packages/cli/src/runtime-host-launch-agent-service.ts
@@ -32,6 +32,7 @@ import {
writeRuntimeHostServiceFile,
} from './runtime-host-service-manager.js';
import {
+ legacyRuntimeHostServiceLaunchArguments,
RUNTIME_HOST_UPDATE_INTERVAL_SECONDS,
runtimeHostServiceLaunchArguments,
runtimeHostUpdateReconcileLaunchArguments,
@@ -69,6 +70,7 @@ interface LaunchAgentDeploymentSnapshot {
}
export interface LaunchAgentServiceOptions {
+ readonly serviceConfigPath: string;
readonly homeDir?: string;
readonly uid?: number;
readonly runLaunchctl?: LaunchctlRunner;
@@ -77,9 +79,10 @@ export interface LaunchAgentServiceOptions {
export function createLaunchAgentRuntimeHostService(
serviceId: string,
- options: LaunchAgentServiceOptions = {},
+ options: LaunchAgentServiceOptions,
): RuntimeHostServiceBackend {
const homeDir = options.homeDir ?? homedir();
+ const { serviceConfigPath } = options;
const uid = options.uid ?? process.getuid?.();
if (uid === undefined || !Number.isSafeInteger(uid) || uid < 0) {
throw new RuntimeHostServiceManagerError(
@@ -114,30 +117,22 @@ export function createLaunchAgentRuntimeHostService(
return status;
};
return {
- preflightInstall: () => assertLaunchAgentDomain(context),
- install: async (config) => {
- await validateRuntimeHostServiceLaunch(config);
+ preflightDeployment: () => assertLaunchAgentDomain(context),
+ stageDeployment: async () => {
const [previous, previousScheduler] = await Promise.all([
captureLaunchAgentDeployment(context),
captureLaunchAgentDeployment(scheduler),
]);
let schedulerMutationStarted = false;
- try {
- await applyLaunchAgentDeployment(context, config);
- await applyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, () => {
- schedulerMutationStarted = true;
- });
- } catch (error) {
- await restoreFailedLaunchAgentDeployment(
- previous,
- schedulerMutationStarted ? previousScheduler : undefined,
- context,
- scheduler,
- error,
- );
- }
let rolledBack = false;
return {
+ apply: async (config) => {
+ await validateRuntimeHostServiceLaunch(config);
+ await applyLaunchAgentDeployment(context, config, serviceConfigPath);
+ await applyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, () => {
+ schedulerMutationStarted = true;
+ });
+ },
rollback: async () => {
if (rolledBack) return;
rolledBack = true;
@@ -158,7 +153,7 @@ export function createLaunchAgentRuntimeHostService(
]);
let schedulerMutationStarted = false;
try {
- await applyLaunchAgentDeployment(context, config);
+ await applyLaunchAgentDeployment(context, config, serviceConfigPath);
await convergeLaunchAgentUpdateSchedulerForReplacement(scheduler, config, () => {
schedulerMutationStarted = true;
});
@@ -184,7 +179,16 @@ export function createLaunchAgentRuntimeHostService(
throw error;
}),
]);
- if (!status.installed || plist !== renderLaunchAgentPlist(config, context)) {
+ if (
+ !status.installed ||
+ !launchAgentPlistMatchesConfig(
+ plist,
+ config,
+ context,
+ serviceConfigPath,
+ options?.acceptLegacyConfigLaunch ?? false,
+ )
+ ) {
throw new RuntimeHostServiceManagerError(
'target_mismatch',
'The installed Runtime Host LaunchAgent does not match its managed deployment',
@@ -256,9 +260,36 @@ export function resolveLaunchAgentUpdatePath(serviceId: string, homeDir = homedi
export function renderLaunchAgentPlist(
config: RuntimeHostManagedServiceConfig,
paths: Pick<LaunchAgentContext, 'label' | 'stdoutPath' | 'stderrPath'>,
+ serviceConfigPath: string,
+): string {
+ return renderLaunchAgentPlistWithArguments(
+ runtimeHostServiceLaunchArguments(config, serviceConfigPath),
+ paths,
+ );
+}
+
+function launchAgentPlistMatchesConfig(
+ plist: string | null,
+ config: RuntimeHostManagedServiceConfig,
+ paths: Pick<LaunchAgentContext, 'label' | 'stdoutPath' | 'stderrPath'>,
+ serviceConfigPath: string,
+ acceptLegacyConfigLaunch: boolean,
+): boolean {
+ return (
+ plist === renderLaunchAgentPlist(config, paths, serviceConfigPath) ||
+ (acceptLegacyConfigLaunch &&
+ config.schemaVersion === 1 &&
+ plist ===
+ renderLaunchAgentPlistWithArguments(legacyRuntimeHostServiceLaunchArguments(config), paths))
+ );
+}
+
+function renderLaunchAgentPlistWithArguments(
+ args: readonly string[],
+ paths: Pick<LaunchAgentContext, 'label' | 'stdoutPath' | 'stderrPath'>,
): string {
const stringEntry = (value: string) => ` <string>${escapeXml(value)}</string>`;
- const argumentsXml = runtimeHostServiceLaunchArguments(config).map(stringEntry).join('\n');
+ const argumentsXml = args.map(stringEntry).join('\n');
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -536,12 +567,13 @@ function isTargetMismatch(error: unknown): boolean {
async function applyLaunchAgentDeployment(
context: LaunchAgentContext,
config: RuntimeHostManagedServiceConfig,
+ serviceConfigPath: string,
): Promise<void> {
await bootoutLaunchAgent(context);
await prepareLaunchAgentLogs(context);
await writeRuntimeHostServiceFile(
context.plistPath,
- renderLaunchAgentPlist(config, context),
+ renderLaunchAgentPlist(config, context, serviceConfigPath),
0o600,
);
await bootstrapLaunchAgent(context);
diff --git a/packages/cli/src/runtime-host-service-launch.ts b/packages/cli/src/runtime-host-service-launch.ts
index 0324467f4..77bc33b0d 100644
--- a/packages/cli/src/runtime-host-service-launch.ts
+++ b/packages/cli/src/runtime-host-service-launch.ts
@@ -27,6 +27,22 @@ import {
export function runtimeHostServiceLaunchArguments(
config: RuntimeHostManagedServiceConfig,
+ serviceConfigPath: string,
+): readonly string[] {
+ return [
+ config.launch.nodePath,
+ config.launch.cliPath,
+ 'runtime-host',
+ 'serve',
+ '--managed-service-config',
+ serviceConfigPath,
+ '--json',
+ ];
+}
+
+/** Exact launch contract used before the managed configuration became authoritative. */
+export function legacyRuntimeHostServiceLaunchArguments(
+ config: RuntimeHostManagedServiceConfig,
): readonly string[] {
return [
config.launch.nodePath,
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 867fabf23..dd6462689 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -23,6 +23,7 @@ import {
encodeRuntimeHostServiceManagementFrame,
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
@@ -32,8 +33,11 @@ import {
} from '@maka/runtime-host/operator';
import {
cleanupRuntimeHostManagedDeployment,
+ effectiveRuntimeHostProjectDirectoryRoots,
manageRuntimeHostService,
+ resolveRuntimeHostManagedServiceConfigPath,
resolveRuntimeHostManagedServiceId,
+ runtimeHostManagedServiceConfigFingerprint,
RuntimeHostServiceManagerError,
withRuntimeHostManagedServiceDeploymentLock,
withRuntimeHostManagedServiceLifecycleLock,
@@ -56,7 +60,7 @@ export interface RuntimeHostServiceManagementCliDeps {
readonly manage: typeof manageRuntimeHostService;
readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
- readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend;
readonly writeOutput: (value: string) => unknown;
readonly writeError: (value: string) => unknown;
}
@@ -77,7 +81,7 @@ export async function runManagedRuntimeHostServiceCli(
try {
const { json: _json, framed: _framed, ...input } = options;
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
- const manage = () => deps.manage(input, deps.createBackend(serviceId));
+ const manage = () => deps.manage(input, deps.createBackend(serviceId, options.clientDataRoot));
const mutate = () =>
deps.withDeploymentLock(options.clientDataRoot, () =>
deps.withLifecycleLock(options.clientDataRoot, manage),
@@ -88,7 +92,9 @@ export async function runManagedRuntimeHostServiceCli(
: options.action === 'retire'
? await deps.withLifecycleLock(options.clientDataRoot, manage)
: await mutate();
- const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks';
+ const blocked =
+ (result.action === 'retire' && result.retirement.kind === 'active_tasks') ||
+ (result.action === 'configure' && result.configuration.kind === 'active_tasks');
if (options.framed) {
deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result)));
} else if (options.json) {
@@ -139,7 +145,7 @@ export async function runManagedRuntimeHostDeploymentCleanupCli(options: {
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
await cleanupRuntimeHostManagedDeployment(
options,
- createPlatformRuntimeHostServiceBackend(serviceId),
+ createPlatformRuntimeHostServiceBackend(serviceId, options.clientDataRoot),
);
return 0;
} catch (error) {
@@ -160,6 +166,13 @@ function formatHumanResult(result: RuntimeHostManagedServiceResult): string {
? `Runtime Host service is installed and running at ${websocketUrl(service)}\n`
: 'Runtime Host service is installed but is not running. Check its status and journal.\n';
}
+ if (result.action === 'configure') {
+ return result.configuration.kind === 'active_tasks'
+ ? 'Runtime Host service still owns active work. Retry with explicit interruption authority.\n'
+ : result.configuration.kind === 'unchanged'
+ ? 'Runtime Host Project roots are already configured.\n'
+ : 'Runtime Host Project roots were configured.\n';
+ }
if (result.action === 'status') {
if (!service.installed) return 'Runtime Host service is not installed.\n';
return `Runtime Host service is ${service.state} at ${websocketUrl(service)}\n`;
@@ -183,19 +196,27 @@ function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServi
...(result.retainedStateRoot ? { retainedStateRoot: result.retainedStateRoot } : {}),
...(result.logs !== undefined ? { logs: result.logs } : {}),
} as const;
- return result.action === 'retire'
- ? { ...common, action: result.action, retirement: { ...result.retirement } }
- : { ...common, action: result.action };
+ if (result.action === 'retire') {
+ return { ...common, action: result.action, retirement: { ...result.retirement } };
+ }
+ if (result.action === 'configure') {
+ return { ...common, action: result.action, configuration: { ...result.configuration } };
+ }
+ return { ...common, action: result.action };
}
function requestedOperatorCapabilities(): {
readonly operatorCapabilities?: RuntimeHostOperatorCapability[];
} {
+ const capabilities: RuntimeHostOperatorCapability[] = [];
const requested = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
- return requested === RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY ||
+ if (
+ requested === RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY ||
requested === RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY
- ? { operatorCapabilities: [requested] }
- : {};
+ ) {
+ capabilities.push(requested);
+ }
+ return capabilities.length > 0 ? { operatorCapabilities: capabilities } : {};
}
export function runtimeHostServiceSummary(
@@ -211,16 +232,26 @@ export function runtimeHostServiceSummary(
lastExitCode: result.service.lastExitCode,
installedVersion: result.service.installedVersion,
...(config ? { stateRoot: config.rootPath } : {}),
- projectDirectoryRoots: [...(config?.projectDirectoryRoots ?? [])],
+ ...(config &&
+ process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] === '1'
+ ? { configurationFingerprint: runtimeHostManagedServiceConfigFingerprint(config) }
+ : {}),
+ projectDirectoryRoots: config ? [...effectiveRuntimeHostProjectDirectoryRoots(config)] : [],
};
}
export function createPlatformRuntimeHostServiceBackend(
serviceId: string,
+ clientDataRoot: string,
platform: NodeJS.Platform = process.platform,
): RuntimeHostServiceBackend {
- if (platform === 'linux') return createSystemdUserRuntimeHostService(serviceId);
- if (platform === 'darwin') return createLaunchAgentRuntimeHostService(serviceId);
+ const serviceConfigPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot);
+ if (platform === 'linux') {
+ return createSystemdUserRuntimeHostService(serviceId, { serviceConfigPath });
+ }
+ if (platform === 'darwin') {
+ return createLaunchAgentRuntimeHostService(serviceId, { serviceConfigPath });
+ }
throw new RuntimeHostServiceManagerError(
'unsupported_platform',
'Managed Runtime Host services currently require Linux or macOS',
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index a14e7aa8f..3fadca312 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -19,14 +19,16 @@
import { createHash, randomUUID } from 'node:crypto';
import { createServer } from 'node:net';
+import { realpathSync } from 'node:fs';
import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
+ canonicalProjectDirectoryRootSpec,
isCanonicalRuntimeHostWebSocketPath,
PROJECT_DIRECTORY_MAX_ROOTS,
- PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES,
+ projectDirectoryPosixRootSpecValid,
RUNTIME_HOST_PROTOCOL_VERSION,
} from '@maka/runtime-host/protocol';
import {
@@ -63,7 +65,7 @@ const SERVICE_READY_TIMEOUT_MS = 45_000;
const SERVICE_READY_POLL_MS = 50;
export interface RuntimeHostManagedServiceConfig {
- readonly schemaVersion: 1;
+ readonly schemaVersion: 1 | 2;
readonly managedDeploymentRoot?: string;
readonly rootPath: string;
readonly projectDirectoryRoots: readonly {
@@ -99,8 +101,8 @@ export interface RuntimeHostServiceBackendStatus {
}
export interface RuntimeHostServiceBackend {
- preflightInstall(): Promise<void>;
- install(config: RuntimeHostManagedServiceConfig): Promise<RuntimeHostServiceDeployment>;
+ preflightDeployment(): Promise<void>;
+ stageDeployment(): Promise<RuntimeHostServiceDeployment>;
/** A rejected replacement must restore the previous deployment or report update_incomplete. */
replace(config: RuntimeHostManagedServiceConfig): Promise<void>;
/** Reject partial or drifted scheduler state before replacement begins. */
@@ -108,7 +110,10 @@ export interface RuntimeHostServiceBackend {
/** Verify the deployment definition and, when requested, its scheduler readiness. */
verifyDeployment(
config: RuntimeHostManagedServiceConfig,
- options?: { readonly requireSchedulerReady?: boolean },
+ options?: {
+ readonly requireSchedulerReady?: boolean;
+ readonly acceptLegacyConfigLaunch?: boolean;
+ },
): Promise<void>;
status(): Promise<RuntimeHostServiceBackendStatus>;
start(): Promise<void>;
@@ -121,6 +126,7 @@ export interface RuntimeHostServiceBackend {
}
export interface RuntimeHostServiceDeployment {
+ apply(config: RuntimeHostManagedServiceConfig): Promise<void>;
rollback(): Promise<void>;
}
@@ -131,6 +137,7 @@ export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceBacke
export type RuntimeHostManagedServiceAction =
| 'install'
+ | 'configure'
| 'status'
| 'start'
| 'stop'
@@ -161,8 +168,14 @@ export type RuntimeHostManagedServiceResult =
readonly retirement: RuntimeHostRetirementResult;
})
| (RuntimeHostManagedServiceResultBase & {
- readonly action: Exclude<RuntimeHostManagedServiceAction, 'retire'>;
+ readonly action: 'configure';
+ readonly configuration: { readonly kind: 'unchanged' | 'configured' | 'active_tasks' };
+ readonly retirement?: never;
+ })
+ | (RuntimeHostManagedServiceResultBase & {
+ readonly action: Exclude<RuntimeHostManagedServiceAction, 'retire' | 'configure'>;
readonly retirement?: never;
+ readonly configuration?: never;
});
export interface RuntimeHostManagedServiceInput {
@@ -177,6 +190,7 @@ export interface RuntimeHostManagedServiceInput {
readonly nodePath: string;
readonly cliPath: string;
readonly expectedTarget?: RuntimeHostManagedServiceTarget;
+ readonly expectedConfigFingerprint?: string;
readonly allowInterruptActiveTasks?: boolean;
}
@@ -226,6 +240,8 @@ export class RuntimeHostServiceManagerError extends Error {
| 'invalid_config'
| 'invalid_launch'
| 'target_mismatch'
+ | 'configuration_changed'
+ | 'configuration_incomplete'
| 'retirement_failed'
| 'update_requires_retirement'
| 'update_incomplete'
@@ -379,24 +395,47 @@ async function manageRuntimeHostServiceLocked(
if (input.action === 'install') {
const previous = await readServiceConfigForRepair(configPath);
const expectedRoot = await resolveExpectedServiceRoot(previous, input);
- await backend.preflightInstall();
+ await backend.preflightDeployment();
const config = await prepareServiceConfig(
expectedRoot ? { ...input, rootPath: expectedRoot.canonicalPath } : input,
previous,
deps,
);
- const deployment = await backend.install(config);
- let configWriteStarted = false;
+ if (
+ previous &&
+ input.projectDirectoryRoots !== undefined &&
+ !sameProjectDirectoryRoots(previous, config)
+ ) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_changed',
+ 'An existing Runtime Host Project root policy must be changed through the configuration workflow',
+ );
+ }
+ const deployment = await backend.stageDeployment();
try {
- await deps.waitForReady(config, backend);
- configWriteStarted = true;
await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
+ await deployment.apply(config);
+ await deps.waitForReady(config, backend);
} catch (error) {
- await rollbackDeployment(
- deployment,
- configWriteStarted ? { configPath, previous } : null,
- error,
- );
+ const revision = await identifyRuntimeHostServiceConfigRevision(configPath, previous, config);
+ if (revision === 'current') throw error;
+ if (revision === 'unknown') {
+ try {
+ await quiesceRuntimeHostServiceForConfigRollback(backend);
+ } catch (rollbackError) {
+ throw new RuntimeHostServiceManagerError(
+ 'service_manager_operation_failed',
+ 'Runtime Host service installation failed with an uncertain configuration and its process could not be stopped',
+ { cause: new AggregateError([error, rollbackError]) },
+ );
+ }
+ throw new RuntimeHostServiceManagerError(
+ 'service_manager_operation_failed',
+ 'Runtime Host service installation failed with an uncertain configuration; its process was stopped for inspection',
+ { cause: error },
+ );
+ }
+ await rollbackDeployment(deployment, backend, { configPath, previous }, error);
}
return result(input.action, await readServiceStatus(configPath, backend));
}
@@ -404,6 +443,9 @@ async function manageRuntimeHostServiceLocked(
if (input.action === 'status') {
const service = await readServiceStatus(configPath, backend);
await resolveExpectedServiceRoot(service.config, input);
+ if (service.installed && service.config?.schemaVersion === 2) {
+ await backend.verifyDeployment(service.config);
+ }
return result(input.action, service);
}
@@ -473,68 +515,101 @@ async function manageRuntimeHostServiceLocked(
const logs = truncateUtf8(await backend.logs(), RUNTIME_HOST_SERVICE_LOG_MAX_BYTES);
return result(input.action, service, undefined, logs);
}
- if (input.action === 'retire') {
- if (!input.expectedTarget) {
+ if (input.action === 'configure') {
+ if (!input.expectedTarget || input.projectDirectoryRoots === undefined) {
throw new RuntimeHostServiceManagerError(
'target_mismatch',
- 'Runtime Host retirement requires the expected managed service identity',
+ 'Runtime Host configuration requires the expected service identity and a complete Project root policy',
+ );
+ }
+ if (!input.expectedConfigFingerprint) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_changed',
+ 'Runtime Host configuration requires the observed service configuration fingerprint',
);
}
const service = await readServiceStatus(configPath, backend);
- const root = await resolveExpectedServiceRoot(service.config, input);
- if (!service.installed || !service.config || !root) {
+ const currentConfig = service.config;
+ const root = await resolveExpectedServiceRoot(currentConfig, input);
+ if (!service.installed || !currentConfig || !root) {
throw new RuntimeHostServiceManagerError(
'not_installed',
'Runtime Host service is not installed',
);
}
- let prepared: { readonly hostEpoch: string; readonly pid: number } | undefined;
- let rootFence: InteractiveRootOwner | undefined;
- if (service.pid !== null) {
- const retirement = await deps.prepareRetirement(
- service.config,
- service.pid,
+ if (
+ runtimeHostManagedServiceConfigFingerprint(currentConfig) !== input.expectedConfigFingerprint
+ ) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_changed',
+ 'The managed Runtime Host configuration changed; refresh it before applying this edit',
+ );
+ }
+ const desired = await prepareServiceConfig(
+ {
+ ...input,
+ rootPath: currentConfig.rootPath,
+ nodePath: currentConfig.launch.nodePath,
+ cliPath: currentConfig.launch.cliPath,
+ },
+ currentConfig,
+ deps,
+ );
+ if (sameProjectDirectoryRoots(currentConfig, desired)) {
+ return configurationResult('unchanged', service);
+ }
+ await backend.preflightDeployment();
+ await backend.verifyDeployment(currentConfig, { acceptLegacyConfigLaunch: true });
+ const deployment = await backend.stageDeployment();
+ const retired = await retireManagedRuntimeHostService(
+ { ...service, config: currentConfig },
+ root,
+ backend,
+ deps,
+ input.allowInterruptActiveTasks ?? false,
+ );
+ if (retired.retirement.kind === 'active_tasks') {
+ return configurationResult('active_tasks', service);
+ }
+ try {
+ await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(desired, null, 2)}\n`, 0o600);
+ await deployment.apply(desired);
+ await deps.waitForReady(desired, backend);
+ } catch (error) {
+ await rollbackRuntimeHostConfiguration(
+ { configPath, currentConfig, desiredConfig: desired, root, deployment },
+ backend,
+ deps,
input.allowInterruptActiveTasks ?? false,
+ error,
);
- if (retirement.kind === 'active_tasks') {
- return { schemaVersion: 1, action: input.action, service, retirement };
- }
- prepared = retirement;
- rootFence = await acquirePreparedRuntimeHostRootRetirementFence(root, prepared.pid, backend);
- } else if (service.active) {
+ }
+ return configurationResult('configured', await readServiceStatus(configPath, backend));
+ }
+ if (input.action === 'retire') {
+ if (!input.expectedTarget) {
throw new RuntimeHostServiceManagerError(
- 'retirement_failed',
- 'Managed Runtime Host service did not report its process identity',
+ 'target_mismatch',
+ 'Runtime Host retirement requires the expected managed service identity',
);
- } else if (service.state === 'starting') {
- rootFence = await acquireRuntimeHostRootRetirementFence(root);
}
- try {
- await backend.retire();
- const stopped = await readServiceStatus(configPath, backend);
- if (stopped.active || stopped.state !== 'stopped' || stopped.pid !== null) {
- throw new RuntimeHostServiceManagerError(
- 'retirement_failed',
- 'Runtime Host service did not reach a stable stopped state after retirement',
- );
- }
- if (rootFence) {
- await releaseRuntimeHostRootRetirementFence(rootFence);
- rootFence = undefined;
- } else {
- await verifyRuntimeHostRootReleased(root);
- }
- return {
- schemaVersion: 1,
- action: input.action,
- service: stopped,
- retirement: prepared
- ? { kind: 'retired', hostEpoch: prepared.hostEpoch, pid: prepared.pid }
- : { kind: 'stopped' },
- };
- } finally {
- await rootFence?.close().catch(() => undefined);
+ const service = await readServiceStatus(configPath, backend);
+ const currentConfig = service.config;
+ const root = await resolveExpectedServiceRoot(currentConfig, input);
+ if (!service.installed || !currentConfig || !root) {
+ throw new RuntimeHostServiceManagerError(
+ 'not_installed',
+ 'Runtime Host service is not installed',
+ );
}
+ const retired = await retireManagedRuntimeHostService(
+ { ...service, config: currentConfig },
+ root,
+ backend,
+ deps,
+ input.allowInterruptActiveTasks ?? false,
+ );
+ return { schemaVersion: 1, action: input.action, ...retired };
}
const config = await readServiceConfig(configPath);
if (!config) {
@@ -546,6 +621,7 @@ async function manageRuntimeHostServiceLocked(
await resolveExpectedServiceRoot(config, input);
if (input.action === 'start' || input.action === 'restart') {
try {
+ if (config.schemaVersion === 2) await backend.verifyDeployment(config);
await backend[input.action]();
await deps.waitForReady(config, backend);
} catch (error) {
@@ -592,7 +668,7 @@ async function replaceRuntimeHostManagedServiceLocked(
'Retire the managed Runtime Host service before replacing its package',
);
}
- await backend.preflightInstall();
+ await backend.preflightDeployment();
const config = await prepareServiceConfig(input, service.config, deps);
let rootFence: InteractiveRootOwner | undefined =
await acquireRuntimeHostRootRetirementFence(root);
@@ -774,7 +850,10 @@ async function prepareServiceConfig(
const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
const requestedRoot = resolve(input.rootPath ?? previous?.rootPath ?? input.defaultRootPath);
const projectDirectoryRoots = await normalizeProjectDirectoryRoots(
- input.projectDirectoryRoots ?? previous?.projectDirectoryRoots ?? [],
+ input.projectDirectoryRoots ??
+ (previous
+ ? effectiveRuntimeHostProjectDirectoryRoots(previous, deps.homeDir)
+ : [{ label: '~', path: deps.homeDir }]),
);
const [nodePath, cliPath] = await Promise.all([
realpath(input.nodePath),
@@ -823,7 +902,7 @@ async function prepareServiceConfig(
);
}
const config: RuntimeHostManagedServiceConfig = {
- schemaVersion: 1,
+ schemaVersion: 2,
...(managedDeploymentRoot ? { managedDeploymentRoot } : {}),
rootPath,
projectDirectoryRoots,
@@ -889,6 +968,19 @@ async function readServiceConfig(path: string): Promise<RuntimeHostManagedServic
}
}
+export async function readRuntimeHostManagedServiceConfig(
+ path: string,
+): Promise<RuntimeHostManagedServiceConfig> {
+ const config = await readServiceConfig(path);
+ if (!config) {
+ throw new RuntimeHostServiceManagerError(
+ 'not_installed',
+ 'Runtime Host managed service configuration is unavailable',
+ );
+ }
+ return config;
+}
+
async function readServiceConfigForRepair(
path: string,
): Promise<RuntimeHostManagedServiceConfig | null> {
@@ -920,7 +1012,9 @@ function validateServiceConfig(
value: unknown,
serviceId: string,
): asserts value is RuntimeHostManagedServiceConfig {
- if (!isRecord(value) || value.schemaVersion !== 1) throw new TypeError('Invalid schemaVersion');
+ if (!isRecord(value) || (value.schemaVersion !== 1 && value.schemaVersion !== 2)) {
+ throw new TypeError('Invalid schemaVersion');
+ }
if (!isSafeAbsolutePath(value.rootPath)) throw new TypeError('Invalid rootPath');
if (
!Array.isArray(value.projectDirectoryRoots) ||
@@ -929,13 +1023,15 @@ function validateServiceConfig(
throw new TypeError('Invalid projectDirectoryRoots');
}
for (const root of value.projectDirectoryRoots) {
+ const canonical =
+ isRecord(root) && typeof root.label === 'string' && typeof root.path === 'string'
+ ? canonicalProjectDirectoryRootSpec({ label: root.label, path: root.path })
+ : undefined;
if (
!isRecord(root) ||
- typeof root.label !== 'string' ||
- root.label.length === 0 ||
- Buffer.byteLength(root.label, 'utf8') > PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES ||
- hasControlCharacters(root.label) ||
- !isSafeAbsolutePath(root.path)
+ !canonical ||
+ root.label !== canonical.label ||
+ !projectDirectoryPosixRootSpecValid(canonical)
) {
throw new TypeError('Invalid project directory root');
}
@@ -1198,6 +1294,65 @@ async function acquirePreparedRuntimeHostRootRetirementFence(
);
}
+async function retireManagedRuntimeHostService(
+ service: RuntimeHostManagedServiceStatus & { readonly config: RuntimeHostManagedServiceConfig },
+ root: StorageRootCapability<'interactive'>,
+ backend: RuntimeHostServiceBackend,
+ deps: RuntimeHostServiceManagerDeps,
+ allowInterruptActiveTasks: boolean,
+): Promise<{
+ readonly service: RuntimeHostManagedServiceStatus;
+ readonly retirement: RuntimeHostRetirementResult;
+}> {
+ let prepared: { readonly hostEpoch: string; readonly pid: number } | undefined;
+ let rootFence: InteractiveRootOwner | undefined;
+ if (service.pid !== null) {
+ const retirement = await deps.prepareRetirement(
+ service.config,
+ service.pid,
+ allowInterruptActiveTasks,
+ );
+ if (retirement.kind === 'active_tasks') return { service, retirement };
+ prepared = retirement;
+ rootFence = await acquirePreparedRuntimeHostRootRetirementFence(root, prepared.pid, backend);
+ } else if (service.active) {
+ throw new RuntimeHostServiceManagerError(
+ 'retirement_failed',
+ 'Managed Runtime Host service did not report its process identity',
+ );
+ } else if (service.state === 'starting') {
+ rootFence = await acquireRuntimeHostRootRetirementFence(root);
+ }
+ try {
+ await backend.retire();
+ const stopped: RuntimeHostManagedServiceStatus = {
+ ...(await backend.status()),
+ config: service.config,
+ installedVersion: service.installedVersion,
+ };
+ if (stopped.active || stopped.state !== 'stopped' || stopped.pid !== null) {
+ throw new RuntimeHostServiceManagerError(
+ 'retirement_failed',
+ 'Runtime Host service did not reach a stable stopped state after retirement',
+ );
+ }
+ if (rootFence) {
+ await releaseRuntimeHostRootRetirementFence(rootFence);
+ rootFence = undefined;
+ } else {
+ await verifyRuntimeHostRootReleased(root);
+ }
+ return {
+ service: stopped,
+ retirement: prepared
+ ? { kind: 'retired', hostEpoch: prepared.hostEpoch, pid: prepared.pid }
+ : { kind: 'stopped' },
+ };
+ } finally {
+ await rootFence?.close().catch(() => undefined);
+ }
+}
+
async function releaseRuntimeHostRootRetirementFence(owner: InteractiveRootOwner): Promise<void> {
try {
await owner.close();
@@ -1212,29 +1367,39 @@ async function releaseRuntimeHostRootRetirementFence(owner: InteractiveRootOwner
async function rollbackDeployment(
deployment: RuntimeHostServiceDeployment,
+ backend: RuntimeHostServiceBackend,
configRollback: {
readonly configPath: string;
readonly previous: RuntimeHostManagedServiceConfig | null;
- } | null,
+ },
originalError: unknown,
-): Promise<never> {
+): Promise<void> {
const rollbackErrors: unknown[] = [];
try {
- await deployment.rollback();
+ await quiesceRuntimeHostServiceForConfigRollback(backend);
+ } catch (rollbackError) {
+ throw new RuntimeHostServiceManagerError(
+ 'service_manager_operation_failed',
+ 'Runtime Host service installation failed and its candidate process could not be stopped; the candidate configuration was retained',
+ { cause: new AggregateError([originalError, rollbackError]) },
+ );
+ }
+ try {
+ if (configRollback.previous) {
+ await writeRuntimeHostServiceFile(
+ configRollback.configPath,
+ `${JSON.stringify(configRollback.previous, null, 2)}\n`,
+ 0o600,
+ );
+ } else {
+ await removeRuntimeHostServiceFile(configRollback.configPath, 'service config');
+ }
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
- if (configRollback) {
+ if (rollbackErrors.length === 0) {
try {
- if (configRollback.previous) {
- await writeRuntimeHostServiceFile(
- configRollback.configPath,
- `${JSON.stringify(configRollback.previous, null, 2)}\n`,
- 0o600,
- );
- } else {
- await removeRuntimeHostServiceFile(configRollback.configPath, 'service config');
- }
+ await deployment.rollback();
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
@@ -1249,6 +1414,153 @@ async function rollbackDeployment(
throw originalError;
}
+async function quiesceRuntimeHostServiceForConfigRollback(
+ backend: RuntimeHostServiceBackend,
+): Promise<void> {
+ let status = await backend.status();
+ if (status.active || status.pid !== null || status.state === 'starting') {
+ await backend.retire();
+ status = await backend.status();
+ }
+ if (
+ status.active ||
+ status.pid !== null ||
+ status.state === 'running' ||
+ status.state === 'starting'
+ ) {
+ throw new Error('The candidate Runtime Host service is still running');
+ }
+}
+
+async function rollbackRuntimeHostConfiguration(
+ input: {
+ readonly configPath: string;
+ readonly currentConfig: RuntimeHostManagedServiceConfig;
+ readonly desiredConfig: RuntimeHostManagedServiceConfig;
+ readonly root: StorageRootCapability<'interactive'>;
+ readonly deployment: RuntimeHostServiceDeployment;
+ },
+ backend: RuntimeHostServiceBackend,
+ deps: RuntimeHostServiceManagerDeps,
+ allowInterruptActiveTasks: boolean,
+ originalError: unknown,
+): Promise<never> {
+ const revision = await identifyRuntimeHostServiceConfigRevision(
+ input.configPath,
+ input.currentConfig,
+ input.desiredConfig,
+ );
+ if (revision === 'current') {
+ try {
+ await input.deployment.rollback();
+ await backend.start();
+ await deps.waitForReady(input.currentConfig, backend);
+ } catch (rollbackError) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ 'The Runtime Host configuration was not published and the previous Host could not be restarted',
+ { cause: new AggregateError([originalError, rollbackError]) },
+ );
+ }
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ 'The Runtime Host configuration could not be published; the previous configuration was restored',
+ { cause: originalError },
+ );
+ }
+ if (revision === 'unknown') {
+ try {
+ await quiesceRuntimeHostServiceForConfigRollback(backend);
+ } catch (rollbackError) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ 'The Runtime Host configuration could not be applied, its persisted revision is uncertain, and its process could not be stopped',
+ { cause: new AggregateError([originalError, rollbackError]) },
+ );
+ }
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ 'The Runtime Host configuration could not be applied and its persisted revision is uncertain; the Host remains stopped for inspection',
+ { cause: originalError },
+ );
+ }
+
+ try {
+ const candidate = await readServiceStatus(input.configPath, backend);
+ const retired = await retireManagedRuntimeHostService(
+ { ...candidate, config: input.desiredConfig },
+ input.root,
+ backend,
+ deps,
+ allowInterruptActiveTasks,
+ );
+ if (retired.retirement.kind === 'active_tasks') {
+ throw new Error('The candidate Runtime Host accepted active work before rollback');
+ }
+ } catch (rollbackError) {
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ 'The Runtime Host configuration could not be verified and the candidate Host could not be safely retired; its configuration was retained',
+ { cause: new AggregateError([originalError, rollbackError]) },
+ );
+ }
+
+ const rollbackErrors: unknown[] = [];
+ try {
+ await writeRuntimeHostServiceFile(
+ input.configPath,
+ `${JSON.stringify(input.currentConfig, null, 2)}\n`,
+ 0o600,
+ );
+ } catch (rollbackError) {
+ rollbackErrors.push(rollbackError);
+ }
+ if (rollbackErrors.length === 0) {
+ try {
+ await input.deployment.rollback();
+ await backend.start();
+ await deps.waitForReady(input.currentConfig, backend);
+ } catch (rollbackError) {
+ rollbackErrors.push(rollbackError);
+ }
+ }
+ throw new RuntimeHostServiceManagerError(
+ 'configuration_incomplete',
+ rollbackErrors.length === 0
+ ? 'The Runtime Host configuration could not be applied; the previous configuration was restored'
+ : 'The Runtime Host configuration could not be applied or fully restored; inspect the service before retrying',
+ {
+ cause:
+ rollbackErrors.length === 0
+ ? originalError
+ : new AggregateError([originalError, ...rollbackErrors]),
+ },
+ );
+}
+
+async function identifyRuntimeHostServiceConfigRevision(
+ configPath: string,
+ current: RuntimeHostManagedServiceConfig | null,
+ desired: RuntimeHostManagedServiceConfig,
+): Promise<'current' | 'desired' | 'unknown'> {
+ let observed: RuntimeHostManagedServiceConfig | null;
+ try {
+ observed = await readServiceConfig(configPath);
+ } catch {
+ return 'unknown';
+ }
+ if (sameRuntimeHostManagedServiceConfig(observed, current)) return 'current';
+ if (sameRuntimeHostManagedServiceConfig(observed, desired)) return 'desired';
+ return 'unknown';
+}
+
+function sameRuntimeHostManagedServiceConfig(
+ left: RuntimeHostManagedServiceConfig | null,
+ right: RuntimeHostManagedServiceConfig | null,
+): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
async function allocateLoopbackPort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const server = createServer();
@@ -1267,7 +1579,7 @@ async function allocateLoopbackPort(): Promise<number> {
}
function result(
- action: Exclude<RuntimeHostManagedServiceAction, 'retire'>,
+ action: Exclude<RuntimeHostManagedServiceAction, 'retire' | 'configure'>,
service: RuntimeHostManagedServiceStatus,
retainedStateRoot?: string,
logs?: string,
@@ -1281,6 +1593,50 @@ function result(
};
}
+function configurationResult(
+ kind: 'unchanged' | 'configured' | 'active_tasks',
+ service: RuntimeHostManagedServiceStatus,
+): RuntimeHostManagedServiceResult {
+ return { schemaVersion: 1, action: 'configure', service, configuration: { kind } };
+}
+
+export function effectiveRuntimeHostProjectDirectoryRoots(
+ config: RuntimeHostManagedServiceConfig,
+ homeDir = homedir(),
+): readonly { readonly label: string; readonly path: string }[] {
+ if (config.schemaVersion !== 1 || config.projectDirectoryRoots.length > 0) {
+ return config.projectDirectoryRoots;
+ }
+ try {
+ return [{ label: '~', path: realpathSync(homeDir) }];
+ } catch {
+ return [];
+ }
+}
+
+export function runtimeHostManagedServiceConfigFingerprint(
+ config: RuntimeHostManagedServiceConfig,
+): string {
+ const canonical = JSON.stringify({
+ managedDeploymentRoot: config.managedDeploymentRoot ?? null,
+ rootPath: config.rootPath,
+ projectDirectoryRoots: effectiveRuntimeHostProjectDirectoryRoots(config),
+ websocket: config.websocket,
+ launch: config.launch,
+ });
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
+}
+
+function sameProjectDirectoryRoots(
+ current: RuntimeHostManagedServiceConfig,
+ desired: RuntimeHostManagedServiceConfig,
+): boolean {
+ return (
+ JSON.stringify(effectiveRuntimeHostProjectDirectoryRoots(current)) ===
+ JSON.stringify(desired.projectDirectoryRoots)
+ );
+}
+
function isSafeAbsolutePath(value: unknown): value is string {
return typeof value === 'string' && isAbsolute(value) && !hasControlCharacters(value);
}
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index dac7928d1..984868c8b 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -76,7 +76,7 @@ export interface RuntimeHostSetupCliOptions {
interface RuntimeHostSetupDeps {
readonly manageService: typeof manageRuntimeHostService;
- readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend;
readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment;
readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
readonly prepareCredential: typeof prepareRuntimeHostAccessCredential;
@@ -142,7 +142,7 @@ async function runRuntimeHostSetupLocked(
): Promise<void> {
emit({ kind: 'progress', phase: 'checking_environment' });
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
- const backend = deps.createBackend(serviceId);
+ const backend = deps.createBackend(serviceId, options.clientDataRoot);
const common = {
clientDataRoot: options.clientDataRoot,
defaultRootPath: options.defaultRootPath,
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index 795ceb505..de5bf1d71 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -32,6 +32,7 @@ import {
writeRuntimeHostServiceFile,
} from './runtime-host-service-manager.js';
import {
+ legacyRuntimeHostServiceLaunchArguments,
RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS,
RUNTIME_HOST_UPDATE_INTERVAL_SECONDS,
RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS,
@@ -59,6 +60,7 @@ interface SystemdUpdateSchedulerContext {
}
export interface SystemdUserServiceOptions {
+ readonly serviceConfigPath: string;
readonly env?: NodeJS.ProcessEnv;
readonly homeDir?: string;
readonly uid?: number;
@@ -75,10 +77,11 @@ export interface SystemdUserServiceOptions {
export function createSystemdUserRuntimeHostService(
serviceId: string,
- options: SystemdUserServiceOptions = {},
+ options: SystemdUserServiceOptions,
): RuntimeHostServiceBackend {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
+ const { serviceConfigPath } = options;
const runSystemctl = options.runSystemctl ?? defaultRunSystemctl;
const context: SystemdUnitContext = {
unitName: resolveSystemdUserRuntimeHostServiceName(serviceId),
@@ -104,34 +107,26 @@ export function createSystemdUserRuntimeHostService(
};
return {
- preflightInstall: async () => {
+ preflightDeployment: async () => {
await assertUserSystemd(runSystemctl);
await assertUserLinger(uid, runLoginctl);
},
- install: async (config) => {
- await validateRuntimeHostServiceLaunch(config);
+ stageDeployment: async () => {
const [previous, previousScheduler] = await Promise.all([
captureSystemdDeployment(context.unitPath, readStatus),
captureSystemdUpdateScheduler(scheduler),
]);
await assertNoSystemdUpdateSchedulerDropIns(scheduler);
let schedulerMutationStarted = false;
- try {
- await applySystemdDeployment(context, config);
- await applySystemdUpdateSchedulerDesiredState(scheduler, config, () => {
- schedulerMutationStarted = true;
- });
- } catch (error) {
- await restoreFailedSystemdDeployment(
- previous,
- schedulerMutationStarted ? previousScheduler : undefined,
- context,
- scheduler,
- error,
- );
- }
let rolledBack = false;
return {
+ apply: async (config) => {
+ await validateRuntimeHostServiceLaunch(config);
+ await applySystemdDeployment(context, config, serviceConfigPath);
+ await applySystemdUpdateSchedulerDesiredState(scheduler, config, () => {
+ schedulerMutationStarted = true;
+ });
+ },
rollback: async () => {
if (rolledBack) return;
rolledBack = true;
@@ -152,7 +147,7 @@ export function createSystemdUserRuntimeHostService(
]);
let schedulerMutationStarted = false;
try {
- await applySystemdDeployment(context, config);
+ await applySystemdDeployment(context, config, serviceConfigPath);
await convergeSystemdUpdateSchedulerForReplacement(scheduler, config, () => {
schedulerMutationStarted = true;
});
@@ -183,7 +178,12 @@ export function createSystemdUserRuntimeHostService(
status.fragmentPath !== context.unitPath ||
status.needDaemonReload !== 'no' ||
Boolean(status.dropInPaths?.trim()) ||
- unit !== renderSystemdUnit(config)
+ !systemdUnitMatchesConfig(
+ unit,
+ config,
+ serviceConfigPath,
+ options?.acceptLegacyConfigLaunch ?? false,
+ )
) {
throw new RuntimeHostServiceManagerError(
'target_mismatch',
@@ -326,8 +326,30 @@ function resolveSystemdUserRuntimeHostUpdateTimerName(serviceId: string): string
return `maka-runtime-host-${serviceId}-update.timer`;
}
-export function renderSystemdUnit(config: RuntimeHostManagedServiceConfig): string {
- const args = runtimeHostServiceLaunchArguments(config);
+export function renderSystemdUnit(
+ config: RuntimeHostManagedServiceConfig,
+ serviceConfigPath: string,
+): string {
+ return renderSystemdUnitWithArguments(
+ runtimeHostServiceLaunchArguments(config, serviceConfigPath),
+ );
+}
+
+function systemdUnitMatchesConfig(
+ unit: string | null,
+ config: RuntimeHostManagedServiceConfig,
+ serviceConfigPath: string,
+ acceptLegacyConfigLaunch: boolean,
+): boolean {
+ return (
+ unit === renderSystemdUnit(config, serviceConfigPath) ||
+ (acceptLegacyConfigLaunch &&
+ config.schemaVersion === 1 &&
+ unit === renderSystemdUnitWithArguments(legacyRuntimeHostServiceLaunchArguments(config)))
+ );
+}
+
+function renderSystemdUnitWithArguments(args: readonly string[]): string {
return [
'[Unit]',
'Description=Maka Runtime Host',
@@ -764,8 +786,13 @@ async function captureSystemdDeployment(
async function applySystemdDeployment(
context: SystemdUnitContext,
config: RuntimeHostManagedServiceConfig,
+ serviceConfigPath: string,
): Promise<void> {
- await writeRuntimeHostServiceFile(context.unitPath, renderSystemdUnit(config), 0o600);
+ await writeRuntimeHostServiceFile(
+ context.unitPath,
+ renderSystemdUnit(config, serviceConfigPath),
+ 0o600,
+ );
await requireSystemctl(context.runSystemctl, ['daemon-reload'], 'Reloading systemd failed');
await requireSystemctl(
context.runSystemctl,
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index f9c733451..250d94970 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -102,7 +102,7 @@ interface RuntimeHostUpdateCliDeps {
readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
readonly withLegacyOperatorLeases: typeof withRuntimeHostManagedServiceLegacyOperatorLeases;
- readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend;
readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady;
readonly runOperator: (
operatorPath: string,
@@ -193,7 +193,7 @@ export async function runManagedRuntimeHostUpdateCli(
'The managed Runtime Host update does not match the expected service identity',
);
}
- const backend = deps.createBackend(serviceId);
+ const backend = deps.createBackend(serviceId, options.clientDataRoot);
const common = {
clientDataRoot: options.clientDataRoot,
defaultRootPath: options.defaultRootPath,
diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts
index 2224f547a..0ffe6c9ee 100644
--- a/packages/cli/src/runtime-host-update-discovery.ts
+++ b/packages/cli/src/runtime-host-update-discovery.ts
@@ -129,7 +129,7 @@ async function resolveManagedRuntimeHostUpdate(
verifyDeployment: boolean,
): Promise<RuntimeHostUpdateSelection> {
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
- const backend = createPlatformRuntimeHostServiceBackend(serviceId);
+ const backend = createPlatformRuntimeHostServiceBackend(serviceId, options.clientDataRoot);
const status = await manageRuntimeHostService(
{
action: 'status',
diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts
index e77331a6e..84d456128 100644
--- a/packages/cli/src/runtime-host-update-reconciliation.ts
+++ b/packages/cli/src/runtime-host-update-reconciliation.ts
@@ -86,7 +86,7 @@ interface RuntimeHostUpdateReconciliationDeps {
readonly readPolicy: typeof readRuntimeHostManagedUpdatePolicy;
readonly writePolicy: typeof writeRuntimeHostManagedUpdatePolicy;
readonly manage: typeof manageRuntimeHostService;
- readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend;
readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection;
readonly applySelection: typeof runManagedRuntimeHostResolvedUpdateCli;
readonly writeOutput: (value: string) => unknown;
@@ -287,7 +287,7 @@ function readManagedServiceStatus(
cliPath: process.argv[1] ?? '',
...(expectedTarget ? { expectedTarget } : {}),
},
- deps.createBackend(serviceId),
+ deps.createBackend(serviceId, options.clientDataRoot),
);
}
@@ -298,7 +298,10 @@ async function inspectUpdateScheduler(
): Promise<RuntimeHostUpdateSchedulerState> {
const config = status.service.config;
if (!status.service.installed || !config?.managedDeploymentRoot) return 'needs_repair';
- const backend = deps.createBackend(resolveRuntimeHostManagedServiceId(options.clientDataRoot));
+ const backend = deps.createBackend(
+ resolveRuntimeHostManagedServiceId(options.clientDataRoot),
+ options.clientDataRoot,
+ );
try {
await backend.verifyDeployment(config, { requireSchedulerReady: true });
return 'ready';
diff --git a/packages/runtime-host/protocol-compatible-changes/project-directory-root-spec.json b/packages/runtime-host/protocol-compatible-changes/project-directory-root-spec.json
new file mode 100644
index 000000000..bb6a88d51
--- /dev/null
+++ b/packages/runtime-host/protocol-compatible-changes/project-directory-root-spec.json
@@ -0,0 +1,5 @@
+{
+ "epoch": 50,
+ "files": ["packages/runtime-host/src/protocol/project-catalog.ts"],
+ "reason": "Adds shared non-wire Project root syntax helpers without changing any protocol codec or message shape"
+}
diff --git a/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts
index cd93bc964..9e3560677 100644
--- a/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts
@@ -26,6 +26,8 @@ import {
decodeProjectCatalogQueryResult,
HOST_OPERATION_SPECS,
PROJECT_CATALOG_PAGE_MAX_ITEMS,
+ projectDirectoryPosixRootSpecValid,
+ projectDirectoryRootSpecValid,
REMOTE_OWNER_OPERATION_GRANTS,
} from '../protocol/index.js';
@@ -34,6 +36,13 @@ const foreignHostPath = process.platform === 'win32' ? '/workspace' : 'C:\\works
const revision = `sha256:${'a'.repeat(64)}` as const;
describe('Project catalog protocol', () => {
+ test('keeps Host-native root shape separate from SSH POSIX policy', () => {
+ const windowsRoot = { label: 'Work', path: 'C:\\workspace' };
+ assert.equal(projectDirectoryRootSpecValid(windowsRoot), true);
+ assert.equal(projectDirectoryPosixRootSpecValid(windowsRoot), false);
+ assert.equal(projectDirectoryPosixRootSpecValid({ label: 'Work', path: '/workspace' }), true);
+ });
+
test('decodes exact invalidations', () => {
const frame = { kind: 'project.catalog.changed' as const, revision: 1 };
assert.deepEqual(decodeHostFrame(frame), frame);
diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index c59f7d8a5..bf53d7a58 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -31,6 +31,7 @@ export { runtimeHostAccessCredentialFingerprint } from '../access-credential-ide
export {
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+ RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY,
RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts
index 1608ebe0c..b58e53e2a 100644
--- a/packages/runtime-host/src/operator/service-management-frame.ts
+++ b/packages/runtime-host/src/operator/service-management-frame.ts
@@ -18,6 +18,7 @@
*/
import { z } from 'zod';
+import { PROJECT_DIRECTORY_MAX_ROOTS } from '../protocol/project-catalog.js';
import {
compareProductReleaseVersions,
isProductReleaseVersion,
@@ -30,6 +31,8 @@ export const RUNTIME_HOST_SERVICE_LOG_MAX_BYTES = 48 * 1024;
export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128;
export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024;
export const RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY = 'access-management-v1';
+export const RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV =
+ 'MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST';
export const RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY = 'process-lifetime-lock-v1';
export const RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY = 'update-scheduler-v1';
export const RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV =
@@ -40,6 +43,7 @@ const PATH_MAX_BYTES = 4 * 1024;
const FIELD_MAX_BYTES = 512;
const SERVICE_ACTIONS = [
'install',
+ 'configure',
'status',
'start',
'stop',
@@ -63,6 +67,7 @@ const NON_RETIRE_SERVICE_ACTIONS = [
] as const;
const NON_UPDATE_SERVICE_ACTIONS = [
'install',
+ 'configure',
'status',
'start',
'stop',
@@ -227,6 +232,10 @@ const SERVICE_SUMMARY_SCHEMA = z
lastExitCode: z.number().int().nonnegative().nullable(),
installedVersion: boundedString(FIELD_MAX_BYTES).nullable(),
stateRoot: boundedString(PATH_MAX_BYTES).optional(),
+ configurationFingerprint: z
+ .string()
+ .regex(/^sha256:[a-f0-9]{64}$/u)
+ .optional(),
projectDirectoryRoots: z
.array(
z
@@ -236,7 +245,7 @@ const SERVICE_SUMMARY_SCHEMA = z
})
.strict(),
)
- .max(64),
+ .max(PROJECT_DIRECTORY_MAX_ROOTS),
})
.strict();
const INSTALLED_SERVICE_SUMMARY_SCHEMA = SERVICE_SUMMARY_SCHEMA.extend({
@@ -361,6 +370,15 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
});
}
}),
+ z
+ .object({
+ ...SERVICE_RESULT_COMMON,
+ action: z.literal('configure'),
+ configuration: z
+ .object({ kind: z.enum(['unchanged', 'configured', 'active_tasks']) })
+ .strict(),
+ })
+ .strict(),
z
.object({
...SERVICE_RESULT_COMMON,
diff --git a/packages/runtime-host/src/protocol/project-catalog.ts b/packages/runtime-host/src/protocol/project-catalog.ts
index 795dc7feb..cc0ed727d 100644
--- a/packages/runtime-host/src/protocol/project-catalog.ts
+++ b/packages/runtime-host/src/protocol/project-catalog.ts
@@ -41,8 +41,44 @@ export const PROJECT_DIRECTORY_MAX_ENTRIES = 4_096;
export const PROJECT_DIRECTORY_MAX_ROOTS = 8;
export const PROJECT_DIRECTORY_MAX_SEGMENTS = 64;
export const PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES = 128;
+export const PROJECT_DIRECTORY_ROOT_PATH_MAX_BYTES = PROJECT_CATALOG_PATH_MAX_BYTES;
export const PROJECT_DIRECTORY_SEGMENT_MAX_BYTES = 255;
+const PROJECT_DIRECTORY_ROOT_TEXT_ENCODER = new TextEncoder();
+
+export interface ProjectDirectoryRootSpec {
+ readonly label: string;
+ readonly path: string;
+}
+
+export function canonicalProjectDirectoryRootSpec(
+ root: ProjectDirectoryRootSpec,
+): ProjectDirectoryRootSpec {
+ return { label: root.label.trim(), path: root.path };
+}
+
+export function projectDirectoryRootSpecValid(root: ProjectDirectoryRootSpec): boolean {
+ const canonical = canonicalProjectDirectoryRootSpec(root);
+ return (
+ canonical.label.length > 0 &&
+ PROJECT_DIRECTORY_ROOT_TEXT_ENCODER.encode(canonical.label).byteLength <=
+ PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES &&
+ !hasProjectDirectoryRootControlCharacters(canonical.label) &&
+ canonical.path.length > 0 &&
+ PROJECT_DIRECTORY_ROOT_TEXT_ENCODER.encode(canonical.path).byteLength <=
+ PROJECT_DIRECTORY_ROOT_PATH_MAX_BYTES &&
+ !hasProjectDirectoryRootControlCharacters(canonical.path)
+ );
+}
+
+export function projectDirectoryPosixRootSpecValid(root: ProjectDirectoryRootSpec): boolean {
+ return projectDirectoryRootSpecValid(root) && root.path.startsWith('/');
+}
+
+function hasProjectDirectoryRootControlCharacters(value: string): boolean {
+ return /[\u0000-\u001f\u007f]/u.test(value);
+}
+
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
diff --git a/packages/runtime-host/src/server/project-directory-authority.ts b/packages/runtime-host/src/server/project-directory-authority.ts
index d63d8629a..d01a0d818 100644
--- a/packages/runtime-host/src/server/project-directory-authority.ts
+++ b/packages/runtime-host/src/server/project-directory-authority.ts
@@ -22,11 +22,12 @@ import { opendir, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import {
+ canonicalProjectDirectoryRootSpec,
PROJECT_DIRECTORY_MAX_ENTRIES,
PROJECT_DIRECTORY_PAGE_MAX_BYTES,
PROJECT_DIRECTORY_PAGE_MAX_ITEMS,
PROJECT_DIRECTORY_MAX_ROOTS,
- PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES,
+ projectDirectoryRootSpecValid,
type ProjectDirectoryQueryInput,
type ProjectDirectoryQueryResult,
type ProjectDirectoryRegisterInput,
@@ -168,21 +169,17 @@ function resolveRoot(
input: PublishedProjectDirectoryRoot,
index: number,
): ResolvedProjectDirectoryRoot {
- const label = input.label.trim();
- if (
- label.length === 0 ||
- Buffer.byteLength(label, 'utf8') > PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES ||
- /[\u0000-\u001f\u007f]/u.test(label)
- ) {
- throw new TypeError('Project directory root label is invalid');
+ const root = canonicalProjectDirectoryRootSpec(input);
+ if (!projectDirectoryRootSpecValid(root) || !isAbsolute(root.path)) {
+ throw new TypeError('Project directory root must use a valid label and absolute Host path');
}
- const path = realpathSync(resolve(input.path));
+ const path = realpathSync(root.path);
if (!statSync(path).isDirectory()) {
throw new TypeError('Project directory root is not a directory');
}
return {
id: `root-${index + 1}`,
- label,
+ label: root.label,
path,
};
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org