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/20 08:22:59 UTC

(maka) branch feat/desktop-managed-remote-onboarding updated: fix(desktop): harden remote host onboarding recovery

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

M4n5ter pushed a commit to branch feat/desktop-managed-remote-onboarding
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/feat/desktop-managed-remote-onboarding by this push:
     new 32fa33681 fix(desktop): harden remote host onboarding recovery
32fa33681 is described below

commit 32fa33681a08ab39240ddb92d0352da929bd946b
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Aug 20 16:22:49 2026 +0800

    fix(desktop): harden remote host onboarding recovery
    
    Keep setup metadata and pairing recovery scoped to onboarding so Desktop startup and existing remote Hosts remain available after a recoverable failure. Centralize profile activation and credential storage, require explicit repair for transport changes, and make managed service restart limits explicit.\n\nGenerated-by: Codex
---
 .../main/__tests__/runtime-host-onboarding.test.ts |  45 +++++-
 .../__tests__/runtime-host-profile-service.test.ts |  86 ++++++++++-
 apps/desktop/src/main/runtime-host-boot.ts         |  48 +++----
 apps/desktop/src/main/runtime-host-onboarding.ts   |   4 +-
 .../src/main/runtime-host-pairing-journal.ts       |  14 +-
 .../src/main/runtime-host-profile-service.ts       | 159 ++++++++++++++-------
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |   3 +
 .../src/renderer/locales/settings-projects-copy.ts |  13 ++
 .../settings/runtime-host-profiles-section.tsx     |  32 +++++
 docs/runtime-host-remote-access.md                 |   3 +-
 .../__tests__/runtime-host-service-manager.test.ts |   2 +
 packages/cli/src/runtime-host-systemd-service.ts   |   3 +
 packages/runtime-host/src/client/host-profile.ts   |  16 ++-
 packages/runtime-host/src/client/index.ts          |   2 +
 15 files changed, 326 insertions(+), 106 deletions(-)

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 3a0a4f0af..5eec2c2b3 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
@@ -22,7 +22,7 @@ test('persists a verified SSH profile without projecting its credential', async
     },
     clientInstanceId: 'stable-client',
     profiles,
-    setupPackage: { kind: 'npm', specifier: 'maka-agent@next' },
+    resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@next' }),
     runSetup: async (_input, onProgress) => {
       onProgress({ phase: 'installing_service' });
       return {
@@ -77,7 +77,7 @@ test('projects invalid setup input as a recoverable failure', async () => {
     profiles: {
       addAndEnableVerified: async () => assert.fail('not used'),
     },
-    setupPackage: { kind: 'npm', specifier: 'maka-agent@0.2.0' },
+    resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
     runSetup: async () => assert.fail('invalid input must not start SSH'),
     send: () => undefined,
   });
@@ -131,7 +131,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
     },
     clientInstanceId: 'stable-client',
     profiles,
-    setupPackage: { kind: 'npm', specifier: 'maka-agent@0.2.0' },
+    resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
     runSetup: async (_input, _onProgress, onComplete) => {
       onComplete();
       completeReceived = true;
@@ -159,3 +159,42 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
   assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 3 });
   await onboarding.close();
 });
+
+test('resolves the setup package only when onboarding starts', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  let resolutions = 0;
+  const onboarding = createDesktopRuntimeHostOnboarding({
+    ipcMain: {
+      handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+      removeHandler: (channel) => handlers.delete(channel),
+    },
+    clientInstanceId: 'stable-client',
+    profiles: {
+      addAndEnableVerified: async () => assert.fail('not used'),
+    },
+    resolveSetupPackage: () => {
+      resolutions += 1;
+      throw new Error('Desktop does not declare an exact Runtime Host setup package');
+    },
+    runSetup: async () => assert.fail('an unavailable package must not start SSH'),
+    send: () => undefined,
+  });
+
+  assert.deepEqual(await handlers.get('runtime-host-onboarding:getSnapshot')?.({}), {
+    kind: 'idle',
+    revision: 0,
+  });
+  assert.equal(resolutions, 0);
+  assert.deepEqual(
+    await handlers.get('runtime-host-onboarding:start')?.({}, {
+      destination: 'operator@example.com',
+    }),
+    {
+      kind: 'failed',
+      message: 'Desktop does not declare an exact Runtime Host setup package',
+      revision: 2,
+    },
+  );
+  assert.equal(resolutions, 1);
+  await onboarding.close();
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
index f361a2569..bdbf65ad2 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, test } from "node:test";
 import {
+  createClientRuntimeHostCredentialStore,
   createClientRuntimeHostProfileCatalog,
   LOCAL_RUNTIME_HOST_PROFILE,
   RuntimeHostPermanentReconnectError,
@@ -362,7 +363,6 @@ test("refreshes the existing profile when managed setup pairs the same Host agai
       ...PROFILE,
       id: "replacement",
       name: "Renamed office",
-      transport: { kind: "tls", url: "wss://new-runtime.example.com" },
     },
     credential: "new-token",
   });
