You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/26 04:06:20 UTC

(maka) 01/01: feat(desktop): configure remote project roots

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/3851-desktop-project-roots
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 83a8ba095b988910f626767556151e1c3829f727
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Aug 26 12:05:57 2026 +0800

    feat(desktop): configure remote project roots
    
    Generated-by: OpenAI Codex
---
 .../main/__tests__/runtime-host-management.test.ts |  95 ++++++-
 .../main/__tests__/runtime-host-onboarding.test.ts |   9 +-
 .../__tests__/runtime-host-ssh-terminal.test.ts    |  66 +++++
 apps/desktop/src/main/runtime-host-management.ts   | 127 +++++++--
 apps/desktop/src/main/runtime-host-onboarding.ts   |   7 +
 .../main/runtime-host-project-directory-policy.ts  |  55 ++++
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |  30 ++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  11 +-
 apps/desktop/src/preload/preload.ts                |  14 +
 .../src/renderer/locales/settings-projects-copy.ts |  41 ++-
 .../settings/runtime-host-management-dialog.tsx    | 130 ++++++++-
 .../settings/runtime-host-onboarding-dialog.tsx    |  31 ++-
 .../runtime-host-project-directory-editor.tsx      |  94 +++++++
 .../src/renderer/styles/settings/runtime-host.css  |  30 ++-
 docs/runtime-host-remote-access.md                 |   4 +-
 .../runtime-host-operator-command.test.ts          |   5 +
 .../__tests__/runtime-host-service-manager.test.ts | 296 ++++++++++++++++++++-
 .../cli/src/__tests__/runtime-host-setup.test.ts   |   2 +-
 packages/cli/src/cli-core.ts                       |   7 +
 packages/cli/src/runtime-host-cli.ts               |  81 +++++-
 packages/cli/src/runtime-host-service-launch.ts    |  10 +-
 .../src/runtime-host-service-management-command.ts |  44 ++-
 packages/cli/src/runtime-host-service-manager.ts   | 272 +++++++++++++++----
 packages/runtime-host/src/operator/index.ts        |   2 +
 .../src/operator/service-management-frame.ts       |  23 +-
 25 files changed, 1354 insertions(+), 132 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..5bd4d2c8b 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -451,6 +451,93 @@ test('publishes update progress and waits for the managed profile to reconnect',
   });
 });
 
