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/31 03:04:24 UTC

(maka) 01/01: feat(desktop): manage WSL host projects

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

M4n5ter pushed a commit to branch feat/wsl-host-project-management
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 75b3ca6980399ee60d682f576f003784f683abe5
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Mon Aug 31 11:02:59 2026 +0800

    feat(desktop): manage WSL host projects
---
 apps/desktop/renderer-architecture.json            |   6 +-
 .../runtime-host-managed-services.test.ts          |  44 ++++++
 .../main/__tests__/runtime-host-management.test.ts | 103 +++++++++++++
 .../main/__tests__/runtime-host-onboarding.test.ts |  30 +++-
 .../__tests__/runtime-host-wsl-controller.test.ts  |  78 +++++++++-
 .../main/__tests__/task-entry-controller.test.ts   |   5 +-
 apps/desktop/src/main/runtime-host-boot.ts         |   6 +-
 .../src/main/runtime-host-managed-services.ts      | 118 ++++++++++-----
 apps/desktop/src/main/runtime-host-management.ts   | 120 ++++++++++++---
 apps/desktop/src/main/runtime-host-onboarding.ts   |  42 ++++--
 .../src/main/runtime-host-peer-mesh-management.ts  |   2 +
 .../src/main/runtime-host-profile-service.ts       |  63 ++++++--
 .../src/main/runtime-host-wsl-controller.ts        | 166 +++++++++++++++++++++
 apps/desktop/src/renderer/app-shell-overlays.tsx   |  13 +-
 apps/desktop/src/renderer/app-shell.tsx            |  40 ++---
 .../controller/use-task-entry-controller.ts        |   4 +
 .../settings/runtime-host-management-dialog.tsx    |  22 +--
 .../settings/runtime-host-profiles-section.tsx     |  59 +++++---
 .../src/renderer/settings/settings-modal.tsx       |  11 +-
 .../src/renderer/settings/settings-surface.tsx     |  26 ++--
 apps/desktop/src/renderer/use-settings-modal.ts    |  34 ++++-
 .../stories/settings/settings-pages.stories.tsx    |   2 +-
 packages/ui/src/conversation-copy.ts               |   5 +-
 packages/ui/src/workspace-picker.tsx               |  11 +-
 24 files changed, 834 insertions(+), 176 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 2f8f24e25e..aa8e819b83 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -624,7 +624,7 @@
           "react": 1
         },
         "importSpecifiers": 22,
-        "nonTriviaTokens": 990
+        "nonTriviaTokens": 977
       },
       "src/renderer/app-shell-project-actions.ts": {
         "importDeclarations": 9,
@@ -945,7 +945,7 @@
           "useShellRunUpdates": 1,
           "useShellSearch": 1,
           "useStableActions": 7,
-          "useState": 18,
+          "useState": 17,
           "useSystemUiLocale": 1,
           "useTaskEntryController": 1,
           "useTaskSubmissionReadiness": 1,
@@ -1063,7 +1063,7 @@
           "react": 1
         },
         "importSpecifiers": 187,
-        "nonTriviaTokens": 15912
+        "nonTriviaTokens": 15908
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
index fc044de4d0..6d274bcf42 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts
@@ -186,3 +186,47 @@ test("keeps Desktop service bindings outside the shared profile catalog", async
     true,
   );
 });
+
+test("persists a WSL deployment through its environment control route", async () => {
+  const root = await mkdtemp(join(tmpdir(), "maka-managed-wsl-deployment-"));
+  roots.push(root);
+  const store = createDesktopRuntimeHostManagedServiceStore(root);
+  const environment = {
+    id: "ubuntu",
+    name: "Ubuntu",
+    kind: "environment" as const,
+    provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" },
+    rootId: "a".repeat(64),
+    operatorPath: "/home/operator/.local/share/maka/operator",
+  };
+  await store.save(environment, {
+    deployment: {
+      id: environment.rootId,
+      rootPath: "/home/operator/.config/Maka/workspaces/default",
+      deploymentId,
+    },
+    control: {
+      kind: "wsl_operator",
+      distribution: environment.provider.distribution,
+      operatorPath: environment.operatorPath,
+    },
+  });
+
+  assert.deepEqual(
+    findDesktopRuntimeHostManagedServiceBinding(await store.read(), environment),
+    {
+      profile: environment,
+      deployment: {
+        id: environment.rootId,
+        rootPath: "/home/operator/.config/Maka/workspaces/default",
+        deploymentId,
+      },
+      control: {
+        kind: "wsl_operator",
+        distribution: "Ubuntu-24.04",
+        operatorPath: environment.operatorPath,
+      },
+      state: "active",
+    },
+  );
+});
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 053647b292..46d44c9d1e 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -34,6 +34,7 @@ import type {
   DesktopRuntimeHostSshUpdatePolicyInput,
   DesktopRuntimeHostSshUpdateReconciliationInput,
 } from '../runtime-host-ssh-terminal.js';
+import type { DesktopRuntimeHostWslManagementInput } from '../runtime-host-wsl-controller.js';
 
 const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111';
 
@@ -88,6 +89,105 @@ test('requires explicit interruption authority before a provider restarts active
   );
 });
 