@@ -373,7 +373,7 @@ test("refreshes the existing profile when managed setup pairs the same Host agai
   assert.equal(connectedProfile?.kind, "remote");
   assert.deepEqual(connectedProfile?.kind === "remote" ? connectedProfile.transport : undefined, {
     kind: "tls",
-    url: "wss://new-runtime.example.com/",
+    url: "wss://runtime.example.com/",
   });
   assert.equal(connected[0]?.credential, "new-token");
   assert.equal((await catalog.resolve(PROFILE.id)).credential, "new-token");
@@ -381,6 +381,37 @@ test("refreshes the existing profile when managed setup pairs the same Host agai
   assert.deepEqual((await catalog.read()).profiles.map((profile) => profile.id), [PROFILE.id]);
 });
 
+test("does not silently move an existing Host profile to another connection", async () => {
+  const root = await clientRoot();
+  const catalog = createClientRuntimeHostProfileCatalog(root);
+  await catalog.create(PROFILE, "old-token");
+  const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
+  const service = createDesktopRuntimeHostProfileService({
+    clientDataRoot: root,
+    startup,
+    catalog,
+    states: () => [connectingLocal()],
+    enable: async () => assert.fail("a different connection must require explicit repair"),
+    disable: async () => undefined,
+    setDefault: () => undefined,
+    finalizePairing: async () => undefined,
+  });
+
+  await assert.rejects(
+    () =>
+      service.addAndEnableVerified({
+        profile: {
+          ...PROFILE,
+          id: "replacement",
+          transport: { kind: "tls", url: "wss://new-runtime.example.com" },
+        },
+        credential: "new-token",
+      }),
+    /already configured through another connection/u,
+  );
+  assert.equal((await catalog.resolve(PROFILE.id)).credential, "old-token");
+});
+
 test("finishes a persisted pairing after Desktop restarts before finalization", async () => {
   const root = await clientRoot();
   const catalog = await stageInterruptedPairing(root);
@@ -400,7 +431,7 @@ test("finishes a persisted pairing after Desktop restarts before finalization",
     },
   });
 
-  await service.recoverPendingPairings();
+  await service.startEnabledProfiles();
 
   assert.deepEqual(finalized, [PROFILE.id]);
   assert.equal((await catalog.resolve(PROFILE.id)).credential, "new-token");
@@ -454,7 +485,7 @@ test("restores the previous credential when an interrupted pairing can no longer
     finalizePairing: async () => assert.fail("an expired credential cannot be finalized"),
   });
 
-  await service.recoverPendingPairings();
+  await service.startEnabledProfiles();
 
   assert.equal((await catalog.resolve(PROFILE.id)).credential, "old-token");
   assert.equal((await resolveDesktopRuntimeHostStartup(root, { catalog })).pairingIntents.length, 0);
@@ -500,7 +531,6 @@ for (const failureAt of ["connection", "finalization"] as const) {
           profile: {
             ...PROFILE,
             id: "replacement",
-            transport: { kind: "tls", url: "wss://new-runtime.example.com" },
           },
           credential: "new-token",
         }),
@@ -518,6 +548,52 @@ for (const failureAt of ["connection", "finalization"] as const) {
   });
 }
 
