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