+test('routes WSL status and directory configuration through the persisted operator route', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  const calls: DesktopRuntimeHostWslManagementInput[] = [];
+  const profile = {
+    id: 'ubuntu',
+    name: 'Ubuntu',
+    kind: 'environment' as const,
+    provider: { kind: 'wsl' as const, distribution: 'Ubuntu-24.04' },
+    rootId: 'a'.repeat(64),
+    operatorPath: '/home/operator/.local/share/maka/operator',
+  };
+  const binding = {
+    profile,
+    deployment: {
+      id: 'a'.repeat(64),
+      rootPath: '/home/operator/.config/Maka/workspaces/default',
+      deploymentId: DEPLOYMENT_ID,
+    },
+    control: {
+      kind: 'wsl_operator' as const,
+      distribution: 'Ubuntu-24.04',
+      operatorPath: profile.operatorPath,
+    },
+    state: 'active' as const,
+  };
+  let reconnects = 0;
+  createDesktopRuntimeHostManagement({
+    ...unusedUpdateDependencies(),
+    ipcMain: {
+      handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+      removeHandler: (channel) => handlers.delete(channel),
+    },
+    profiles: {
+      ...unusedDirectPeerProfileDependencies(),
+      resolveManagedService: async () => binding,
+      resolveManagedAccess: async () => undefined,
+      rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
+      markManagedServiceUninstalling: async () => assert.fail('uninstall is not expected'),
+      markManagedServiceCleanupPending: async () => assert.fail('uninstall is not expected'),
+      clearManagedServiceBinding: async () => assert.fail('uninstall is not expected'),
+    },
+    runServiceManagement: async () => assert.fail('WSL must not use SSH management'),
+    runWslManagement: async (input) => {
+      calls.push(input);
+      return serviceResult(input.action);
+    },
+    runAccessManagement: async () => assert.fail('access management is not expected'),
+    cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
+    currentHostEpoch: () => 'before-configure',
+    awaitUpdatedConnection: async () => {
+      reconnects += 1;
+    },
+  });
+
+  const run = handlers.get('runtime-host-management:run');
+  const configure = handlers.get('runtime-host-management:configure-project-directories');
+  assert.ok(run);
+  assert.ok(configure);
+  await run({}, profile.id, 'status');
+  await configure(
+    {},
+    profile.id,
+    [{ label: 'Work', path: '/srv/work' }],
+    `sha256:${'b'.repeat(64)}`,
+    false,
+  );
+
+  assert.deepEqual(calls.map(({ action, distribution, operatorPath, expectedTarget }) => ({
+    action,
+    distribution,
+    operatorPath,
+    expectedTarget,
+  })), [
+    {
+      action: 'status',
+      distribution: 'Ubuntu-24.04',
+      operatorPath: profile.operatorPath,
+      expectedTarget: {
+        serviceId: 'a'.repeat(64),
+        rootPath: '/home/operator/.config/Maka/workspaces/default',
+        rootId: 'a'.repeat(64),
+        deploymentId: DEPLOYMENT_ID,
+      },
+    },
+    {
+      action: 'configure',
+      distribution: 'Ubuntu-24.04',
+      operatorPath: profile.operatorPath,
+      expectedTarget: {
+        serviceId: 'a'.repeat(64),
+        rootPath: '/home/operator/.config/Maka/workspaces/default',
+        rootId: 'a'.repeat(64),
+        deploymentId: DEPLOYMENT_ID,
+      },
+    },
+  ]);
+  assert.equal(reconnects, 0);
+});
+
 test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   const profile = {
@@ -450,6 +550,7 @@ test('publishes update progress and waits for the managed profile to reconnect',
       clearManagedServiceBinding: async () => undefined,
     },
     runServiceManagement: async () => assert.fail('ordinary management is not expected'),
+    runWslManagement: async () => assert.fail('WSL management is not expected'),
     runPeerManagement: async () => assert.fail('direct peer management is not expected'),
     directPeerClientAvailable: false,
     runUpdate: async (input, onProgress) => {
@@ -1224,6 +1325,8 @@ function serviceSummary(installedVersion: string) {
 
 function unusedUpdateDependencies() {
   return {
+    runWslManagement: async (): Promise<never> =>
+      assert.fail('WSL management is not expected'),
     runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
     runUpdatePolicy: async (): Promise<never> => assert.fail('update policy is not expected'),
     runUpdateReconciliation: async (): Promise<never> =>
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 c40b096d21..220a507e38 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
@@ -93,11 +93,19 @@ test('persists a verified on-demand SSH profile without endpoint or credential p
 });
 
 test('onboards WSL as a credential-free environment profile', async () => {
-  let saved: DesktopRuntimeHostProfileAddInput | undefined;
+  let saved:
+    | {
+        readonly profile: Extract<
+          DesktopRuntimeHostProfileAddInput['profile'],
+          { kind: 'environment' }
+        >;
+        readonly managedService: DesktopRuntimeHostManagedServiceTarget;
+      }
+    | undefined;
   const peerTargets: string[] = [];
   const harness = createHarness({
     profiles: {
-      addAndEnable: async (input) => {
+      addManagedEnvironmentAndEnable: async (input) => {
         saved = input;
         return { kind: 'connected', snapshot: { entries: [], defaultProfileId: 'local' } };
       },
@@ -105,6 +113,9 @@ test('onboards WSL as a credential-free environment profile', async () => {
     runWslSetup: async (_input, _onProgress, onComplete) => {
       onComplete();
       return {
+        serviceId: 'a'.repeat(64),
+        deploymentId: '00000000-0000-4000-8000-000000000001',
+        rootPath: '/home/operator/.config/Maka/workspaces/default',
         rootId: 'a'.repeat(64),
         operatorPath: '/home/operator/.local/share/maka/operator',
       };
@@ -121,7 +132,6 @@ test('onboards WSL as a credential-free environment profile', async () => {
   });
 
   assert.equal((result as { kind?: string }).kind, 'complete');
-  assert.equal(saved?.credential, undefined);
   assert.deepEqual(saved?.profile, {
     id: saved?.profile.id,
     name: 'Ubuntu-24.04',
@@ -130,6 +140,18 @@ test('onboards WSL as a credential-free environment profile', async () => {
     rootId: 'a'.repeat(64),
     operatorPath: '/home/operator/.local/share/maka/operator',
   });
+  assert.deepEqual(saved?.managedService, {
+    deployment: {
+      id: 'a'.repeat(64),
+      rootPath: '/home/operator/.config/Maka/workspaces/default',
+      deploymentId: '00000000-0000-4000-8000-000000000001',
+    },
+    control: {
+      kind: 'wsl_operator',
+      distribution: 'Ubuntu-24.04',
+      operatorPath: '/home/operator/.local/share/maka/operator',
+    },
+  });
   assert.deepEqual(peerTargets, ['none']);
   await harness.onboarding.close();
 });
@@ -296,7 +318,7 @@ function createHarness(overrides: HarnessOverrides = {}) {
   const onboarding = createDesktopRuntimeHostOnboarding({
     clientInstanceId: 'stable-client',
     profiles: {
-      addAndEnable: async () => assert.fail('profile must not be saved'),
+      addManagedEnvironmentAndEnable: async () => assert.fail('profile must not be saved'),
       addAndEnableVerified: async () => assert.fail('profile must not be saved'),
       ...profiles,
     },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
index 42fbc75a09..9e76469949 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts
@@ -25,10 +25,86 @@ import { PassThrough } from 'node:stream';
 import test from 'node:test';
 import type { RuntimeHostWslProcessFactory } from '@maka/runtime-host/client';
 import {
+  encodeRuntimeHostServiceManagementFrame,
   encodeRuntimeHostSetupFrame,
+  RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
   RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
 } from '@maka/runtime-host/operator';
-import { runDesktopRuntimeHostWslSetup } from '../runtime-host-wsl-controller.js';
+import {
+  runDesktopRuntimeHostWslManagement,
+  runDesktopRuntimeHostWslSetup,
+} from '../runtime-host-wsl-controller.js';
+
+test('WSL management invokes the stable operator directly with the exact deployment target', async () => {
+  let launch:
+    | { readonly executable: string; readonly args: readonly string[]; readonly environment: NodeJS.ProcessEnv }
+    | undefined;
+  const frame = encodeRuntimeHostServiceManagementFrame({
+    schemaVersion: 1,
+    kind: 'result',
+    action: 'configure',
+    service: {
+      platform: 'linux',
+      arch: 'x64',
+      osRelease: '6.8.0',
+      state: 'running',
+      pid: 42,
+      lastExitCode: 0,
+      installedVersion: '0.2.0',
+      configurationFingerprint: `sha256:${'c'.repeat(64)}`,
+      projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }],
+    },
+    configuration: { kind: 'configured' },
+  });
+  await runDesktopRuntimeHostWslManagement({
+    distribution: 'Ubuntu',
+    operatorPath: '/home/operator/.local/share/maka/operator',
+    action: 'configure',
+    expectedTarget: {
+      serviceId: 'a'.repeat(64),
+      rootPath: '/home/operator/.config/Maka/workspaces/default',
+      rootId: 'a'.repeat(64),
+      deploymentId: '00000000-0000-4000-8000-000000000001',
+    },
+    projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }],
+    expectedConfigFingerprint: `sha256:${'b'.repeat(64)}`,
+  }, {
+    wslExecutable: 'wsl.exe',
+    processFactory: (executable, args, environment) => {
+      launch = { executable, args: [...args], environment };
+      const child = new EventEmitter() as ChildProcessWithoutNullStreams;
+      const stdin = new PassThrough();
+      const stdout = new PassThrough();
+      const stderr = new PassThrough();
+      Object.assign(child, { stdin, stdout, stderr, kill: () => true });
+      process.nextTick(() => {
+        stdout.end(frame);
+        stderr.end();
+        child.emit('close', 0, null);
+      });
+      return child;
+    },
+  });
+
+  assert.equal(launch?.executable, 'wsl.exe');
+  assert.deepEqual(launch?.args.slice(0, 5), [
+    '--distribution',
+    'Ubuntu',
+    '--exec',
+    '/home/operator/.local/share/maka/operator',
+    'configure',
+  ]);
+  assert.ok(launch?.args.includes('--expected-deployment-id'));
+  assert.equal(
+    launch?.environment[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV],
+    '1',
+  );
+  assert.ok(
+    launch?.environment.WSLENV?.split(':').includes(
+      RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+    ),
+  );
+});
 
 test('WSL setup forwards the development archive and its exact evidence', async () => {
   const launches: string[][] = [];
diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
index 0988848d20..1e32f93f0c 100644
--- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts
@@ -108,7 +108,10 @@ function deferred<T>() {
 let latestController: TaskEntryController | undefined;
 
 function ControllerProbe(props: { reportError(error: unknown): void }) {
-  latestController = useTaskEntryController({ reportError: props.reportError });
+  latestController = useTaskEntryController({
+    reportError: props.reportError,
+    manageProjects() {},
+  });
   return null;
 }
 
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index a4d554b222..d79b40dba1 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -176,7 +176,10 @@ import {
 import {
   createDesktopRuntimeHostSshTerminal,
 } from "./runtime-host-ssh-terminal.js";
-import { runDesktopRuntimeHostWslSetup } from './runtime-host-wsl-controller.js';
+import {
+  runDesktopRuntimeHostWslManagement,
+  runDesktopRuntimeHostWslSetup,
+} from './runtime-host-wsl-controller.js';
 import {
   createRuntimeHostSetupPackageResolver,
   desktopRuntimeHostDevelopmentPeerTarget,
@@ -539,6 +542,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
   ipcMain,
   profiles: runtimeHostProfileService,
   runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
+  runWslManagement: runDesktopRuntimeHostWslManagement,
   runPeerManagement: runtimeHostSshTerminal.runPeerManagement,
   directPeerClientAvailable: runtimeHostDirectPeerAvailable,
   runUpdate: runtimeHostSshTerminal.runUpdate,
diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts
index 2c9202543d..eed5e99508 100644
--- a/apps/desktop/src/main/runtime-host-managed-services.ts
+++ b/apps/desktop/src/main/runtime-host-managed-services.ts
@@ -21,9 +21,9 @@ import { randomUUID } from "node:crypto";
 import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
 import { dirname, join } from "node:path";
 import {
-  decodeRemoteRuntimeHostProfile,
-  sameRemoteRuntimeHostProfileTarget,
-  type RemoteRuntimeHostProfile,
+  decodePersistedRuntimeHostProfile,
+  sameResolvedRuntimeHostProfileTarget,
+  type PersistedRuntimeHostProfile,
 } from "@maka/runtime-host/client";
 import { requireHostRootId } from "@maka/runtime-host/protocol";
 import { withFileUpdateLock } from "@maka/storage/file-update-lock";
@@ -40,10 +40,16 @@ export interface DesktopRuntimeHostDeploymentBinding {
   readonly deploymentId?: string;
 }
 
-export interface DesktopRuntimeHostControlRoute {
-  readonly kind: "ssh_operator";
-  readonly operatorPath: string;
-}
+export type DesktopRuntimeHostControlRoute =
+  | {
+      readonly kind: "ssh_operator";
+      readonly operatorPath: string;
+    }
+  | {
+      readonly kind: "wsl_operator";
+      readonly distribution: string;
+      readonly operatorPath: string;
+    };
 
 export interface DesktopRuntimeHostManagedServiceTarget {
   readonly deployment: DesktopRuntimeHostDeploymentBinding;
@@ -51,7 +57,7 @@ export interface DesktopRuntimeHostManagedServiceTarget {
 }
 
 export interface DesktopRuntimeHostManagedServiceBinding {
-  readonly profile: RemoteRuntimeHostProfile;
+  readonly profile: PersistedRuntimeHostProfile;
   readonly deployment: DesktopRuntimeHostDeploymentBinding;
   readonly control: DesktopRuntimeHostControlRoute;
   readonly state: "active" | "uninstalling" | "cleanup_pending";
@@ -65,14 +71,14 @@ export interface DesktopRuntimeHostManagedServiceDocument {
 export interface DesktopRuntimeHostManagedServiceStore {
   read(): Promise<DesktopRuntimeHostManagedServiceDocument>;
   save(
-    profile: RemoteRuntimeHostProfile,
+    profile: PersistedRuntimeHostProfile,
     target: DesktopRuntimeHostManagedServiceTarget,
   ): Promise<void>;
   removeIfCurrent(
     binding: DesktopRuntimeHostManagedServiceBinding,
   ): Promise<boolean>;
   removeForProfileIfCurrent(
-    profile: RemoteRuntimeHostProfile,
+    profile: PersistedRuntimeHostProfile,
   ): Promise<boolean>;
   markUninstallingIfCurrent(
     binding: DesktopRuntimeHostManagedServiceBinding,
@@ -96,12 +102,12 @@ export function createDesktopRuntimeHostManagedServiceStore(
 
 export function findDesktopRuntimeHostManagedServiceBinding(
   document: DesktopRuntimeHostManagedServiceDocument,
-  profile: RemoteRuntimeHostProfile,
+  profile: PersistedRuntimeHostProfile,
 ): DesktopRuntimeHostManagedServiceBinding | undefined {
   const binding = document.bindings.find(
     (candidate) => candidate.profile.id === profile.id,
   );
-  return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile)
+  return binding && sameManagedProfileTarget(binding.profile, profile)
     ? binding
     : undefined;
 }
@@ -113,7 +119,7 @@ export function sameDesktopRuntimeHostManagedServiceBinding(
   return (
     left.state === right.state &&
     left.profile.id === right.profile.id &&
-    sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) &&
+    sameManagedProfileTarget(left.profile, right.profile) &&
     sameBindingTarget(left, right)
   );
 }
@@ -158,17 +164,13 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
   }
 
   save(
-    value: RemoteRuntimeHostProfile,
+    value: PersistedRuntimeHostProfile,
     managedTarget: DesktopRuntimeHostManagedServiceTarget,
   ): Promise<void> {
-    const profile = decodeRemoteRuntimeHostProfile(value);
-    if (profile.transport.kind !== "ssh") {
-      return Promise.reject(
-        new Error("A managed Runtime Host service requires SSH"),
-      );
-    }
+    const profile = decodePersistedRuntimeHostProfile(value);
     const deployment = decodeDeployment(managedTarget.deployment);
     const control = decodeControlRoute(managedTarget.control);
+    assertControlRouteMatchesProfile(profile, control);
     return this.#exclusive(async () => {
       const current = await this.#readUnlocked();
       const bindings = current.bindings.filter(
@@ -224,14 +226,14 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
     return this.#remove(binding);
   }
 
-  removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise<boolean> {
-    return this.#remove(undefined, undefined, decodeRemoteRuntimeHostProfile(value));
+  removeForProfileIfCurrent(value: PersistedRuntimeHostProfile): Promise<boolean> {
+    return this.#remove(undefined, undefined, decodePersistedRuntimeHostProfile(value));
   }
 
   #remove(
     expected?: DesktopRuntimeHostManagedServiceBinding,
     state?: DesktopRuntimeHostManagedServiceBinding["state"],
-    profileOverride?: RemoteRuntimeHostProfile,
+    profileOverride?: PersistedRuntimeHostProfile,
   ): Promise<boolean> {
     const profile = expected?.profile ?? profileOverride!;
     return this.#exclusive(async () => {
@@ -241,7 +243,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
       );
       if (
         !binding ||
-        !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
+        !sameManagedProfileTarget(binding.profile, profile) ||
         (expected && !sameBindingTarget(binding, expected)) ||
         (state && binding.state !== state)
       ) {
@@ -270,7 +272,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan
       );
       if (
         !binding ||
-        !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
+        !sameManagedProfileTarget(binding.profile, profile) ||
         !sameBindingTarget(binding, expected) ||
         !allowedStates.includes(binding.state)
       ) {
@@ -318,10 +320,9 @@ function decodeDocument(
       "Runtime Host managed service binding",
       ["control", "deployment", "profile", "state"],
     );
-    const profile = decodeRemoteRuntimeHostProfile(binding.profile);
-    if (profile.transport.kind !== "ssh") {
-      throw new Error("A managed Runtime Host service requires SSH");
-    }
+    const profile = decodePersistedRuntimeHostProfile(binding.profile);
+    const control = decodeControlRoute(binding.control);
+    assertControlRouteMatchesProfile(profile, control);
     if (
       binding.state !== "active" &&
       binding.state !== "uninstalling" &&
@@ -332,7 +333,7 @@ function decodeDocument(
     return Object.freeze({
       profile,
       deployment: decodeDeployment(binding.deployment),
-      control: decodeControlRoute(binding.control),
+      control,
       state: binding.state,
     });
   });
@@ -401,12 +402,16 @@ function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding {
 }
 
 function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute {
+  const candidate = value as { readonly kind?: unknown } | null;
+  const kind = candidate?.kind;
   const record = requireExactRecord(
     value,
     "Managed Runtime Host control route",
-    ["kind", "operatorPath"],
+    kind === "wsl_operator"
+      ? ["distribution", "kind", "operatorPath"]
+      : ["kind", "operatorPath"],
   );
-  if (record.kind !== "ssh_operator") {
+  if (record.kind !== "ssh_operator" && record.kind !== "wsl_operator") {
     throw new Error("Managed Runtime Host control route is invalid");
   }
   const operatorPath = requirePath(
@@ -416,7 +421,23 @@ function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute {
   if (!operatorPath.startsWith("/")) {
     throw new Error("Managed Runtime Host operator path must be absolute");
   }
-  return Object.freeze({ kind: "ssh_operator", operatorPath });
+  if (record.kind === "ssh_operator") {
+    return Object.freeze({ kind: "ssh_operator", operatorPath });
+  }
+  if (
+    typeof record.distribution !== "string" ||
+    record.distribution.length === 0 ||
+    record.distribution.length > 128 ||
+    record.distribution.trim() !== record.distribution ||
+    /[\u0000-\u001f\u007f]/u.test(record.distribution)
+  ) {
+    throw new Error("Managed WSL distribution is invalid");
+  }
+  return Object.freeze({
+    kind: "wsl_operator",
+    distribution: record.distribution,
+    operatorPath,
+  });
 }
 
 function decodeLegacyService(value: unknown): {
@@ -500,10 +521,39 @@ function sameBindingTarget(
     left.deployment.rootPath === right.deployment.rootPath &&
     left.deployment.deploymentId === right.deployment.deploymentId &&
     left.control.kind === right.control.kind &&
-    left.control.operatorPath === right.control.operatorPath
+    left.control.operatorPath === right.control.operatorPath &&
+    (left.control.kind !== "wsl_operator" ||
+      (right.control.kind === "wsl_operator" &&
+        left.control.distribution === right.control.distribution))
+  );
+}
+
+function sameManagedProfileTarget(
+  left: PersistedRuntimeHostProfile,
+  right: PersistedRuntimeHostProfile,
+): boolean {
+  return (
+    left.id === right.id &&
+    sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right })
   );
 }
 
+function assertControlRouteMatchesProfile(
+  profile: PersistedRuntimeHostProfile,
+  control: DesktopRuntimeHostControlRoute,
+): void {
+  if (
+    control.kind === "ssh_operator"
+      ? profile.kind !== "remote" || profile.transport.kind !== "ssh"
+      : profile.kind !== "environment" ||
+        profile.provider.kind !== "wsl" ||
+        profile.provider.distribution !== control.distribution ||
+        profile.operatorPath !== control.operatorPath
+  ) {
+    throw new Error("Managed Runtime Host control route does not match its profile");
+  }
+}
+
 function emptyDocument(): DesktopRuntimeHostManagedServiceDocument {
   return Object.freeze({
     schemaVersion: SCHEMA_VERSION,
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index 9381767178..8b83a6b0ed 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -61,6 +61,7 @@ import type {
   DesktopRuntimeHostManagementProvider,
   DesktopRuntimeHostManagementTerminalFrame,
 } from './runtime-host-management-provider.js';
+import type { DesktopRuntimeHostWslManagementInput } from './runtime-host-wsl-controller.js';
 
 const MANAGEMENT_ACTIONS = new Set<DesktopRuntimeHostManagementAction>([
   'status',
@@ -94,6 +95,9 @@ export function createDesktopRuntimeHostManagement(input: {
   readonly runServiceManagement: (
     input: DesktopRuntimeHostSshManagementInput,
   ) => Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
+  readonly runWslManagement: (
+    input: DesktopRuntimeHostWslManagementInput,
+  ) => Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
   readonly runAccessManagement: (
     input: DesktopRuntimeHostSshAccessInput,
   ) => Promise<RuntimeHostAccessManagementFrame>;
@@ -176,9 +180,6 @@ export function createDesktopRuntimeHostManagement(input: {
   ): Promise<DesktopRuntimeHostManagementResponse> => {
     const managed = await resolveManagedService(profileId);
     const { profile, deployment, control } = managed;
-    if (profile.transport.kind !== 'ssh') {
-      throw new Error('This Runtime Host profile is not bound to a managed service');
-    }
     if (managed.state !== 'active' && managementAction !== 'uninstall') {
       throw new Error('Finish uninstalling this Runtime Host service before managing it');
     }
@@ -192,17 +193,41 @@ export function createDesktopRuntimeHostManagement(input: {
         'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation',
       );
     }
+    const expectedTarget = {
+      serviceId: deployment.id,
+      rootPath: deployment.rootPath,
+      rootId: profile.rootId,
+      ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}),
+    };
+    if (control.kind === 'wsl_operator') {
+      if (
+        profile.kind !== 'environment' ||
+        profile.provider.kind !== 'wsl' ||
+        profile.provider.distribution !== control.distribution ||
+        profile.operatorPath !== control.operatorPath
+      ) {
+        throw new Error('This Runtime Host profile has an invalid WSL management route');
+      }
+      if (managementAction !== 'status') {
+        throw new Error('This WSL Runtime Host management action is not available');
+      }
+      const response = await input.runWslManagement({
+        distribution: control.distribution,
+        operatorPath: control.operatorPath,
+        action: managementAction,
+        expectedTarget,
+      });
+      return projectManagementFrame(response, false);
+    }
+    if (profile.kind !== 'remote' || profile.transport.kind !== 'ssh') {
+      throw new Error('This Runtime Host profile has an invalid SSH management route');
+    }
     const managementInput: DesktopRuntimeHostSshManagementInput = {
       destination: profile.transport.destination,
       ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }),
       operatorPath: control.operatorPath,
       action: managementAction,
-      expectedTarget: {
-        serviceId: deployment.id,
-        rootPath: deployment.rootPath,
-        rootId: profile.rootId,
-        ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}),
-      },
+      expectedTarget,
       ...(managementAction === 'install'
         ? {
             rootPath: deployment.rootPath,
@@ -324,7 +349,11 @@ export function createDesktopRuntimeHostManagement(input: {
     if (managed.state !== 'active') {
       throw new Error('Finish uninstalling this Runtime Host service before managing access');
     }
-    if (managed.profile.transport.kind !== 'ssh') {
+    if (
+      managed.profile.kind !== 'remote' ||
+      managed.profile.transport.kind !== 'ssh' ||
+      managed.control.kind !== 'ssh_operator'
+    ) {
       throw new Error('This Runtime Host profile does not have an SSH management channel');
     }
     return {
@@ -347,8 +376,12 @@ export function createDesktopRuntimeHostManagement(input: {
     const profileId = requireProfileId(profileIdValue);
     input.profiles.assertPairingComplete(profileId);
     const managed = await resolveManagedService(profileId);
-    const transport = managed.profile.transport;
-    if (managed.state !== 'active' || transport.kind !== 'ssh') {
+    if (
+      managed.state !== 'active' ||
+      managed.profile.kind !== 'remote' ||
+      managed.profile.transport.kind !== 'ssh' ||
+      managed.control.kind !== 'ssh_operator'
+    ) {
       throw new Error('This Runtime Host profile is not available for managed service changes');
     }
     if (!managed.deployment.deploymentId) {
@@ -359,7 +392,7 @@ export function createDesktopRuntimeHostManagement(input: {
     return {
       profileId,
       managed,
-      transport,
+      transport: managed.profile.transport,
       expectedTarget: {
         serviceId: managed.deployment.id,
         rootPath: managed.deployment.rootPath,
@@ -643,18 +676,57 @@ export function createDesktopRuntimeHostManagement(input: {
         );
       reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true);
     } else {
-      const { managed, transport, expectedTarget } = await managedMutationTarget(profileId);
+      const managed = await resolveManagedService(profileId);
+      if (managed.state !== 'active') {
+        throw new Error('This Runtime Host profile is not available for managed service changes');
+      }
+      if (!managed.deployment.deploymentId) {
+        throw new Error(
+          'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation',
+        );
+      }
+      const expectedTarget = {
+        serviceId: managed.deployment.id,
+        rootPath: managed.deployment.rootPath,
+        rootId: managed.profile.rootId,
+        deploymentId: managed.deployment.deploymentId,
+      };
       const previousHostEpoch = input.currentHostEpoch(profileId);
-      execute = () => input.runServiceManagement({
-        destination: transport.destination,
-        ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }),
-        operatorPath: managed.control.operatorPath,
-        action: 'configure',
-        expectedTarget,
-        projectDirectoryRoots: roots,
-        expectedConfigFingerprint: expectedConfigFingerprintValue,
-        ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
-      });
+      if (managed.control.kind === 'wsl_operator') {
+        const control = managed.control;
+        if (
+          managed.profile.kind !== 'environment' ||
+          managed.profile.provider.kind !== 'wsl' ||
+          managed.profile.provider.distribution !== control.distribution ||
+          managed.profile.operatorPath !== control.operatorPath
+        ) {
+          throw new Error('This Runtime Host profile has an invalid WSL management route');
+        }
+        execute = () => input.runWslManagement({
+          distribution: control.distribution,
+          operatorPath: control.operatorPath,
+          action: 'configure',
+          expectedTarget,
+          projectDirectoryRoots: roots,
+          expectedConfigFingerprint: expectedConfigFingerprintValue,
+          ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+        });
+      } else {
+        if (managed.profile.kind !== 'remote' || managed.profile.transport.kind !== 'ssh') {
+          throw new Error('This Runtime Host profile has an invalid SSH management route');
+        }
+        const transport = managed.profile.transport;
+        execute = () => input.runServiceManagement({
+          destination: transport.destination,
+          ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }),
+          operatorPath: managed.control.operatorPath,
+          action: 'configure',
+          expectedTarget,
+          projectDirectoryRoots: roots,
+          expectedConfigFingerprint: expectedConfigFingerprintValue,
+          ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+        });
+      }
       reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch);
     }
     const response = requireManagementFrame(await execute(), 'configure');
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index 0311ac536a..ab940f2af0 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -45,7 +45,10 @@ type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapsh
 export function createDesktopRuntimeHostOnboarding(input: {
   readonly ipcMain: Pick<IpcMain, 'handle' | 'removeHandler'>;
   readonly clientInstanceId: string;
-  readonly profiles: Pick<DesktopRuntimeHostProfileService, 'addAndEnable' | 'addAndEnableVerified'>;
+  readonly profiles: Pick<
+    DesktopRuntimeHostProfileService,
+    'addManagedEnvironmentAndEnable' | 'addAndEnableVerified'
+  >;
   readonly runSetup: (
     input: DesktopRuntimeHostSshSetupInput,
     onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void,
@@ -63,7 +66,13 @@ export function createDesktopRuntimeHostOnboarding(input: {
     input: DesktopRuntimeHostWslSetupInput,
     onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void,
     onComplete: () => void,
-  ) => Promise<{ readonly rootId: string; readonly operatorPath: string }>;
+  ) => Promise<{
+    readonly rootId: string;
+    readonly rootPath: string;
+    readonly serviceId: string;
+    readonly deploymentId: string;
+    readonly operatorPath: string;
+  }>;
   readonly listWslDistributions: () => Promise<readonly string[]>;
   readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void;
   readonly setupPackageMode: 'published' | 'development';
@@ -261,14 +270,27 @@ export function createDesktopRuntimeHostOnboarding(input: {
     );
     beginCommit();
     const profileId = `environment-${randomUUID()}`;
-    const connected = await input.profiles.addAndEnable({
-      profile: {
-        id: profileId,
-        name: request.name?.trim() || request.distribution,
-        kind: 'environment',
-        provider: { kind: 'wsl', distribution: request.distribution },
-        rootId: complete.rootId,
-        operatorPath: complete.operatorPath,
+    const profile = {
+      id: profileId,
+      name: request.name?.trim() || request.distribution,
+      kind: 'environment' as const,
+      provider: { kind: 'wsl' as const, distribution: request.distribution },
+      rootId: complete.rootId,
+      operatorPath: complete.operatorPath,
+    };
+    const connected = await input.profiles.addManagedEnvironmentAndEnable({
+      profile,
+      managedService: {
+        deployment: {
+          id: complete.serviceId,
+          rootPath: complete.rootPath,
+          deploymentId: complete.deploymentId,
+        },
+        control: {
+          kind: 'wsl_operator',
+          distribution: request.distribution,
+          operatorPath: complete.operatorPath,
+        },
       },
     });
     if (connected.kind === 'unavailable') throw new Error(connected.message);
diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts
index 7c6a24f95f..200088d86f 100644
--- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts
+++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts
@@ -133,7 +133,9 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: {
     if (
       !managed ||
       managed.state !== 'active' ||
+      managed.profile.kind !== 'remote' ||
       managed.profile.transport.kind !== 'ssh' ||
+      managed.control.kind !== 'ssh_operator' ||
       !managed.deployment.deploymentId
     ) {
       throw new Error('This Runtime Host does not have an active SSH management channel');
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index c6b42484c1..64bc4b2a6f 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -23,6 +23,7 @@ import { dirname, join } from "node:path";
 import {
   createClientRuntimeHostCredentialStore,
   createClientRuntimeHostProfileCatalog,
+  decodeEnvironmentRuntimeHostProfile,
   decodeRuntimeHostOwnerConnectionCode,
   LOCAL_RUNTIME_HOST_PROFILE,
   RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
@@ -31,6 +32,7 @@ import {
   RuntimeHostRemoteCompatibilityError,
   sameRemoteRuntimeHostProfileTarget,
   sameResolvedRuntimeHostProfileTarget,
+  type EnvironmentRuntimeHostProfile,
   type PersistedRuntimeHostProfile,
   type RemoteRuntimeHostProfile,
   type ResolvedRuntimeHostProfile,
@@ -95,6 +97,10 @@ export interface DesktopRuntimeHostProfileService {
   addAndEnable(
     input: DesktopRuntimeHostProfileAddInput,
   ): Promise<DesktopRuntimeHostProfileAddResult>;
+  addManagedEnvironmentAndEnable(input: {
+    readonly profile: EnvironmentRuntimeHostProfile;
+    readonly managedService: DesktopRuntimeHostManagedServiceTarget;
+  }): Promise<DesktopRuntimeHostProfileAddResult>;
   addAndEnableVerified(
     input: {
       readonly profile: RemoteRuntimeHostProfile;
@@ -366,7 +372,7 @@ export function createDesktopRuntimeHostProfileService(input: {
           : unavailable.get(profile.id);
         return {
           profile,
-          ...(profile.kind === "remote" &&
+          ...(profile.kind !== "local" &&
           findDesktopRuntimeHostManagedServiceBinding(managedDocument, profile)
             ? { managedService: true as const }
             : {}),
@@ -768,6 +774,35 @@ export function createDesktopRuntimeHostProfileService(input: {
           : { kind: "connected", snapshot: await snapshot() };
       });
     },
+    addManagedEnvironmentAndEnable(value) {
+      const profile = decodeEnvironmentRuntimeHostProfile(value.profile);
+      return mutateProfiles(async () => {
+        await managedServices.save(profile, value.managedService);
+        try {
+          const document = await catalog.create(profile);
+          const persisted = document.profiles.find((candidate) => candidate.id === profile.id);
+          if (!persisted || persisted.kind !== 'environment') {
+            throw new Error("Runtime Host profile creation did not persist");
+          }
+          const target: ResolvedRuntimeHostProfile = { profile: persisted };
+          let error: Error | undefined;
+          try {
+            error = await enable(profile.id);
+          } catch (failure) {
+            await rollbackCreatedProfile(catalog, target, failure);
+            throw failure;
+          }
+          return error
+            ? { kind: "unavailable", snapshot: await snapshot(), message: error.message }
+            : { kind: "connected", snapshot: await snapshot() };
+        } catch (failure) {
+          await managedServices.removeForProfileIfCurrent(profile).catch((error) =>
+            console.error("[runtime-host] failed to roll back WSL deployment binding:", error),
+          );
+          throw failure;
+        }
+      });
+    },
     addAndEnableVerified,
     async importConnectionCode(code) {
       let decoded;
@@ -884,7 +919,6 @@ export function createDesktopRuntimeHostProfileService(input: {
           (candidate) => candidate.id === profileId,
         );
         if (!profile) return undefined;
-        if (profile.kind !== 'remote') return undefined;
         const binding = findDesktopRuntimeHostManagedServiceBinding(
           await managedServices.read(),
           profile,
@@ -1047,8 +1081,10 @@ export function createDesktopRuntimeHostProfileService(input: {
         );
         if (
           !current ||
-          current.kind !== 'remote' ||
-          !sameRemoteRuntimeHostProfileTarget(current, expected.profile)
+          !sameResolvedRuntimeHostProfileTarget(
+            { profile: current },
+            { profile: expected.profile },
+          )
         ) {
           throw new Error('Runtime Host managed service binding changed during uninstall');
         }
@@ -1075,8 +1111,10 @@ export function createDesktopRuntimeHostProfileService(input: {
         );
         if (
           !current ||
-          current.kind !== 'remote' ||
-          !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
+          !sameResolvedRuntimeHostProfileTarget(
+            { profile: current },
+            { profile: expected.profile },
+          ) ||
           !(await managedServices.markCleanupPendingIfCurrent(expected))
         ) {
           throw new Error('Runtime Host managed service binding changed during uninstall');
@@ -1092,8 +1130,10 @@ export function createDesktopRuntimeHostProfileService(input: {
         );
         if (
           !current ||
-          current.kind !== 'remote' ||
-          !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
+          !sameResolvedRuntimeHostProfileTarget(
+            { profile: current },
+            { profile: expected.profile },
+          ) ||
           !(await managedServices.removeCleanupPendingIfCurrent(expected))
         ) {
           throw new Error('Runtime Host managed service binding changed during uninstall');
@@ -1252,9 +1292,10 @@ export function createDesktopRuntimeHostProfileService(input: {
         ) {
           throw new Error('Disable and remove the Direct peer profile before removing its SSH profile');
         }
-        const managedBinding = profile.kind === 'remote'
-          ? findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)
-          : undefined;
+        const managedBinding = findDesktopRuntimeHostManagedServiceBinding(
+          await managedServices.read(),
+          profile,
+        );
         if (managedBinding && managedBinding.state !== 'active') {
           throw new Error('Finish uninstalling this Runtime Host service before removing it');
         }
diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts
index 80bc59a038..404eb8c093 100644
--- a/apps/desktop/src/main/runtime-host-wsl-controller.ts
+++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts
@@ -21,15 +21,20 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
 import type { Readable } from 'node:stream';
 import {
   normalizeRuntimeHostWslDistribution,
+  normalizeRuntimeHostWslOperatorPath,
   resolveSystemRuntimeHostWslExecutable,
   type RuntimeHostWslProcessFactory,
 } from '@maka/runtime-host/client';
 import {
   decodeRuntimeHostSetupFrame,
+  decodeRuntimeHostServiceManagementFrame,
+  RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+  RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
   RUNTIME_HOST_SETUP_FRAME_PREFIX,
   RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV,
   type RuntimeHostSetupFrame,
   type RuntimeHostSetupPhase,
+  type RuntimeHostServiceManagementFrame,
 } from '@maka/runtime-host/operator';
 import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js';
 import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js';
@@ -39,6 +44,140 @@ const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024;
 const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024;
 
 type RuntimeHostSetupCompleteFrame = Extract<RuntimeHostSetupFrame, { kind: 'complete' }>;
+type RuntimeHostManagementTerminalFrame = Exclude<
+  RuntimeHostServiceManagementFrame,
+  { readonly kind: 'progress' }
+>;
+
+export interface DesktopRuntimeHostWslManagementInput {
+  readonly distribution: string;
+  readonly operatorPath: string;
+  readonly action: 'status' | 'configure';
+  readonly expectedTarget: {
+    readonly serviceId: string;
+    readonly rootPath: string;
+    readonly rootId: string;
+    readonly deploymentId?: string;
+  };
+  readonly projectDirectoryRoots?: readonly {
+    readonly label: string;
+    readonly path: string;
+  }[];
+  readonly expectedConfigFingerprint?: string;
+  readonly allowInterruptActiveTasks?: boolean;
+  readonly signal?: AbortSignal;
+}
+
+type RuntimeHostWslManagementProcessFactory = (
+  executable: string,
+  args: readonly string[],
+  environment: NodeJS.ProcessEnv,
+) => ChildProcessWithoutNullStreams;
+
+export async function runDesktopRuntimeHostWslManagement(
+  input: DesktopRuntimeHostWslManagementInput,
+  overrides: {
+    readonly processFactory?: RuntimeHostWslManagementProcessFactory;
+    readonly wslExecutable?: string;
+  } = {},
+): Promise<RuntimeHostManagementTerminalFrame> {
+  input.signal?.throwIfAborted();
+  const distribution = normalizeRuntimeHostWslDistribution(input.distribution);
+  const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath);
+  const args = [
+    '--distribution',
+    distribution,
+    '--exec',
+    operatorPath,
+    input.action,
+    '--framed',
+    ...(input.projectDirectoryRoots === undefined
+      ? []
+      : input.projectDirectoryRoots.length === 0
+        ? ['--no-project-roots']
+        : input.projectDirectoryRoots.flatMap(({ label, path }) => [
+            '--project-root-json',
+            JSON.stringify({ label, path }),
+          ])),
+    ...(input.expectedConfigFingerprint
+      ? ['--expected-config-fingerprint', input.expectedConfigFingerprint]
+      : []),
+    ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
+    '--expected-service-id',
+    input.expectedTarget.serviceId,
+    '--expected-root-path',
+    input.expectedTarget.rootPath,
+    '--expected-root-id',
+    input.expectedTarget.rootId,
+    ...(input.expectedTarget.deploymentId
+      ? ['--expected-deployment-id', input.expectedTarget.deploymentId]
+      : []),
+  ];
+  const environment = passEnvironmentToWsl(
+    process.env,
+    RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
+    '1',
+  );
+  const child = (overrides.processFactory ?? spawnWslManagement)(
+    overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(),
+    args,
+    environment,
+  );
+  const abort = () => child.kill();
+  input.signal?.addEventListener('abort', abort, { once: true });
+  if (input.signal?.aborted) abort();
+  child.stdin.end();
+  let result: RuntimeHostManagementTerminalFrame | undefined;
+  let failure: Error | undefined;
+  const filter = createRuntimeHostFramedOutputFilter({
+    prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
+    pendingMaxBytes: WSL_SETUP_OUTPUT_MAX_BYTES,
+    decode: decodeRuntimeHostServiceManagementFrame,
+    label: 'WSL Runtime Host management',
+    onFrame: (frame) => {
+      if (frame.kind === 'progress') {
+        failure = new Error('WSL Runtime Host management returned unexpected progress');
+      } else if (result) {
+        failure = new Error('WSL Runtime Host management returned multiple results');
+      } else {
+        result = frame;
+      }
+    },
+    onError: (error) => {
+      failure = error;
+    },
+  });
+  let outputBytes = 0;
+  child.stdout.on('data', (value: Buffer | string) => {
+    const chunk = typeof value === 'string' ? Buffer.from(value) : value;
+    outputBytes += chunk.byteLength;
+    if (outputBytes > WSL_SETUP_OUTPUT_MAX_BYTES) {
+      failure = new Error('WSL Runtime Host management output exceeded its byte limit');
+      child.kill();
+      return;
+    }
+    filter.push(chunk.toString('utf8'));
+  });
+  const stderr = collectBounded(child.stderr, WSL_SETUP_STDERR_MAX_BYTES);
+  const timeout = setTimeout(() => child.kill(), WSL_SETUP_TIMEOUT_MS);
+  const [exit, capturedStderr] = await Promise.all([childExit(child), stderr]).finally(() => {
+    clearTimeout(timeout);
+    input.signal?.removeEventListener('abort', abort);
+  });
+  filter.finish();
+  input.signal?.throwIfAborted();
+  if (failure) throw failure;
+  if (!result) {
+    const diagnostic = formatBoundedDiagnostic(capturedStderr);
+    throw new Error(
+      `WSL Runtime Host management exited with code ${String(exit.code)} without a result${diagnostic ? `: ${diagnostic}` : ''}`,
+    );
+  }
+  if (result.action !== input.action) {
+    throw new Error('WSL Runtime Host returned an unrelated management result');
+  }
+  return result;
+}
 
 export interface DesktopRuntimeHostWslSetupInput {
   readonly distribution: string;
@@ -207,6 +346,33 @@ function spawnWsl(executable: string, args: readonly string[]) {
   });
 }
 
+function spawnWslManagement(
+  executable: string,
+  args: readonly string[],
+  environment: NodeJS.ProcessEnv,
+) {
+  return spawn(executable, args, {
+    shell: false,
+    windowsHide: true,
+    stdio: ['pipe', 'pipe', 'pipe'],
+    env: environment,
+  });
+}
+
+function passEnvironmentToWsl(
+  source: NodeJS.ProcessEnv,
+  name: string,
+  value: string,
+): NodeJS.ProcessEnv {
+  const existing = source.WSLENV?.split(':').filter(Boolean) ?? [];
+  const included = existing.some((entry) => entry.split('/')[0] === name);
+  return {
+    ...source,
+    [name]: value,
+    WSLENV: included ? existing.join(':') : [...existing, name].join(':'),
+  };
+}
+
 function childExit(child: ChildProcessWithoutNullStreams): Promise<{
   readonly code: number | null;
   readonly signal: NodeJS.Signals | null;
diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx
index d1509582ec..d675b618e1 100644
--- a/apps/desktop/src/renderer/app-shell-overlays.tsx
+++ b/apps/desktop/src/renderer/app-shell-overlays.tsx
@@ -39,9 +39,7 @@ const SettingsModal = lazy(async () => {
     }
   ).makaE2eLatch;
   await e2eLatch?.wait('settings.chunk');
-  return import('./settings/settings-modal').then((module) => ({
-    default: module.SettingsModal,
-  }));
+  return import('./settings/settings-modal');
 });
 
 type SearchModalProps = Parameters<typeof SearchModal>[0];
@@ -80,7 +78,7 @@ export function AppShellOverlays(props: {
    * can disagree the moment anything else writes the setting.
    */
   refreshChatDefaults(): void;
-  settingsRequestedSection: SettingsSection | undefined;
+  settingsRequest: { readonly section?: SettingsSection; readonly profileId?: string };
   settingsProviderCatalogOpen: boolean;
   settingsConnectionDetailSlug: string | undefined;
   settingsCreateProviderType: ProviderType | undefined;
@@ -113,7 +111,7 @@ export function AppShellOverlays(props: {
     searchModalOnNavigate,
     searchModalOpen,
     settingsOpen,
-    settingsRequestedSection,
+    settingsRequest,
     settingsProviderCatalogOpen,
     settingsConnectionDetailSlug,
     settingsCreateProviderType,
@@ -161,12 +159,11 @@ export function AppShellOverlays(props: {
   // #1045: base commands freeze per open/close; session rows stay live on
   // visibleSessions/activeId. run() closures read latest options via ref.
   const commands = useAppShellCommands(paletteOpen, commandOptions);
-  const copyDiagnosticsCommand = commands.find((command) => command.id === 'diag:copy-diagnostics');
   useHotkeys([
     {
       keys: 'mod+shift+d',
       allowInInputs: true,
-      onPress: () => void copyDiagnosticsCommand?.run(),
+      onPress: () => void commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(),
     },
   ]);
   return (
@@ -183,7 +180,7 @@ export function AppShellOverlays(props: {
             uiLocaleUpdateGate={uiLocaleUpdateGate}
             onUserLabelChange={setUserLabel}
             onDefaultPermissionModeChange={() => refreshChatDefaults()}
-            requestedSection={settingsRequestedSection}
+            request={settingsRequest}
             openProviderCatalog={settingsProviderCatalogOpen}
             initialConnectionSlug={settingsConnectionDetailSlug}
             initialCreateProviderType={settingsCreateProviderType}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index b899ce429f..986fc6c501 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -389,6 +389,23 @@ function AppShellContent({
     epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1);
   }, []);
 
+  const {
+    settingsOpen,
+    settingsRequest,
+    settingsProviderCatalogOpen,
+    settingsConnectionDetailSlug,
+    settingsCreateProviderType,
+    setSettingsOpen,
+    setSettingsProviderCatalogOpen,
+    setSettingsProfileId,
+    openSettings,
+    openSettingsSection,
+    openProjectSettings,
+    openProviderCatalog,
+    openConnectionDetail,
+    openProviderCreate,
+  } = useSettingsModal();
+
   const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot);
   const reportTaskEntryError = useCallback(
     ({ title, description, profileId }: TaskEntryError) => {
@@ -398,6 +415,7 @@ function AppShellContent({
   );
   const taskEntry = useTaskEntryController({
     reportError: reportTaskEntryError,
+    manageProjects: openProjectSettings,
   });
   // Named on its own because the rail depends on it: `taskEntry.commands` is a
   // fresh object every render, so depending on the bag rather than the command
@@ -610,22 +628,6 @@ function AppShellContent({
     onboarding.snapshot,
     sessions.length > 0,
   );
-  const {
-    settingsOpen,
-    settingsRequestedSection,
-    settingsProviderCatalogOpen,
-    settingsConnectionDetailSlug,
-    settingsCreateProviderType,
-    setSettingsOpen,
-    setSettingsProviderCatalogOpen,
-    openSettings,
-    openSettingsSection,
-    openProviderCatalog,
-    openConnectionDetail,
-    openProviderCreate,
-  } = useSettingsModal();
-  const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] =
-    useState<string>();
   const {
     themePref,
     setThemePref,
@@ -2670,7 +2672,7 @@ function AppShellContent({
     messages,
     newTaskProfileId: taskEntry.selectors.selectedProfileId,
     settingsOpen,
-    settingsProfileId: settingsDiagnosticProfileId,
+    settingsProfileId: settingsRequest.profileId,
     sessions,
     themePref,
     visibleSessions,
@@ -3287,7 +3289,7 @@ function AppShellContent({
         refreshChatDefaults={() => {
           void taskEntry.commands.refresh().catch(() => undefined);
         }}
-        settingsRequestedSection={settingsRequestedSection}
+        settingsRequest={settingsRequest}
         settingsProviderCatalogOpen={settingsProviderCatalogOpen}
         settingsConnectionDetailSlug={settingsConnectionDetailSlug}
         settingsCreateProviderType={settingsCreateProviderType}
@@ -3319,7 +3321,7 @@ function AppShellContent({
           openNewTaskSurface();
           void taskEntry.commands.chooseProjectForProfile(profileId).catch(() => undefined);
         }}
-        onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId}
+        onSelectedRuntimeHostProfileIdChange={setSettingsProfileId}
       />
     </div>
     </ComposerMentionsProvider>
diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
index 23602b0a60..820797be1b 100644
--- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
+++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts
@@ -62,6 +62,7 @@ export interface TaskEntryError {
 
 export interface UseTaskEntryControllerInput {
   reportError(error: TaskEntryError): void;
+  manageProjects(profileId: string): void;
 }
 
 export interface TaskEntryControllerSelectors {
@@ -137,6 +138,7 @@ export function useTaskEntryController(
   const copy = getShellCopy(locale).projectActions;
   const conversationCopy = getConversationCopy(locale).workspace;
   const reportError = input.reportError;
+  const manageProjects = input.manageProjects;
   const { catalog: service } = useTaskEntryServices();
   const [catalog, setCatalog] = useState<TaskEntryCatalog>(EMPTY_CATALOG);
   const [selectedProfileId, setSelectedProfileId] = useState<string>();
@@ -453,6 +455,7 @@ export function useTaskEntryController(
           ...(host.capabilities.selectNoProject
             ? { onSelectNoProject: () => selectNoProject(host) }
             : {}),
+          onManage: () => manageProjects(host.profile.id),
         };
       }),
       ...(catalogNeedsRetry
@@ -473,6 +476,7 @@ export function useTaskEntryController(
     copy.runtimeHostReadiness,
     currentProject?.name,
     error,
+    manageProjects,
     pending,
     refreshing,
     refresh,
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 f8fce1c2e7..b1bc2fc730 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -84,6 +84,7 @@ export interface RuntimeHostManagementTarget {
   readonly id: string;
   readonly name: string;
   readonly subtitle?: string;
+  readonly scope: 'full' | 'project_directories';
   readonly directPeerManagement: boolean;
 }
 
@@ -153,7 +154,7 @@ export function RuntimeHostManagementDialog(props: {
       } catch (failure) {
         if (!disposed) setError(settingsActionErrorMessage(failure, locale));
       }
-      if (shouldLoadUpdatePolicy) {
+      if (shouldLoadUpdatePolicy && target.scope === 'full') {
         try {
           const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(target.id);
           if (!disposed) applyUpdatePolicy(policy);
@@ -164,7 +165,7 @@ export function RuntimeHostManagementDialog(props: {
           }
         }
       }
-      if (shouldLoadUpdatePolicy && target.directPeerManagement) {
+      if (shouldLoadUpdatePolicy && target.scope === 'full' && target.directPeerManagement) {
         try {
           const peer = await window.maka.runtimeHostManagement.getDirectPeer(target.id);
           if (!disposed) applyDirectPeer(peer);
@@ -231,7 +232,9 @@ export function RuntimeHostManagementDialog(props: {
       setResult(response);
       reconcileDirectoryPolicy(response.service);
       if (response.service.state === 'not_installed') setUpdatePolicy(undefined);
-      else if (action !== 'logs') await reloadUpdatePolicy(target.id);
+      else if (target.scope === 'full' && action !== 'logs') {
+        await reloadUpdatePolicy(target.id);
+      }
     } catch (failure) {
       const message = settingsActionErrorMessage(failure, locale);
       setUpdatePolicy(undefined);
@@ -582,6 +585,7 @@ export function RuntimeHostManagementDialog(props: {
     directoryPolicyEdit !== undefined &&
     JSON.stringify(normalizedDirectoryRoots) !== JSON.stringify(directoryPolicyEdit.baseline.roots);
   const updateOutcome = lastUpdateOutcome;
+  const fullManagement = target?.scope === 'full';
   return (
     <Dialog
       isOpen={target !== undefined}
@@ -715,7 +719,7 @@ export function RuntimeHostManagementDialog(props: {
                       <Fact label={copy.stateRoot} value={service.stateRoot} wide />
                     ) : null}
                   </dl>
-                  {serviceInstalled && target?.directPeerManagement ? (
+                  {serviceInstalled && fullManagement && target?.directPeerManagement ? (
                     <section className="settingsRuntimeHostDirectPeer">
                       <div className="settingsRuntimeHostUpdatePolicyHeading">
                         <div>
@@ -866,7 +870,7 @@ export function RuntimeHostManagementDialog(props: {
                       ) : null}
                     </section>
                   ) : null}
-                  {serviceInstalled ? (
+                  {serviceInstalled && fullManagement ? (
                     <section className="settingsRuntimeHostUpdatePolicy">
                       <div className="settingsRuntimeHostUpdatePolicyHeading">
                         <div>
@@ -1036,7 +1040,7 @@ export function RuntimeHostManagementDialog(props: {
                       </Text>
                     )}
                   </div>
-                  {result.action === 'logs' ? (
+                  {fullManagement && result.action === 'logs' ? (
                     <pre ref={logsRef} className="settingsRuntimeHostManagementLogs">
                       {result.logs || copy.noLogs}
                     </pre>
@@ -1253,7 +1257,7 @@ export function RuntimeHostManagementDialog(props: {
                     isDisabled={loading}
                     onClick={props.onClose}
                   />
-                  {target && !uninstalled ? (
+                  {target && fullManagement && !uninstalled ? (
                     <MoreMenu
                       label={copy.moreActions(target.name)}
                       size="sm"
@@ -1286,14 +1290,14 @@ export function RuntimeHostManagementDialog(props: {
                         isDisabled={loading}
                         onClick={() => void run('status')}
                       />
-                      {serviceInstalled && supervised && serviceActive ? (
+                      {fullManagement && serviceInstalled && supervised && serviceActive ? (
                         <Button
                           variant="primary"
                           label={copy.restartService}
                           isDisabled={loading}
                           onClick={() => void run('restart')}
                         />
-                      ) : serviceInstalled && supervised ? (
+                      ) : fullManagement && serviceInstalled && supervised ? (
                         <Button
                           variant="primary"
                           label={copy.startService}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
index b7f58fadb1..cf28cb4d8d 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -360,7 +360,12 @@ export function RuntimeHostProfilesSection(props: {
   const connectedEntries = snapshot?.entries.filter((entry) => entry.profile.kind !== 'local') ?? [];
   const localProfile = snapshot?.entries.find((entry) => entry.profile.kind === 'local')?.profile;
   const localManagementTarget = localProfile
-    ? { id: localProfile.id, name: localProfile.name, directPeerManagement: false }
+    ? {
+        id: localProfile.id,
+        name: localProfile.name,
+        scope: 'full' as const,
+        directPeerManagement: false,
+      }
     : undefined;
   const profileOptions = (snapshot?.entries ?? [])
     .filter(
@@ -636,12 +641,25 @@ export function RuntimeHostProfilesSection(props: {
               if (profile.kind === 'local') return null;
               const isSharedAccess =
                 profile.kind === 'remote' && profile.access === 'session_guest';
-              const managedSshDestination =
-                !isSharedAccess &&
-                profile.kind === 'remote' &&
-                profile.transport.kind === 'ssh' &&
-                entry.managedService
-                  ? profile.transport.destination
+              const managementTarget: RuntimeHostManagementTarget | undefined =
+                !isSharedAccess && entry.managedService
+                  ? profile.kind === 'environment'
+                    ? {
+                        id: profile.id,
+                        name: profile.name,
+                        subtitle: profile.provider.distribution,
+                        scope: 'project_directories',
+                        directPeerManagement: false,
+                      }
+                    : profile.transport.kind === 'ssh'
+                      ? {
+                          id: profile.id,
+                          name: profile.name,
+                          subtitle: profile.transport.destination,
+                          scope: 'full',
+                          directPeerManagement: true,
+                        }
+                      : undefined
                   : undefined;
               return (
                 <ListItem
@@ -708,24 +726,21 @@ export function RuntimeHostProfilesSection(props: {
                         onChanged={() => void reload()}
                         onWorkingChange={setSwitching}
                         items={[
-                          ...(managedSshDestination && !entry.pairingPending
+                          ...(managementTarget && !entry.pairingPending
                             ? [{
                                 label: copy.manage,
                                 isDisabled: switching,
-                                onClick: () => setManagedTarget({
-                                  id: profile.id,
-                                  name: profile.name,
-                                  subtitle: managedSshDestination,
-                                  directPeerManagement: true,
-                                }),
-                              }, {
-                                label: copy.managePeerMesh,
-                                isDisabled: switching,
-                                onClick: () => setPeerMeshTarget({
-                                  target: { kind: 'managed_host', profileId: profile.id },
-                                  name: profile.name,
-                                }),
-                              }]
+                                onClick: () => setManagedTarget(managementTarget),
+                              }, ...(managementTarget.directPeerManagement
+                                ? [{
+                                    label: copy.managePeerMesh,
+                                    isDisabled: switching,
+                                    onClick: () => setPeerMeshTarget({
+                                      target: { kind: 'managed_host', profileId: profile.id },
+                                      name: profile.name,
+                                    }),
+                                  }]
+                                : [])]
                             : []),
                           {
                             label: copy.remove,
diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx
index 6c2b09a738..3cf5cce934 100644
--- a/apps/desktop/src/renderer/settings/settings-modal.tsx
+++ b/apps/desktop/src/renderer/settings/settings-modal.tsx
@@ -31,7 +31,7 @@ import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
 export { SETTINGS_NAV } from './settings-nav';
 export type { SettingsNavGroup } from './settings-nav';
 
-export function SettingsModal(props: {
+export default function SettingsModal(props: {
   onClose(): void;
   themePref: ThemePreference;
   onThemeChange(pref: ThemePreference): void;
@@ -49,11 +49,10 @@ export function SettingsModal(props: {
   onUserLabelChange?(label: string): void;
   onDefaultPermissionModeChange(mode: ChatDefaultPermissionMode): void;
   /**
-   * Force the modal to a specific section when it (re-)mounts or when the
-   * value changes while already open. Used by the command palette so
-   * ⌘K → "网络" jumps straight to the section without an extra click.
+   * Force the modal to a specific section and Runtime Host when it mounts.
+   * Section changes while already open remain live for command-palette jumps.
    */
-  requestedSection?: SettingsSection;
+  request?: { readonly section?: SettingsSection; readonly profileId?: string };
   openProviderCatalog?: boolean;
   initialConnectionSlug?: string;
   initialCreateProviderType?: ProviderType;
@@ -105,7 +104,7 @@ export function SettingsModal(props: {
         uiLocaleUpdateGate={props.uiLocaleUpdateGate}
         onUserLabelChange={props.onUserLabelChange}
         onDefaultPermissionModeChange={props.onDefaultPermissionModeChange}
-        requestedSection={props.requestedSection}
+        request={props.request}
         openProviderCatalog={props.openProviderCatalog}
         initialConnectionSlug={props.initialConnectionSlug}
         initialCreateProviderType={props.initialCreateProviderType}
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index 8addb78be9..f202d45521 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -154,7 +154,7 @@ export function SettingsSurface(props: {
   uiLocaleUpdateGate: UiLocaleUpdateGate;
   onUserLabelChange?(label: string): void;
   onDefaultPermissionModeChange(mode: ChatDefaultPermissionMode): void;
-  requestedSection?: SettingsSection;
+  request?: { readonly section?: SettingsSection; readonly profileId?: string };
   openProviderCatalog?: boolean;
   initialConnectionSlug?: string;
   initialCreateProviderType?: ProviderType;
@@ -172,7 +172,7 @@ export function SettingsSurface(props: {
   const copy = getSettingsSharedCopy(locale);
   const localizedNav = groupedNav(locale);
   const isNarrowSettings = useMediaQuery(NARROW_SETTINGS_QUERY);
-  const [section, setSection] = useState<SettingsSection>(() => props.requestedSection ?? readLastSettingsSection());
+  const [section, setSection] = useState<SettingsSection>(() => props.request?.section ?? readLastSettingsSection());
   const [providerCatalogRequested, setProviderCatalogRequested] = useState(props.openProviderCatalog === true);
   // One-shot landing intent, mirroring providerCatalogRequested above: the
   // request retires once ProvidersPanel consumes it, so remounting the panel
@@ -190,15 +190,15 @@ export function SettingsSurface(props: {
     setCreateProviderRequest(props.initialCreateProviderType);
   }, [props.initialCreateProviderType]);
 
-  // When the parent updates requestedSection (e.g. the palette opens
+  // When the parent updates the navigation request (e.g. the palette opens
   // Settings with a different section while it's already mounted), reflect
-  // that into the local state.
+  // its section into the local state.
   useEffect(() => {
-    if (props.requestedSection && props.requestedSection !== section) {
-      setSection(props.requestedSection);
+    if (props.request?.section && props.request.section !== section) {
+      setSection(props.request.section);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [props.requestedSection]);
+  }, [props.request?.section]);
 
   // Focus follows the active section's nav button: on mount, and whenever
   // `section` changes (nav click — a native-focus no-op — or a ⌘K palette
@@ -252,12 +252,14 @@ export function SettingsSurface(props: {
     () => snapshotCache.readRuntimeHostCatalog(),
     [snapshotCache],
   );
+  const initialSelectedProfileId =
+    props.request?.profileId ?? initialRuntimeHostCatalog?.defaultProfileId;
   const initialRuntimeHost = useMemo(
     () => readyRuntimeHost(
       initialRuntimeHostCatalog,
-      initialRuntimeHostCatalog?.defaultProfileId,
+      initialSelectedProfileId,
     ),
-    [initialRuntimeHostCatalog],
+    [initialRuntimeHostCatalog, initialSelectedProfileId],
   );
   const initialRuntimeHostKey = initialRuntimeHost
     ? runtimeHostSettingsKey(initialRuntimeHost)
@@ -288,7 +290,7 @@ export function SettingsSurface(props: {
     initialRuntimeHostCatalog,
   ));
   const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>(
-    initialRuntimeHostCatalog?.defaultProfileId,
+    initialSelectedProfileId,
   );
   const selectedProfileIdRef = useRef(selectedProfileId);
   const defaultRuntimeHostProfileIdRef = useRef(
@@ -309,7 +311,9 @@ export function SettingsSurface(props: {
   const usageReloadTicketRef = useRef(0);
   const runtimeHostReloadTicketRef = useRef(0);
   const runtimeHostCatalogHydratedRef = useRef(false);
-  const selectedProfileChangedByUserRef = useRef(false);
+  const selectedProfileChangedByUserRef = useRef(
+    props.request?.profileId !== undefined,
+  );
   const runtimeHostLifecycleByProfileRef = useRef(
     new Map<string, DesktopRuntimeHostProfileChangedEvent>(),
   );
diff --git a/apps/desktop/src/renderer/use-settings-modal.ts b/apps/desktop/src/renderer/use-settings-modal.ts
index 6335f361f2..7b6d7e9678 100644
--- a/apps/desktop/src/renderer/use-settings-modal.ts
+++ b/apps/desktop/src/renderer/use-settings-modal.ts
@@ -22,6 +22,11 @@ import type { ProviderType } from '@maka/core/llm-connections';
 import type { SettingsSection } from '@maka/core/settings';
 import { safeLocalStorageSet } from './browser-storage';
 
+interface SettingsNavigationRequest {
+  readonly section?: SettingsSection;
+  readonly profileId?: string;
+}
+
 /**
  * Owns the Settings modal surface state (issue #1043): the open flag, the
  * requested section, and the provider-catalog sub-open flag, plus the openers
@@ -33,13 +38,15 @@ import { safeLocalStorageSet } from './browser-storage';
  */
 export function useSettingsModal() {
   const [settingsOpen, setSettingsOpen] = useState(false);
-  const [settingsRequestedSection, setSettingsRequestedSection] = useState<SettingsSection | undefined>(
-    undefined,
-  );
+  const [settingsRequest, setSettingsRequest] = useState<SettingsNavigationRequest>({});
   const [settingsProviderCatalogOpen, setSettingsProviderCatalogOpen] = useState(false);
   const [settingsConnectionDetailSlug, setSettingsConnectionDetailSlug] = useState<string | undefined>(undefined);
   const [settingsCreateProviderType, setSettingsCreateProviderType] = useState<ProviderType | undefined>(undefined);
 
+  function setSettingsProfileId(profileId: string | undefined) {
+    setSettingsRequest((current) => ({ ...current, profileId }));
+  }
+
   function showSettings() {
     // macOS menu commands do not move DOM focus before opening Settings.
     // Settle blur-owned edits before the obscured shell unmounts them.
@@ -58,7 +65,16 @@ export function useSettingsModal() {
 
   function openSettingsSection(section: SettingsSection) {
     safeLocalStorageSet('maka-settings-section-v1', section);
-    setSettingsRequestedSection(section);
+    setSettingsRequest((current) => ({ ...current, section }));
+    setSettingsProviderCatalogOpen(false);
+    setSettingsConnectionDetailSlug(undefined);
+    setSettingsCreateProviderType(undefined);
+    showSettings();
+  }
+
+  function openProjectSettings(profileId: string) {
+    safeLocalStorageSet('maka-settings-section-v1', 'projects');
+    setSettingsRequest({ section: 'projects', profileId });
     setSettingsProviderCatalogOpen(false);
     setSettingsConnectionDetailSlug(undefined);
     setSettingsCreateProviderType(undefined);
@@ -67,7 +83,7 @@ export function useSettingsModal() {
 
   function openProviderCatalog() {
     safeLocalStorageSet('maka-settings-section-v1', 'models');
-    setSettingsRequestedSection('models');
+    setSettingsRequest((current) => ({ ...current, section: 'models' }));
     setSettingsProviderCatalogOpen(true);
     setSettingsConnectionDetailSlug(undefined);
     setSettingsCreateProviderType(undefined);
@@ -77,7 +93,7 @@ export function useSettingsModal() {
   /** Open Settings → 模型 with a specific connection's detail sheet expanded. */
   function openConnectionDetail(slug: string) {
     safeLocalStorageSet('maka-settings-section-v1', 'models');
-    setSettingsRequestedSection('models');
+    setSettingsRequest((current) => ({ ...current, section: 'models' }));
     setSettingsProviderCatalogOpen(false);
     setSettingsConnectionDetailSlug(slug);
     setSettingsCreateProviderType(undefined);
@@ -87,7 +103,7 @@ export function useSettingsModal() {
   /** Open Settings → 模型 with the create-connection dialog for this provider expanded. */
   function openProviderCreate(providerType: ProviderType) {
     safeLocalStorageSet('maka-settings-section-v1', 'models');
-    setSettingsRequestedSection('models');
+    setSettingsRequest((current) => ({ ...current, section: 'models' }));
     setSettingsProviderCatalogOpen(false);
     setSettingsConnectionDetailSlug(undefined);
     setSettingsCreateProviderType(providerType);
@@ -96,14 +112,16 @@ export function useSettingsModal() {
 
   return {
     settingsOpen,
-    settingsRequestedSection,
+    settingsRequest,
     settingsProviderCatalogOpen,
     settingsConnectionDetailSlug,
     settingsCreateProviderType,
     setSettingsOpen,
     setSettingsProviderCatalogOpen,
+    setSettingsProfileId,
     openSettings,
     openSettingsSection,
+    openProjectSettings,
     openProviderCatalog,
     openConnectionDetail,
     openProviderCreate,
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index b1dbd489c4..0a93af857b 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -1613,7 +1613,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
           onUiLocalePreferenceChange={noop}
           uiLocaleUpdateGate={uiLocaleUpdateGate}
           onDefaultPermissionModeChange={noop}
-          requestedSection={props.section}
+          request={{ section: props.section }}
           openProviderCatalog={props.openProviderCatalog}
           initialConnectionSlug={props.initialConnectionSlug}
           initialFocusRef={initialFocusRef}
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index d7a4d79cba..7c6773e24f 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -242,6 +242,7 @@ export interface ConversationCopy {
     choose: string;
     current: string;
     addProject: string;
+    manageProjects: string;
     noProject: string;
     relink: string;
     unavailable: string;
@@ -492,7 +493,7 @@ const CONVERSATION_COPY = {
     questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' },
     mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
     workspace: {
-      choose: '选择项目', current: '当前项目', addProject: '添加项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
+      choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
       chooseTitle: (branch) => branch ? `选择项目 · ${branch}` : '选择项目',
       chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`,
     },
@@ -640,7 +641,7 @@ const CONVERSATION_COPY = {
     questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' },
     mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' },
     workspace: {
-      choose: 'Choose project', current: 'Current project', addProject: 'Add project', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
+      choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
       chooseTitle: (branch) => branch ? `Choose project · ${branch}` : 'Choose project',
       chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`,
     },
diff --git a/packages/ui/src/workspace-picker.tsx b/packages/ui/src/workspace-picker.tsx
index 43f8eda746..9533837e0a 100644
--- a/packages/ui/src/workspace-picker.tsx
+++ b/packages/ui/src/workspace-picker.tsx
@@ -19,7 +19,7 @@
 
 import type { ProjectRecord } from '@maka/core/project';
 import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu';
-import { ICON_SIZE, AlertTriangle, Check, FolderOpen, Network, Plus, RefreshCcw, X } from './icons.js';
+import { ICON_SIZE, AlertTriangle, Check, FolderOpen, Network, Plus, RefreshCcw, Settings, X } from './icons.js';
 import { useUiLocale } from './locale-context.js';
 import { getConversationCopy } from './conversation-copy.js';
 
@@ -32,6 +32,7 @@ export interface WorkspacePickerGroup {
   selectedProjectId?: string | null;
   onSelectProject?(projectId: string): void;
   onAdd?(): void;
+  onManage?(): void;
   onRelink?(projectId: string): void;
   onSelectNoProject?(): void;
 }
@@ -136,6 +137,14 @@ export function WorkspacePicker({ workspacePicker: picker }: {
                   onClick={group.onAdd}
                 />
               ) : null}
+              {group.onManage ? (
+                <DropdownMenuItem
+                  icon={<Settings size={ICON_SIZE.meta} aria-hidden="true" />}
+                  label={copy.manageProjects}
+                  isDisabled={locked || group.disabled}
+                  onClick={group.onManage}
+                />
+              ) : null}
               {group.onSelectNoProject ? (
                 <DropdownMenuItem
                   icon={<X size={ICON_SIZE.meta} aria-hidden="true" />}


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