+test("keeps existing Hosts available until unreadable pairing recovery is discarded", async () => {
+  const root = await clientRoot();
+  const credentialStore = createClientRuntimeHostCredentialStore(root);
+  const catalog = createClientRuntimeHostProfileCatalog(root, credentialStore);
+  await catalog.create(PROFILE, "token");
+  await writeFile(
+    join(root, "runtime-host-profile-selection.json"),
+    `${JSON.stringify({
+      schemaVersion: 2,
+      defaultProfileId: "local",
+      enabledRemoteProfileIds: [PROFILE.id],
+    })}\n`,
+  );
+  await credentialStore.setSecret(
+    "runtime-host-pairing-recovery",
+    "runtime_host_access",
+    "not-json",
+  );
+  const startup = await resolveDesktopRuntimeHostStartup(root, { catalog, credentialStore });
+  assert.deepEqual(startup.remotes.map((target) => target.profile.id), [PROFILE.id]);
+  const enabled: string[] = [];
+  const service = createDesktopRuntimeHostProfileService({
+    clientDataRoot: root,
+    startup,
+    catalog,
+    credentialStore,
+    states: () => [connectingLocal()],
+    enable: async (target) => {
+      enabled.push(target.profile.id);
+    },
+    disable: async () => undefined,
+    setDefault: () => undefined,
+    finalizePairing: async () => undefined,
+  });
+
+  await service.startEnabledProfiles();
+  assert.deepEqual(enabled, [PROFILE.id]);
+  assert.ok((await service.getSnapshot()).pairingRecoveryError);
+  await assert.rejects(
+    () => service.setEnabled(PROFILE.id, false),
+    /Discard the unreadable/u,
+  );
+  assert.equal((await service.discardPairingRecovery()).pairingRecoveryError, undefined);
+  assert.equal((await service.setEnabled(PROFILE.id, false)).entries[1]?.enabled, false);
+});
+
 test("keeps enablement, default selection, and removal as separate states", async () => {
   const root = await clientRoot();
   await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 4d66ecb18..5883af3d9 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -19,6 +19,8 @@ import {
 } from '@maka/runtime/scheduled-task-tools';
 import { buildMcpTools } from '@maka/runtime/mcp-tools';
 import {
+  createClientRuntimeHostCredentialStore,
+  createClientRuntimeHostProfileCatalog,
   LOCAL_RUNTIME_HOST_PROFILE,
   loadOrCreateRuntimeHostClientInstanceId,
 } from "@maka/runtime-host/client";
@@ -166,7 +168,15 @@ const userDataDir = app.getPath("userData");
 const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId(
   join(userDataDir, "runtime-host-client.json"),
 );
-const runtimeHostStartup = await resolveDesktopRuntimeHostStartup(userDataDir);
+const runtimeHostCredentialStore = createClientRuntimeHostCredentialStore(userDataDir);
+const runtimeHostProfileCatalog = createClientRuntimeHostProfileCatalog(
+  userDataDir,
+  runtimeHostCredentialStore,
+);
+const runtimeHostStartup = await resolveDesktopRuntimeHostStartup(userDataDir, {
+  catalog: runtimeHostProfileCatalog,
+  credentialStore: runtimeHostCredentialStore,
+});
 let runtimeHostManager: RuntimeHostDesktopManager | undefined;
 function activeRuntimeHostRef(): DesktopTargetScope | undefined {
   const current = runtimeHostManager?.current();
@@ -277,8 +287,10 @@ const oauthPresentation = new RuntimeHostOAuthPresentation((url) => shell.openEx
 const runtimeHostProfileService = createDesktopRuntimeHostProfileService({
   clientDataRoot: userDataDir,
   startup: runtimeHostStartup,
+  catalog: runtimeHostProfileCatalog,
+  credentialStore: runtimeHostCredentialStore,
   states: () => runtimeHostManager?.entries() ?? [],
-  enable: async (target) => {
+  enable: async (target, sshInteraction) => {
     if (target.profile.kind !== "remote" || !target.credential) {
       throw new Error("A resolved remote Runtime Host profile is required");
     }
@@ -286,9 +298,7 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({
     await runtimeHostManager.enable({
       profile: target.profile,
       credential: target.credential,
-      ...(target.profile.transport.kind === "ssh"
-        ? { sshInteraction: "terminal" as const }
-        : {}),
+      ...(target.profile.transport.kind === "ssh" ? { sshInteraction } : {}),
     });
   },
   disable: async (profileId) => {
@@ -309,7 +319,7 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({
   clientInstanceId: runtimeHostClientInstanceId,
   profiles: runtimeHostProfileService,
   runSetup: runtimeHostSshTerminal.runSetup,
-  setupPackage: runtimeHostSetupPackage(),
+  resolveSetupPackage: runtimeHostSetupPackage,
   send: (snapshot) =>
     mainWindowController.send("runtime-host-onboarding:changed", snapshot),
 });
@@ -715,12 +725,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
 });
 wireLifecycle();
 runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId);
-const pendingPairingProfileIds = new Set(
-  runtimeHostStartup.pairingIntents.map((intent) => intent.target.profile.id),
-);
-void runtimeHostProfileService
-  .recoverPendingPairings()
-  .catch((error) => console.error("[runtime-host] pairing recovery failed:", error));
+void runtimeHostProfileService.startEnabledProfiles();
 const unavailableDefault = runtimeHostStartup.unavailable.get(
   runtimeHostStartup.preferences.defaultProfileId,
 );
@@ -740,25 +745,6 @@ if (unavailableDefault) {
       console.error("[runtime-host] failed to resolve unavailable default Host:", error),
     );
 }
-// Startup may present one interactive SSH prompt without serializing every
-// enabled Host behind it. Other SSH targets fail batch-mode and remain retryable.
-for (const target of runtimeHostStartup.remotes) {
-  if (target.profile.kind !== "remote" || !target.credential) continue;
-  if (pendingPairingProfileIds.has(target.profile.id)) continue;
-  void runtimeHostManager
-    .enable({
-      profile: target.profile,
-      credential: target.credential,
-      ...(target.profile.transport.kind === "ssh" &&
-      target.profile.id === runtimeHostStartup.preferences.defaultProfileId
-        ? { sshInteraction: "terminal" as const }
-        : {}),
-    })
-    .catch((error) =>
-      console.error(`[runtime-host] ${target.profile.name} is unavailable:`, error),
-    );
-}
-
 const stopComputerUseSession = (sessionId: string): void => {
   const ref = parseDesktopSessionResourceKey(sessionId);
   void runtimeHostManager
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index 577ffc664..fc48d7045 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -31,7 +31,7 @@ export function createDesktopRuntimeHostOnboarding(input: {
     readonly credential: string;
   }>;
   readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void;
-  readonly setupPackage: DesktopRuntimeHostSetupPackage;
+  readonly resolveSetupPackage: () => DesktopRuntimeHostSetupPackage;
 }): { close(): Promise<void> } {
   let revision = 0;
   let snapshot: DesktopRuntimeHostOnboardingSnapshot = { kind: 'idle', revision };
@@ -91,7 +91,7 @@ export function createDesktopRuntimeHostOnboarding(input: {
         {
           destination: request.destination,
           ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }),
-          setupPackage: input.setupPackage,
+          setupPackage: input.resolveSetupPackage(),
           principalId: `desktop:${input.clientInstanceId}`,
           signal,
         },
diff --git a/apps/desktop/src/main/runtime-host-pairing-journal.ts b/apps/desktop/src/main/runtime-host-pairing-journal.ts
index 0e67561de..ad546a94c 100644
--- a/apps/desktop/src/main/runtime-host-pairing-journal.ts
+++ b/apps/desktop/src/main/runtime-host-pairing-journal.ts
@@ -1,4 +1,3 @@
-import { join } from 'node:path';
 import {
   decodeRemoteRuntimeHostProfile,
   RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
@@ -6,7 +5,7 @@ import {
   type RemoteRuntimeHostProfile,
   type ResolvedRuntimeHostProfile,
 } from '@maka/runtime-host/client';
-import { createFileCredentialStore } from '@maka/storage';
+import type { CredentialStore } from '@maka/storage';
 
 const PAIRING_JOURNAL_SCHEMA_VERSION = 1;
 const PAIRING_JOURNAL_CREDENTIAL_SLOT = 'runtime-host-pairing-recovery';
@@ -47,9 +46,9 @@ export function pairingIntentMatchesTarget(
 }
 
 export async function readDesktopRuntimeHostPairingIntents(
-  clientDataRoot: string,
+  credentials: Pick<CredentialStore, 'getSecret'>,
 ): Promise<readonly DesktopRuntimeHostPairingIntent[]> {
-  const contents = await pairingCredentialStore(clientDataRoot).getSecret(
+  const contents = await credentials.getSecret(
     PAIRING_JOURNAL_CREDENTIAL_SLOT,
     'runtime_host_access',
   );
@@ -65,10 +64,9 @@ export async function readDesktopRuntimeHostPairingIntents(
 }
 
 export async function writeDesktopRuntimeHostPairingIntents(
-  clientDataRoot: string,
+  credentials: Pick<CredentialStore, 'setSecret' | 'deleteSecret'>,
   intents: readonly DesktopRuntimeHostPairingIntent[],
 ): Promise<void> {
-  const credentials = pairingCredentialStore(clientDataRoot);
   if (intents.length === 0) {
     await credentials.deleteSecret(
       PAIRING_JOURNAL_CREDENTIAL_SLOT,
@@ -152,10 +150,6 @@ function requireRemoteTarget(
   };
 }
 
-function pairingCredentialStore(clientDataRoot: string) {
-  return createFileCredentialStore(join(clientDataRoot, 'runtime-host-client'));
-}
-
 function requireCredential(value: unknown): string {
   if (
     typeof value !== 'string' ||
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 74d621bcd..ba2bc07d3 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -2,16 +2,19 @@ import { randomUUID } from "node:crypto";
 import { open, readFile, rename, rm } from "node:fs/promises";
 import { dirname, join } from "node:path";
 import {
+  createClientRuntimeHostCredentialStore,
   createClientRuntimeHostProfileCatalog,
   LOCAL_RUNTIME_HOST_PROFILE,
   RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
   RuntimeHostOperationError,
   RuntimeHostPermanentReconnectError,
+  sameRemoteRuntimeHostProfileTarget,
   sameResolvedRuntimeHostProfileTarget,
   type RemoteRuntimeHostProfile,
   type ResolvedRuntimeHostProfile,
   type RuntimeHostProfileCatalog,
 } from "@maka/runtime-host/client";
+import type { CredentialStore } from "@maka/storage";
 import { withFileUpdateLock } from "@maka/storage/file-update-lock";
 import type {
   DesktopRuntimeHostProfileAddInput,
@@ -58,7 +61,8 @@ export interface DesktopRuntimeHostProfileService {
   addAndEnableVerified(
     input: DesktopRuntimeHostProfileAddInput & { readonly credential: string },
   ): Promise<{ readonly profileId: string }>;
-  recoverPendingPairings(): Promise<void>;
+  startEnabledProfiles(): Promise<void>;
+  discardPairingRecovery(): Promise<DesktopRuntimeHostProfileSnapshot>;
   setEnabled(profileId: string, enabled: boolean): Promise<DesktopRuntimeHostProfileSnapshot>;
   setDefault(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
   remove(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
@@ -68,6 +72,7 @@ export async function resolveDesktopRuntimeHostStartup(
   clientDataRoot: string,
   overrides: {
     catalog?: RuntimeHostProfileCatalog;
+    credentialStore?: CredentialStore;
     readPreferences?: () => Promise<DesktopRuntimeHostPreferences>;
   } = {},
 ): Promise<DesktopRuntimeHostStartup> {
@@ -87,13 +92,16 @@ export async function resolveDesktopRuntimeHostStartup(
   }
   let pairingIntents: readonly DesktopRuntimeHostPairingIntent[] = [];
   let pairingReadFailure: Error | undefined;
+  const credentialStore =
+    overrides.credentialStore ?? createClientRuntimeHostCredentialStore(clientDataRoot);
   try {
-    pairingIntents = await readDesktopRuntimeHostPairingIntents(clientDataRoot);
+    pairingIntents = await readDesktopRuntimeHostPairingIntents(credentialStore);
   } catch (error) {
     pairingReadFailure = asError(error);
     console.error("[runtime-host] pairing recovery journal could not be read:", pairingReadFailure);
   }
-  const catalog = overrides.catalog ?? createClientRuntimeHostProfileCatalog(clientDataRoot);
+  const catalog =
+    overrides.catalog ?? createClientRuntimeHostProfileCatalog(clientDataRoot, credentialStore);
   let document: Awaited<ReturnType<RuntimeHostProfileCatalog["read"]>>;
   try {
     document = await catalog.read();
@@ -146,17 +154,6 @@ export async function resolveDesktopRuntimeHostStartup(
   }
   const remotes: ResolvedRuntimeHostProfile[] = [];
   const unavailable = new Map<string, Error>();
-  if (pairingReadFailure) {
-    for (const profileId of enabledIds) unavailable.set(profileId, pairingReadFailure);
-    return {
-      preferences,
-      ...(preferencesReadFailure ? { preferencesReadFailure } : {}),
-      pairingIntents,
-      pairingReadFailure,
-      remotes,
-      unavailable,
-    };
-  }
   for (const profileId of enabledIds) {
     try {
       remotes.push(await catalog.resolve(profileId));
@@ -168,6 +165,7 @@ export async function resolveDesktopRuntimeHostStartup(
     preferences,
     ...(preferencesReadFailure ? { preferencesReadFailure } : {}),
     pairingIntents,
+    ...(pairingReadFailure ? { pairingReadFailure } : {}),
     remotes,
     unavailable,
   };
@@ -177,18 +175,26 @@ export function createDesktopRuntimeHostProfileService(input: {
   readonly clientDataRoot: string;
   readonly startup: DesktopRuntimeHostStartup;
   readonly states: () => readonly RuntimeHostDesktopTargetState[];
-  readonly enable: (target: ResolvedRuntimeHostProfile) => Promise<void>;
+  readonly enable: (
+    target: ResolvedRuntimeHostProfile,
+    sshInteraction: "terminal" | "batch",
+  ) => Promise<void>;
   readonly disable: (profileId: string) => Promise<void>;
   readonly finalizePairing: (profileId: string) => Promise<void>;
   readonly setDefault: (profileId: string) => void;
   readonly catalog?: RuntimeHostProfileCatalog;
+  readonly credentialStore?: CredentialStore;
 }): DesktopRuntimeHostProfileService {
-  const catalog = input.catalog ?? createClientRuntimeHostProfileCatalog(input.clientDataRoot);
+  const credentialStore =
+    input.credentialStore ?? createClientRuntimeHostCredentialStore(input.clientDataRoot);
+  const catalog =
+    input.catalog ?? createClientRuntimeHostProfileCatalog(input.clientDataRoot, credentialStore);
   const preferencesPath = join(input.clientDataRoot, PREFERENCES_FILE);
   const profilePath = join(input.clientDataRoot, PROFILE_FILE);
   let preferences = input.startup.preferences;
   const unavailable = new Map(input.startup.unavailable);
   let pairingIntents = [...input.startup.pairingIntents];
+  let pairingReadFailure = input.startup.pairingReadFailure;
   let mutationTail = Promise.resolve();
 
   const mutate = <T>(operation: () => Promise<T>): Promise<T> => {
@@ -210,10 +216,10 @@ export function createDesktopRuntimeHostProfileService(input: {
           { cause: input.startup.preferencesReadFailure },
         );
       }
-      if (input.startup.pairingReadFailure) {
+      if (pairingReadFailure) {
         throw new Error(
-          "Runtime Host pairing recovery state could not be read; restart Maka before changing profiles",
-          { cause: input.startup.pairingReadFailure },
+          "Discard the unreadable Runtime Host pairing recovery state before changing profiles",
+          { cause: pairingReadFailure },
         );
       }
       return operation();
@@ -222,7 +228,7 @@ export function createDesktopRuntimeHostProfileService(input: {
   const persistPairingIntents = async (
     next: readonly DesktopRuntimeHostPairingIntent[],
   ): Promise<void> => {
-    await writeDesktopRuntimeHostPairingIntents(input.clientDataRoot, next);
+    await writeDesktopRuntimeHostPairingIntents(credentialStore, next);
     pairingIntents = [...next];
   };
 
@@ -251,6 +257,7 @@ export function createDesktopRuntimeHostProfileService(input: {
     const enabled = new Set(preferences.enabledRemoteProfileIds);
     return {
       defaultProfileId: preferences.defaultProfileId,
+      ...(pairingReadFailure ? { pairingRecoveryError: pairingReadFailure.message } : {}),
       entries: profiles.map((profile): DesktopRuntimeHostProfileEntry => {
         const isEnabled = profile.kind === "local" || enabled.has(profile.id);
         const state = states.get(profile.id);
@@ -288,6 +295,33 @@ export function createDesktopRuntimeHostProfileService(input: {
     );
   };
 
+  const ensureEnabled = async (target: ResolvedRuntimeHostProfile): Promise<void> => {
+    assertRootIsNotEnabled(target, preferences, await catalog.read(), input.states());
+    if (preferences.enabledRemoteProfileIds.includes(target.profile.id)) return;
+    const next = withEnabled(preferences, target.profile.id, true);
+    await persistIfCurrentTarget(catalog, profilePath, preferencesPath, target, next);
+    preferences = next;
+  };
+
+  const activateTarget = async (
+    target: ResolvedRuntimeHostProfile,
+    sshInteraction: "terminal" | "batch",
+  ): Promise<void> => {
+    try {
+      await input.enable(target, sshInteraction);
+      const current = await catalog.resolve(target.profile.id).catch(() => undefined);
+      if (!current || !sameResolvedRuntimeHostProfileTarget(current, target)) {
+        await input.disable(target.profile.id);
+        throw new Error("Runtime Host profile changed while it was connecting");
+      }
+      unavailable.delete(target.profile.id);
+    } catch (error) {
+      const failure = asError(error);
+      unavailable.set(target.profile.id, failure);
+      throw failure;
+    }
+  };
+
   const finishPairingIntent = async (
     intent: DesktopRuntimeHostPairingIntent,
   ): Promise<void> => {
@@ -295,20 +329,9 @@ export function createDesktopRuntimeHostProfileService(input: {
     if (!pairingIntentMatchesTarget(intent.target, target)) {
       throw new Error("Runtime Host profile changed before pairing could resume");
     }
-    assertRootIsNotEnabled(target, preferences, await catalog.read(), input.states());
-    if (!preferences.enabledRemoteProfileIds.includes(target.profile.id)) {
-      const next = withEnabled(preferences, target.profile.id, true);
-      await persistIfCurrentTarget(catalog, profilePath, preferencesPath, target, next);
-      preferences = next;
-    }
-    await input.enable(target);
-    const current = await catalog.resolve(target.profile.id).catch(() => undefined);
-    if (!current || !sameResolvedRuntimeHostProfileTarget(current, target)) {
-      await input.disable(target.profile.id);
-      throw new Error("Runtime Host profile changed while pairing was connecting");
-    }
+    await ensureEnabled(target);
+    await activateTarget(target, "terminal");
     await input.finalizePairing(target.profile.id);
-    unavailable.delete(target.profile.id);
     await forgetPairingIntentBestEffort(intent);
   };
 
@@ -353,7 +376,9 @@ export function createDesktopRuntimeHostProfileService(input: {
             (error) => rollbackFailures.push(error),
           );
           if (intent.wasEnabled) {
-            await input.enable(previousTarget).catch((error) => rollbackFailures.push(error));
+            await activateTarget(previousTarget, "terminal").catch((error) =>
+              rollbackFailures.push(error),
+            );
           }
         }
       }
@@ -426,26 +451,20 @@ export function createDesktopRuntimeHostProfileService(input: {
     );
     if (pairingIntent) return recoverPairingIntent(pairingIntent);
     const target = await catalog.resolve(profileId);
-    assertRootIsNotEnabled(target, preferences, await catalog.read(), input.states());
-    const next = withEnabled(preferences, profileId, true);
-    await persistIfCurrentTarget(catalog, profilePath, preferencesPath, target, next);
-    preferences = next;
+    await ensureEnabled(target);
     try {
-      await input.enable(target);
-      const current = await catalog.resolve(profileId).catch(() => undefined);
-      if (!current || !sameResolvedRuntimeHostProfileTarget(current, target)) {
-        await input.disable(profileId);
-        throw new Error("Runtime Host profile changed while it was connecting");
-      }
-      unavailable.delete(profileId);
+      await activateTarget(target, "terminal");
       return undefined;
     } catch (error) {
-      const failure = asError(error);
-      unavailable.set(profileId, failure);
-      return failure;
+      return asError(error);
     }
   };
 
+  const recoverPendingPairings = (): Promise<void> =>
+    mutateProfiles(async () => {
+      for (const intent of [...pairingIntents]) await recoverPairingIntent(intent);
+    });
+
   return {
     getSnapshot: () => mutate(snapshot),
     addAndEnable(value) {
@@ -477,6 +496,11 @@ export function createDesktopRuntimeHostProfileService(input: {
         const existing = currentDocument.profiles.find(
           (profile) => profile.rootId === value.profile.rootId,
         );
+        if (existing && !sameRemoteRuntimeHostProfileTarget(existing, value.profile)) {
+          throw new Error(
+            "This Runtime Host is already configured through another connection",
+          );
+        }
         const previousTarget = existing ? await catalog.resolve(existing.id) : undefined;
         const profile = existing ? { ...value.profile, id: existing.id } : value.profile;
         const target = { profile, credential: value.credential } as const;
@@ -510,9 +534,40 @@ export function createDesktopRuntimeHostProfileService(input: {
         }
       });
     },
-    recoverPendingPairings() {
-      return mutateProfiles(async () => {
-        for (const intent of [...pairingIntents]) await recoverPairingIntent(intent);
+    startEnabledProfiles() {
+      const pendingProfileIds = new Set(
+        pairingIntents.map((intent) => intent.target.profile.id),
+      );
+      const tasks: Promise<unknown>[] = [];
+      if (!pairingReadFailure) {
+        tasks.push(
+          recoverPendingPairings().catch((error) =>
+            console.error("[runtime-host] pairing recovery failed:", error),
+          ),
+        );
+      }
+      for (const target of input.startup.remotes) {
+        if (target.profile.kind !== "remote" || !target.credential) continue;
+        if (pendingProfileIds.has(target.profile.id)) continue;
+        const sshInteraction =
+          target.profile.transport.kind === "ssh" &&
+          target.profile.id === preferences.defaultProfileId
+            ? "terminal"
+            : "batch";
+        tasks.push(
+          activateTarget(target, sshInteraction).catch((error) =>
+            console.error(`[runtime-host] ${target.profile.name} is unavailable:`, error),
+          ),
+        );
+      }
+      return Promise.all(tasks).then(() => undefined);
+    },
+    discardPairingRecovery() {
+      return mutate(async () => {
+        if (!pairingReadFailure) return snapshot();
+        await writeDesktopRuntimeHostPairingIntents(credentialStore, []);
+        pairingReadFailure = undefined;
+        return snapshot();
       });
     },
     setEnabled(profileId, isEnabled) {
@@ -651,6 +706,7 @@ export function registerDesktopRuntimeHostProfileIpc(
     "runtime-host-profiles:set-enabled",
     "runtime-host-profiles:set-default",
     "runtime-host-profiles:remove",
+    "runtime-host-profiles:discard-pairing-recovery",
   ] as const;
   ipcMain.handle(channels[0], () => service.getSnapshot());
   ipcMain.handle(channels[1], (_event, value: DesktopRuntimeHostProfileAddInput) =>
@@ -661,6 +717,7 @@ export function registerDesktopRuntimeHostProfileIpc(
   );
   ipcMain.handle(channels[3], (_event, profileId: string) => service.setDefault(profileId));
   ipcMain.handle(channels[4], (_event, profileId: string) => service.remove(profileId));
+  ipcMain.handle(channels[5], () => service.discardPairingRecovery());
   return () => {
     for (const channel of channels) ipcMain.removeHandler(channel);
   };
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 5fe281dee..668885b2a 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -231,6 +231,7 @@ export interface DesktopRuntimeHostProfileEntry {
 export interface DesktopRuntimeHostProfileSnapshot {
   readonly entries: readonly DesktopRuntimeHostProfileEntry[];
   readonly defaultProfileId: string;
+  readonly pairingRecoveryError?: string;
 }
 
 export interface DesktopRuntimeHostRef {
@@ -432,6 +433,7 @@ export interface MakaBridge {
     remove(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
     setEnabled(profileId: string, enabled: boolean): Promise<DesktopRuntimeHostProfileSnapshot>;
     setDefault(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
+    discardPairingRecovery(): Promise<DesktopRuntimeHostProfileSnapshot>;
     subscribeChanges(
       handler: (event: DesktopRuntimeHostProfileChangedEvent) => void,
     ): () => void;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 0dc0bd3eb..e0598b2f0 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1027,6 +1027,9 @@ const makaBridge = {
     setDefault(profileId: string) {
       return ipcRenderer.invoke('runtime-host-profiles:set-default', profileId);
     },
+    discardPairingRecovery() {
+      return ipcRenderer.invoke('runtime-host-profiles:discard-pairing-recovery');
+    },
     subscribeChanges(handler: (event: DesktopRuntimeHostProfileChangedEvent) => 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 4ddfb39b4..26c116673 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -63,6 +63,10 @@ export type SettingsProjectsCopy = {
     selectFailed: string;
     saveFailed: string;
     removeFailed: string;
+    pairingRecoveryTitle: string;
+    pairingRecoveryDescription: string;
+    discardPairingRecovery: string;
+    discardPairingRecoveryFailed: string;
     moreActions(name: string): string;
   };
   section: string;
@@ -173,6 +177,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       selectFailed: '无法更新 Runtime Host',
       saveFailed: '无法保存 Runtime Host profile',
       removeFailed: '无法移除 Runtime Host profile',
+      pairingRecoveryTitle: '配对恢复需要处理',
+      pairingRecoveryDescription: '现有 Host 仍可使用;修改 Host 前需要丢弃已损坏的恢复数据。',
+      discardPairingRecovery: '丢弃恢复数据',
+      discardPairingRecoveryFailed: '无法丢弃配对恢复数据',
       moreActions: (name: string) => `更多操作:${name}`,
     },
     section: '工作区',
@@ -281,6 +289,11 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       selectFailed: 'Could not update the Runtime Host',
       saveFailed: 'Could not save the Runtime Host profile',
       removeFailed: 'Could not remove the Runtime Host profile',
+      pairingRecoveryTitle: 'Pairing recovery needs attention',
+      pairingRecoveryDescription:
+        'Existing Hosts remain available. Discard the unreadable recovery data before changing Hosts.',
+      discardPairingRecovery: 'Discard recovery data',
+      discardPairingRecoveryFailed: 'Could not discard pairing recovery data',
       moreActions: (name: string) => `More actions for ${name}`,
     },
     section: 'Workspace',
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 3d905c99a..564ff1b91 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -156,6 +156,23 @@ export function RuntimeHostProfilesSection(props: {
     }
   }
 
+  async function discardPairingRecovery() {
+    setSwitching(true);
+    try {
+      const next = await window.maka.runtimeHostProfiles.discardPairingRecovery();
+      if (mountedRef.current) setSnapshot(next);
+    } catch (error) {
+      if (mountedRef.current) {
+        toast.error(
+          copy.discardPairingRecoveryFailed,
+          settingsActionErrorMessage(error, locale),
+        );
+      }
+    } finally {
+      if (mountedRef.current) setSwitching(false);
+    }
+  }
+
   const remoteEntries = snapshot?.entries.filter((entry) => entry.profile.kind === "remote") ?? [];
   const profileOptions = (snapshot?.entries ?? [])
     .filter((entry) => entry.enabled)
@@ -205,6 +222,21 @@ export function RuntimeHostProfilesSection(props: {
           </HStack>
         }
       >
+        {snapshot?.pairingRecoveryError ? (
+          <SettingsRow
+            label={copy.pairingRecoveryTitle}
+            description={copy.pairingRecoveryDescription}
+            end={(
+              <Button
+                variant="secondary"
+                size="sm"
+                label={copy.discardPairingRecovery}
+                isDisabled={switching}
+                onClick={() => void discardPairingRecovery()}
+              />
+            )}
+          />
+        ) : null}
         {showAdd ? (
           <>
             <SettingsRow
diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index ad0e958f4..72ba8225a 100644
--- a/docs/runtime-host-remote-access.md
+++ b/docs/runtime-host-remote-access.md
@@ -69,7 +69,8 @@ maka runtime-host service status --json
 ```
 
 The install command persists the current exact Node and Maka CLI paths. Re-running it updates the
-same service; an omitted WebSocket port preserves the existing port. Before uninstalling the npm
+same systemd-supervised service; repeated startup failures stop automatic restarts and remain visible
+in `maka runtime-host service status`. An omitted WebSocket port preserves the existing port. Before uninstalling the npm
 package, remove the service with `maka runtime-host service uninstall`. Service uninstall keeps the
 State Root and Project data. Installation reports an actionable error instead of claiming persistence
 when systemd user lingering is disabled. Run service installation from a persistent global Maka
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 31ce62879..fa17cac2a 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -312,6 +312,8 @@ describe('managed Runtime Host service', () => {
     assert.match(unit, /"\/srv\/Maka 100%%"/u);
     assert.match(unit, /"Cash\$\$=\/home\/ada\/My Projects"/u);
     assert.match(unit, /^Restart=always$/mu);
+    assert.match(unit, /^StartLimitIntervalSec=60s$/mu);
+    assert.match(unit, /^StartLimitBurst=5$/mu);
   });
 
   it('emits one stable machine error for an unmet service prerequisite', async () => {
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index c92f5b371..65c0ecd09 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -167,10 +167,13 @@ export function renderSystemdUnit(config: RuntimeHostManagedServiceConfig): stri
     '[Unit]',
     'Description=Maka Runtime Host',
     'After=network.target',
+    'StartLimitIntervalSec=60s',
+    'StartLimitBurst=5',
     '',
     '[Service]',
     'Type=simple',
     `ExecStart=${args.map(quoteSystemdArgument).join(' ')}`,
+    // A clean idle exit must not silently remove a configured remote Host.
     'Restart=always',
     'RestartSec=2s',
     'KillMode=mixed',
diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts
index 2487f3821..3f9494b39 100644
--- a/packages/runtime-host/src/client/host-profile.ts
+++ b/packages/runtime-host/src/client/host-profile.ts
@@ -86,6 +86,13 @@ export function sameResolvedRuntimeHostProfileTarget(
   );
 }
 
+export function sameRemoteRuntimeHostProfileTarget(
+  left: RemoteRuntimeHostProfile,
+  right: RemoteRuntimeHostProfile,
+): boolean {
+  return profileCredentialBinding(left) === profileCredentialBinding(right);
+}
+
 export interface RuntimeHostProfileCatalog {
   read(): Promise<RuntimeHostProfileDocument>;
   resolve(profileId?: string): Promise<ResolvedRuntimeHostProfile>;
@@ -124,15 +131,18 @@ export function createFileRuntimeHostProfileCatalog(
 
 export function createClientRuntimeHostProfileCatalog(
   clientDataRoot: string,
+  credentialStore: CredentialStore = createClientRuntimeHostCredentialStore(clientDataRoot),
 ): RuntimeHostProfileCatalog {
   return createFileRuntimeHostProfileCatalog(
     join(clientDataRoot, 'runtime-host-profiles.json'),
-    createRuntimeHostProfileCredentialStore(
-      createFileCredentialStore(join(clientDataRoot, 'runtime-host-client')),
-    ),
+    createRuntimeHostProfileCredentialStore(credentialStore),
   );
 }
 
+export function createClientRuntimeHostCredentialStore(clientDataRoot: string): CredentialStore {
+  return createFileCredentialStore(join(clientDataRoot, 'runtime-host-client'));
+}
+
 export function createRuntimeHostProfileCredentialStore(
   credentials: Pick<CredentialStore, 'getSecret' | 'setSecret' | 'deleteSecret'>,
 ): RuntimeHostProfileCredentialStore {
diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts
index 3fabf2fcb..5bed235f8 100644
--- a/packages/runtime-host/src/client/index.ts
+++ b/packages/runtime-host/src/client/index.ts
@@ -10,12 +10,14 @@ export {
 export {
   LOCAL_RUNTIME_HOST_PROFILE,
   RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
+  createClientRuntimeHostCredentialStore,
   createClientRuntimeHostProfileCatalog,
   createFileRuntimeHostProfileCatalog,
   createRuntimeHostProfileCredentialStore,
   connectRemoteRuntimeHostProfile,
   decodeRemoteRuntimeHostProfile,
   remoteRuntimeHostUnavailableError,
+  sameRemoteRuntimeHostProfileTarget,
   sameResolvedRuntimeHostProfileTarget,
   type RemoteRuntimeHostProfile,
   type RuntimeHostRemoteTransport,


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