+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[][] = [];
+  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);
+    },
+  });
+  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]]);
+});
+
 test('manages one Host update policy and reconciles it through the bound operator', async () => {
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   const policyInputs: DesktopRuntimeHostSshUpdatePolicyInput[] = [];
@@ -751,9 +838,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..1224d55bb 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);
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..7910e41c4 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,72 @@ 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', 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/u);
+  assert.match(remoteCommand, /Work=\/srv\/work trees/u);
+  assert.match(remoteCommand, /Data=\/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', 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..83e6d0e92 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 './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();
@@ -307,6 +309,65 @@ 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');
+    }
+    if (response.kind === 'result' && response.configuration.kind === 'configured') {
+      const reconnectError = await reconnectUpdatedTarget(
+        profileId,
+        managed,
+        previousHostEpoch,
+        true,
+      );
+      if (reconnectError) {
+        return {
+          schemaVersion: 1,
+          kind: 'error',
+          action: 'configure',
+          error: reconnectError,
+        };
+      }
+    }
+    return response.kind === 'result'
+      ? {
+          ...response,
+          accessManagementAvailable:
+            response.operatorCapabilities?.includes(
+              RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+            ) ?? false,
+        }
+      : { ...response, action: 'configure' };
+  };
+
   const reconnectUpdatedTarget = async (
     profileId: string,
     managed: Awaited<ReturnType<typeof resolveManagedService>>,
@@ -339,7 +400,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 +430,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(
       {
@@ -525,42 +587,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..2b8c36aa3 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 './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-project-directory-policy.ts b/apps/desktop/src/main/runtime-host-project-directory-policy.ts
new file mode 100644
index 000000000..1cfc5f314
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-project-directory-policy.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 {
+  PROJECT_DIRECTORY_MAX_ROOTS,
+  PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES,
+} from '@maka/runtime-host/protocol';
+
+export function requireProjectDirectoryRoots(
+  value: unknown,
+): readonly { readonly label: string; readonly path: string }[] {
+  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' ||
+      root.label.length === 0 ||
+      Buffer.byteLength(root.label, 'utf8') > PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES ||
+      /[\u0000-\u001f\u007f]/u.test(root.label) ||
+      typeof root.path !== 'string' ||
+      root.path.length === 0 ||
+      Buffer.byteLength(root.path, 'utf8') > 4 * 1024 ||
+      /[\u0000-\u001f\u007f]/u.test(root.path)
+    ) {
+      throw new Error('Runtime Host Project directory is invalid');
+    }
+    return { label: root.label, path: root.path };
+  });
+  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/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index a186f2637..427ace3ee 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',
+            `${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',
+            `${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 f2aa6d881..58a276b4c 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,14 @@ export type DesktopRuntimeHostManagementResult = Extract<
   RuntimeHostServiceManagementFrame,
   { kind: 'result' }
 > & {
-  readonly action: DesktopRuntimeHostManagementAction | 'update';
+  readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update';
   readonly accessManagementAvailable: boolean;
 };
 
 export type DesktopRuntimeHostManagementResponse =
   | DesktopRuntimeHostManagementResult
   | (Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> & {
-      readonly action: DesktopRuntimeHostManagementAction | 'update';
+      readonly action: DesktopRuntimeHostManagementAction | 'configure' | 'update';
     })
   | {
       readonly kind: 'uninstalled';
@@ -620,6 +621,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 1671c2da6..c22e5abce 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1262,6 +1262,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..231a220c7 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,17 @@ export type SettingsProjectsCopy = {
     lastExitCode: string;
     stateRoot: string;
     directoryRoots: string;
+    directoryRootsDescription: string;
+    directoryRootsUnavailable: 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;
@@ -225,6 +236,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       setupDescription: '通过 SSH 安装并连接 Runtime Host',
       setupName: '显示名称(可选)',
       setupSshPort: 'SSH 端口(可选)',
+      setupDirectoryRootsDescription: '留空时使用远端 Home。添加目录后,只有这些目录可用于浏览并添加项目。',
       setupConnect: '连接',
       setupCancel: '取消',
       setupRetry: '重试',
@@ -292,8 +304,18 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       processId: '进程 ID',
       lastExitCode: '上次退出码',
       stateRoot: 'State Root',
-      directoryRoots: '可用目录',
-      noDirectoryRoots: '未配置额外目录',
+      directoryRoots: '可用于添加项目的目录',
+      directoryRootsDescription: '远程 Client 只能从这些目录浏览并添加新项目。移除目录不会删除已经添加的项目。',
+      directoryRootsUnavailable: '更新或修复这个 Host 后,即可在 Desktop 中管理这些目录。',
+      noDirectoryRoots: '目录浏览和项目添加已禁用',
+      directoryRootLabel: '显示名称',
+      directoryRootPath: '远端绝对路径',
+      addDirectoryRoot: '添加目录',
+      removeDirectoryRoot: '移除',
+      saveDirectoryRoots: '应用目录',
+      directoryRootsActiveTasks: '这个 Host 仍有正在运行的任务',
+      directoryRootsActiveTasksDescription: '应用目录需要安全重启远端服务。只有明确确认后才会中断这些任务。',
+      configureDirectoriesInterrupt: '中断任务并应用',
       refresh: '刷新',
       startService: '启动',
       restartService: '重启',
@@ -432,6 +454,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 +522,18 @@ 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.',
+      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',
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..19b27e2d7 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -46,10 +46,17 @@ import type {
 } from '../../preload/bridge-contract.js';
 import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
 import { settingsActionErrorMessage } from './settings-error-copy.js';
+import {
+  canonicalProjectDirectoryRoots,
+  projectDirectoryRootsValid,
+  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 +64,6 @@ type RuntimeHostManagementConfirmation =
     };
 
 type UpdatePolicyChoice = 'manual' | 'fixed' | 'latest' | 'next';
-
 export function RuntimeHostManagementDialog(props: {
   readonly profile: RemoteRuntimeHostProfile | undefined;
   readonly onClose: () => void;
@@ -78,6 +84,11 @@ export function RuntimeHostManagementDialog(props: {
   const [updatePolicyError, setUpdatePolicyError] = useState<string>();
   const [lastUpdateOutcome, setLastUpdateOutcome] =
     useState<DesktopRuntimeHostUpdateReconciliationOutcome>();
+  const [directoryRoots, setDirectoryRoots] = useState<readonly ProjectDirectoryRootDraft[]>([]);
+  const [savedDirectoryRoots, setSavedDirectoryRoots] = useState<
+    readonly { readonly label: string; readonly path: string }[]
+  >([]);
+  const nextDirectoryRootId = useRef(1);
   const logsRef = useRef<HTMLPreElement>(null);
 
   const profile = props.profile;
@@ -95,6 +106,8 @@ export function RuntimeHostManagementDialog(props: {
     setFixedVersion('');
     setUpdatePolicyError(undefined);
     setLastUpdateOutcome(undefined);
+    setDirectoryRoots([]);
+    setSavedDirectoryRoots([]);
     setLoading(true);
     void (async () => {
       let shouldLoadUpdatePolicy = false;
@@ -103,6 +116,7 @@ export function RuntimeHostManagementDialog(props: {
         if (disposed) return;
         if (response.kind === 'result') {
           setResult(response);
+          applyDirectoryRoots(response.service.projectDirectoryRoots);
           shouldLoadUpdatePolicy = response.service.state !== 'not_installed';
         }
         else if (response.kind === 'error') setError(response.error.message);
@@ -158,6 +172,7 @@ export function RuntimeHostManagementDialog(props: {
         return;
       }
       setResult(response);
+      applyDirectoryRoots(response.service.projectDirectoryRoots);
       if (response.service.state === 'not_installed') setUpdatePolicy(undefined);
       else if (action !== 'logs') await reloadUpdatePolicy(profile.id);
     } catch (failure) {
@@ -206,6 +221,7 @@ export function RuntimeHostManagementDialog(props: {
         throw new Error('Runtime Host update returned an uninstall result');
       }
       setResult(response);
+      applyDirectoryRoots(response.service.projectDirectoryRoots);
       if (response.action === 'update') setLastUpdateOutcome(response.update);
       setConfirmation(
         response.action === 'update' && response.update.kind === 'active_tasks'
@@ -226,6 +242,51 @@ export function RuntimeHostManagementDialog(props: {
     }
   }
 
+  function applyDirectoryRoots(
+    roots: readonly { readonly label: string; readonly path: string }[],
+  ): void {
+    setSavedDirectoryRoots(roots.map((root) => ({ ...root })));
+    setDirectoryRoots(
+      roots.map((root) => ({ id: nextDirectoryRootId.current++, ...root })),
+    );
+  }
+
+  async function configureDirectories(allowInterruptActiveTasks: boolean): Promise<void> {
+    const fingerprint = result?.service.configurationFingerprint;
+    if (!profile || !fingerprint) return;
+    setLoading(true);
+    setError(undefined);
+    try {
+      const response = await window.maka.runtimeHostManagement.configureProjectDirectories(
+        profile.id,
+        canonicalProjectDirectoryRoots(directoryRoots),
+        fingerprint,
+        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);
+      if (response.configuration.kind === 'active_tasks') {
+        setConfirmation({ kind: 'configureDirectories' });
+      } else {
+        setConfirmation(undefined);
+        applyDirectoryRoots(response.service.projectDirectoryRoots);
+      }
+    } 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;
@@ -293,6 +354,7 @@ export function RuntimeHostManagementDialog(props: {
       applyUpdatePolicy(response.updatePolicy);
       const reconciledService = response.service;
       if (reconciledService) {
+        applyDirectoryRoots(reconciledService.projectDirectoryRoots);
         setResult((current) => current ? { ...current, service: reconciledService } : current);
       }
     } catch (failure) {
@@ -356,6 +418,10 @@ export function RuntimeHostManagementDialog(props: {
       updatePolicy.policy.version !== fixedVersion.trim());
   const automaticPolicySelected = updatePolicyChoice !== 'manual';
   const automaticUpdatesAvailable = updatePolicy?.schedulingState === 'ready';
+  const normalizedDirectoryRoots = canonicalProjectDirectoryRoots(directoryRoots);
+  const directoryRootsAreValid = projectDirectoryRootsValid(directoryRoots);
+  const directoryRootsDirty =
+    JSON.stringify(normalizedDirectoryRoots) !== JSON.stringify(savedDirectoryRoots);
   const updateOutcome = lastUpdateOutcome;
   return (
     <Dialog
@@ -387,6 +453,13 @@ export function RuntimeHostManagementDialog(props: {
                 </div>
               ) : null}
               {error ? <Banner status="error" title={error} /> : null}
+              {confirmation?.kind === 'configureDirectories' ? (
+                <Banner
+                  status="warning"
+                  title={copy.directoryRootsActiveTasks}
+                  description={copy.directoryRootsActiveTasksDescription}
+                />
+              ) : null}
               {confirmation?.kind === 'uninstall' ? (
                 <Banner
                   status="warning"
@@ -577,17 +650,37 @@ 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>
+                    {service.configurationFingerprint ? (
+                      <>
+                        {directoryRoots.length === 0 ? (
+                          <Text type="supporting" color="secondary">{copy.noDirectoryRoots}</Text>
+                        ) : null}
+                        <RuntimeHostProjectDirectoryEditor
+                          roots={directoryRoots}
+                          isDisabled={loading}
+                          nextId={() => nextDirectoryRootId.current++}
+                          copy={copy}
+                          onChange={setDirectoryRoots}
+                        />
+                        <div className="settingsRuntimeHostManagementDirectoryRootActions">
+                          <Button
+                            variant="primary"
+                            size="sm"
+                            label={copy.saveDirectoryRoots}
+                            isDisabled={
+                              loading || !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 +808,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..3c8f1c0c4 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
@@ -24,6 +24,12 @@ 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 { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
+import {
+  canonicalProjectDirectoryRoots,
+  projectDirectoryRootsValid,
+  RuntimeHostProjectDirectoryEditor,
+  type ProjectDirectoryRootDraft,
+} from './runtime-host-project-directory-editor.js';
 
 export function RuntimeHostOnboardingDialog(props: {
   readonly isOpen: boolean;
@@ -40,6 +46,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 +68,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 +159,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..822a08f75
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx
@@ -0,0 +1,94 @@
+/*
+ * 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: '' },
+        ])}
+      />
+    </>
+  );
+}
+
+export function canonicalProjectDirectoryRoots(
+  roots: readonly ProjectDirectoryRootDraft[],
+): readonly { readonly label: string; readonly path: string }[] {
+  return roots.map(({ label, path }) => ({ label: label.trim(), path: path.trim() }));
+}
+
+export function projectDirectoryRootsValid(roots: readonly ProjectDirectoryRootDraft[]): boolean {
+  const canonical = canonicalProjectDirectoryRoots(roots);
+  return (
+    canonical.every(({ label, path }) => label.length > 0 && path.length > 0) &&
+    new Set(canonical.map(({ label }) => label)).size === canonical.length
+  );
+}
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/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index eebda6d31..4e41a5654 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,8 @@ 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.
+
 Project paths stay on the Host. Issue a credential for each Client:
 
 ```sh
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 abd195c54..7ac239734 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',
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..738192a16 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,8 @@ import {
   decodeRuntimeHostServiceManagementFrame,
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+  RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_CAPABILITY,
+  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 +45,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 +57,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,
@@ -100,6 +105,21 @@ describe('managed Runtime Host service', () => {
         websocketPort: 7443,
       },
     );
+    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 +192,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 +318,225 @@ 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 replaceCalls = 0;
+    const backend: RuntimeHostServiceBackend = {
+      ...createUnusedBackend(),
+      preflightInstall: async () => undefined,
+      install: async () => ({ 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;
+        state = 'stopped';
+      },
+      replace: async () => {
+        replaceCalls += 1;
+        state = 'running';
+      },
+    };
+    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);
+    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).join(' '),
+      /no-project-roots/u,
+    );
+    assert.match(runtimeHostServiceLaunchArguments(explicitEmpty).join(' '), /--no-project-roots/u);
+    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);
+
+    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(replaceCalls, 1);
+    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');
+  });
+
   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 +545,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'),
@@ -915,13 +1184,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 +1263,24 @@ 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,
+      RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_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());
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 02efbbb5e..07168bf25 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 {
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 4189bd1d6..560ef4dd7 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -120,6 +120,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]`,
@@ -152,6 +153,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)',
@@ -164,6 +166,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',
@@ -173,6 +176,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',
@@ -275,6 +279,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 d00a333ae..fbe309098 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -63,7 +63,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;
@@ -72,6 +81,7 @@ export type RuntimeHostCliCommand =
       websocketPort?: number;
       websocketPath?: string;
       expectedTarget?: RuntimeHostManagedServiceTarget;
+      expectedConfigFingerprint?: string;
       retainManagedDeployment?: true;
       allowInterruptActiveTasks?: true;
     }
@@ -260,6 +270,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
   }
   if (
     action !== 'install' &&
+    action !== 'configure' &&
     action !== 'status' &&
     action !== 'start' &&
     action !== 'stop' &&
@@ -275,7 +286,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',
     );
   }
 
@@ -283,6 +294,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'
       ? {
@@ -291,7 +303,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
             retainManagedDeployment = true;
           },
         }
-      : action === 'retire' || action === 'update'
+      : action === 'retire' || action === 'update' || action === 'configure'
         ? {
             '--allow-interrupt-active-tasks': () => {
               if (allowInterruptActiveTasks) {
@@ -302,7 +314,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) => {
@@ -318,13 +330,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;
@@ -382,6 +418,12 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
     ...(clientDataRoot ? { clientDataRoot } : {}),
     ...(retainManagedDeployment ? { retainManagedDeployment: true } : {}),
     ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
+    ...(action === 'configure'
+      ? {
+          expectedConfigFingerprint: expectedConfigFingerprint!,
+          projectDirectoryRoots: options.projectDirectoryRoots!,
+        }
+      : {}),
   };
 }
 
@@ -433,6 +475,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];
@@ -454,6 +497,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' ||
@@ -481,6 +532,10 @@ function parseManagedServiceOptions(
       else if (argument === '--expected-root-path') expectedRootPath = parsed;
       else if (argument === '--expected-root-id') expectedRootId = parsed;
       else if (argument === '--project-root') {
+        if (projectDirectoryPolicySpecified && projectDirectoryRoots.length === 0) {
+          return error('--project-root cannot be combined with --no-project-roots');
+        }
+        projectDirectoryPolicySpecified = true;
         const root = parseProjectRoot(parsed);
         if ('kind' in root) return root;
         if (projectDirectoryRoots.length >= PROJECT_DIRECTORY_MAX_ROOTS) {
@@ -537,7 +592,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
@@ -784,6 +839,7 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
   let allowInsecureRemote = false;
   const allowedOrigins: 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') {
@@ -803,6 +859,10 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
       continue;
     }
     if (argument === '--project-root') {
+      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 = parseProjectRoot(parsed);
@@ -817,6 +877,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;
@@ -879,7 +946,7 @@ function parseServeCommand(argv: string[]): RuntimeHostCliCommand {
     kind: 'runtime-host-serve',
     json,
     ...(rootPath ? { rootPath } : {}),
-    ...(projectDirectoryRoots.length > 0 ? { projectDirectoryRoots } : {}),
+    ...(projectDirectoryPolicySpecified ? { projectDirectoryRoots } : {}),
     ...(websocketPort === undefined
       ? {}
       : {
diff --git a/packages/cli/src/runtime-host-service-launch.ts b/packages/cli/src/runtime-host-service-launch.ts
index 0324467f4..ac8f8b1c5 100644
--- a/packages/cli/src/runtime-host-service-launch.ts
+++ b/packages/cli/src/runtime-host-service-launch.ts
@@ -35,10 +35,12 @@ export function runtimeHostServiceLaunchArguments(
     'serve',
     '--root',
     config.rootPath,
-    ...config.projectDirectoryRoots.flatMap(({ label, path }) => [
-      '--project-root',
-      `${label}=${path}`,
-    ]),
+    ...(config.schemaVersion === 2 && config.projectDirectoryRoots.length === 0
+      ? ['--no-project-roots']
+      : config.projectDirectoryRoots.flatMap(({ label, path }) => [
+          '--project-root',
+          `${label}=${path}`,
+        ])),
     '--websocket-host',
     config.websocket.host,
     '--websocket-port',
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 867fabf23..a3938cda6 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -23,6 +23,8 @@ import {
   encodeRuntimeHostServiceManagementFrame,
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+  RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_CAPABILITY,
+  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 +34,10 @@ import {
 } from '@maka/runtime-host/operator';
 import {
   cleanupRuntimeHostManagedDeployment,
+  effectiveRuntimeHostProjectDirectoryRoots,
   manageRuntimeHostService,
   resolveRuntimeHostManagedServiceId,
+  runtimeHostManagedServiceConfigFingerprint,
   RuntimeHostServiceManagerError,
   withRuntimeHostManagedServiceDeploymentLock,
   withRuntimeHostManagedServiceLifecycleLock,
@@ -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) {
@@ -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,30 @@ 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);
+  }
+  if (process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] === '1') {
+    capabilities.push(RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_CAPABILITY);
+  }
+  return capabilities.length > 0 ? { operatorCapabilities: capabilities } : {};
 }
 
 export function runtimeHostServiceSummary(
@@ -211,7 +235,11 @@ 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)] : [],
   };
 }
 
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index f727eaa75..2f734695c 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -19,6 +19,7 @@
 
 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, relative, resolve, sep } from 'node:path';
@@ -62,7 +63,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 {
@@ -130,6 +131,7 @@ export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceBacke
 
 export type RuntimeHostManagedServiceAction =
   | 'install'
+  | 'configure'
   | 'status'
   | 'start'
   | 'stop'
@@ -160,8 +162,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 {
@@ -176,6 +184,7 @@ export interface RuntimeHostManagedServiceInput {
   readonly nodePath: string;
   readonly cliPath: string;
   readonly expectedTarget?: RuntimeHostManagedServiceTarget;
+  readonly expectedConfigFingerprint?: string;
   readonly allowInterruptActiveTasks?: boolean;
 }
 
@@ -225,6 +234,8 @@ export class RuntimeHostServiceManagerError extends Error {
       | 'invalid_config'
       | 'invalid_launch'
       | 'target_mismatch'
+      | 'configuration_changed'
+      | 'configuration_incomplete'
       | 'retirement_failed'
       | 'update_requires_retirement'
       | 'update_incomplete'
@@ -472,68 +483,119 @@ 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,
-        input.allowInterruptActiveTasks ?? false,
-      );
-      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) {
+    if (
+      runtimeHostManagedServiceConfigFingerprint(currentConfig) !== input.expectedConfigFingerprint
+    ) {
       throw new RuntimeHostServiceManagerError(
-        'retirement_failed',
-        'Managed Runtime Host service did not report its process identity',
+        'configuration_changed',
+        'The managed Runtime Host configuration changed; refresh it before applying this edit',
       );
-    } else if (service.state === 'starting') {
-      rootFence = await acquireRuntimeHostRootRetirementFence(root);
+    }
+    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.preflightInstall();
+    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 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',
-        );
+      await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(desired, null, 2)}\n`, 0o600);
+      await backend.replace(desired);
+      await deps.waitForReady(desired, backend);
+    } catch (error) {
+      const rollbackErrors: unknown[] = [];
+      try {
+        await backend.retire();
+      } catch (rollbackError) {
+        rollbackErrors.push(rollbackError);
       }
-      if (rootFence) {
-        await releaseRuntimeHostRootRetirementFence(rootFence);
-        rootFence = undefined;
-      } else {
-        await verifyRuntimeHostRootReleased(root);
+      try {
+        await writeRuntimeHostServiceFile(
+          configPath,
+          `${JSON.stringify(currentConfig, null, 2)}\n`,
+          0o600,
+        );
+        await backend.replace(currentConfig);
+        await deps.waitForReady(currentConfig, backend);
+      } catch (rollbackError) {
+        rollbackErrors.push(rollbackError);
       }
-      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);
+      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 ? error : new AggregateError([error, ...rollbackErrors]),
+        },
+      );
     }
+    return configurationResult('configured', await readServiceStatus(configPath, backend));
+  }
+  if (input.action === 'retire') {
+    if (!input.expectedTarget) {
+      throw new RuntimeHostServiceManagerError(
+        'target_mismatch',
+        'Runtime Host retirement requires the expected managed service identity',
+      );
+    }
+    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) {
@@ -773,7 +835,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),
@@ -822,7 +887,7 @@ async function prepareServiceConfig(
     );
   }
   const config: RuntimeHostManagedServiceConfig = {
-    schemaVersion: 1,
+    schemaVersion: 2,
     ...(managedDeploymentRoot ? { managedDeploymentRoot } : {}),
     rootPath,
     projectDirectoryRoots,
@@ -919,7 +984,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) ||
@@ -1210,6 +1277,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();
@@ -1279,7 +1405,7 @@ async function allocateLoopbackPort(): Promise<number> {
 }
 
 function result(
-  action: Exclude<RuntimeHostManagedServiceAction, 'retire'>,
+  action: Exclude<RuntimeHostManagedServiceAction, 'retire' | 'configure'>,
   service: RuntimeHostManagedServiceStatus,
   retainedStateRoot?: string,
   logs?: string,
@@ -1293,6 +1419,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/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index 030e33b28..4c2975001 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -31,6 +31,8 @@ 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_CAPABILITY,
+  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..4904d1538 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,10 @@ 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_CAPABILITY =
+  'project-directory-configuration-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 +45,7 @@ const PATH_MAX_BYTES = 4 * 1024;
 const FIELD_MAX_BYTES = 512;
 const SERVICE_ACTIONS = [
   'install',
+  'configure',
   'status',
   'start',
   'stop',
@@ -63,6 +69,7 @@ const NON_RETIRE_SERVICE_ACTIONS = [
 ] as const;
 const NON_UPDATE_SERVICE_ACTIONS = [
   'install',
+  'configure',
   'status',
   'start',
   'stop',
@@ -85,6 +92,7 @@ const INSTALLED_SERVICE_STATES = ['stopped', 'starting', 'running', 'failed'] as
 const SERVICE_STATES = ['not_installed', ...INSTALLED_SERVICE_STATES] as const;
 const OPERATOR_CAPABILITIES = [
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
   RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY,
 ] as const;
@@ -227,6 +235,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 +248,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 +373,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,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org