Skip to content

Commit 074f719

Browse files
authored
fix(runtime-host): retain actionable remote compatibility details (#3246)
Preserve typed Runtime Host compatibility details across remote profile failures while sanitizing Host-controlled identifiers at the human-readable projection boundary. Surface actionable recovery guidance in Terminal and Desktop without making Local unavailable. Generated-by: Codex
1 parent abb5928 commit 074f719

9 files changed

Lines changed: 577 additions & 4 deletions

File tree

‎apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
66
import {
77
createClientRuntimeHostProfileCatalog,
88
LOCAL_RUNTIME_HOST_PROFILE,
9+
RuntimeHostRemoteCompatibilityError,
910
type ResolvedRuntimeHostProfile,
1011
} from "@maka/runtime-host/client";
12+
import {
13+
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
14+
RUNTIME_HOST_COMPATIBILITY_EPOCH,
15+
RUNTIME_HOST_PROTOCOL_VERSION,
16+
} from "@maka/runtime-host/protocol";
1117
import {
1218
createDesktopRuntimeHostProfileService,
1319
resolveDesktopRuntimeHostStartup,
@@ -22,6 +28,13 @@ const PROFILE = {
2228
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
2329
rootId: ROOT_ID,
2430
};
31+
const READY_PROFILE = {
32+
id: "backup",
33+
name: "Backup",
34+
kind: "remote" as const,
35+
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
36+
rootId: "b".repeat(64),
37+
};
2538
const temporaryDirectories: string[] = [];
2639

2740
afterEach(async () => {
@@ -217,6 +230,62 @@ test("preserves an enabled remote profile when that Host is unavailable", async
217230
assert.equal(result.snapshot.entries.find((entry) => entry.profile.id === "local")?.enabled, true);
218231
});
219232

233+
test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
234+
const root = await clientRoot();
235+
const catalog = createClientRuntimeHostProfileCatalog(root);
236+
await catalog.create(PROFILE, "office-token");
237+
await catalog.create(READY_PROFILE, "backup-token");
238+
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
239+
kind: "incompatible",
240+
hostEpoch: "host-epoch",
241+
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
242+
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
243+
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
244+
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
245+
compositionRevision: "host-revision",
246+
state: "ready",
247+
replacement: "blocked_by_residency",
248+
});
249+
const service = createDesktopRuntimeHostProfileService({
250+
clientDataRoot: root,
251+
startup: {
252+
preferences: {
253+
schemaVersion: 2,
254+
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
255+
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
256+
},
257+
remotes: [
258+
{ profile: PROFILE, credential: "office-token" },
259+
{ profile: READY_PROFILE, credential: "backup-token" },
260+
],
261+
unavailable: new Map(),
262+
},
263+
states: () => [
264+
connectingLocal(),
265+
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
266+
ready({ profile: READY_PROFILE, credential: "backup-token" }),
267+
],
268+
enable: async () => undefined,
269+
disable: async () => undefined,
270+
setDefault: () => undefined,
271+
catalog,
272+
});
273+
274+
const snapshot = await service.getSnapshot();
275+
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
276+
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
277+
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);
278+
279+
assert.equal(office?.enabled, true);
280+
assert.equal(office?.readiness, "unavailable");
281+
assert.equal(office?.message, compatibilityError.message);
282+
assert.equal(local?.enabled, true);
283+
assert.equal(local?.readiness, "connecting");
284+
assert.equal(backup?.enabled, true);
285+
assert.equal(backup?.readiness, "ready");
286+
assert.equal(backup?.message, undefined);
287+
});
288+
220289
test("keeps enablement, default selection, and removal as separate states", async () => {
221290
const root = await clientRoot();
222291
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
264333
readiness: "connecting",
265334
};
266335
}
336+
337+
function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
338+
return {
339+
epoch: `epoch-${target.profile.id}`,
340+
target,
341+
readiness: "ready",
342+
candidate: {
343+
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
344+
} as never,
345+
};
346+
}
347+
348+
function unavailable(
349+
target: ResolvedRuntimeHostProfile,
350+
error: Error,
351+
): RuntimeHostDesktopTargetState {
352+
return {
353+
epoch: `epoch-${target.profile.id}`,
354+
target,
355+
readiness: "unavailable",
356+
error,
357+
};
358+
}

‎docs/runtime-host-remote-access.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"
167167

168168
Each TUI or CLI process connects to one Profile. TUI may interact with SSH during its initial connection; non-interactive commands require preconfigured authentication.
169169

170+
## Compatibility troubleshooting
171+
172+
`RUNTIME_HOST_REMOTE_INCOMPATIBLE` means the Client and remote Runtime Host cannot safely communicate. Compare the Client and Host compatibility epochs first. When the diagnostic reports them, also inspect the Client and Host protocol ranges and composition IDs (including the Host composition revision).
173+
174+
Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.
175+
176+
Remote Clients never auto-upgrade or restart the Host, downgrade the transport, mutate the Profile, change the default Host or Session, or expose credentials, endpoints, paths, or State Roots in this diagnostic.
177+
170178
## Security boundaries
171179

172180
- Do not put credentials on the command line or in Profile JSON.

‎docs/runtime-host-remote-access.zh-CN.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"
161161

162162
每个 TUI 或 CLI 进程只连接一个 Profile。TUI 的首次 SSH 连接可以交互;非交互命令要求提前配置认证。
163163

164+
## 兼容性排查
165+
166+
`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。
167+
168+
请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。
169+
170+
Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。
171+
164172
## 安全边界
165173

166174
- 不要把 credential 放在命令行或 Profile JSON 中。

‎packages/cli/src/__tests__/runtime-host-cli-context.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
77
import {
88
connectRemoteRuntimeHostProfile,
99
createClientRuntimeHostProfileCatalog,
10+
RuntimeHostRemoteCompatibilityError,
1011
RuntimeHostStartupError,
1112
type RuntimeHostConnection,
13+
type RuntimeHostProfileCatalog,
14+
type RemoteRuntimeHostProfile,
1215
} from '@maka/runtime-host/client';
1316
import {
1417
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
1518
RUNTIME_HOST_COMPATIBILITY_EPOCH,
19+
RUNTIME_HOST_PROTOCOL_VERSION,
1620
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
21+
type ClientSurface,
22+
type HostIncompatible,
1723
} from '@maka/runtime-host/protocol';
1824
import {
1925
connectRuntimeHostCli,
@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
310316
await context.close();
311317
});
312318

319+
test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
320+
const cases: readonly {
321+
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
322+
readonly handshake: HostIncompatible;
323+
}[] = [
324+
{
325+
surface: 'run',
326+
handshake: incompatibleRemoteHandshake({
327+
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
328+
}),
329+
},
330+
{
331+
surface: 'tui',
332+
handshake: incompatibleRemoteHandshake({
333+
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
334+
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
335+
}),
336+
},
337+
{
338+
surface: 'run',
339+
handshake: incompatibleRemoteHandshake({
340+
compositionId: 'maka.other-composition',
341+
compositionRevision: 'other-revision',
342+
}),
343+
},
344+
];
345+
346+
for (const { surface, handshake } of cases) {
347+
const profile: RemoteRuntimeHostProfile = {
348+
id: `office-${surface}-${handshake.compositionRevision}`,
349+
name: 'Office',
350+
kind: 'remote',
351+
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
352+
rootId: 'c'.repeat(64),
353+
};
354+
await assert.rejects(
355+
() =>
356+
connectRuntimeHostCli(
357+
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
358+
{
359+
connectRemoteProfile: (input) =>
360+
connectRemoteRuntimeHostProfile(input, {
361+
connect: async () => ({ kind: 'incompatible', handshake }),
362+
}),
363+
profileCatalog: singleRemoteProfileCatalog(profile),
364+
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
365+
},
366+
),
367+
(error: unknown) => {
368+
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
369+
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
370+
assert.equal(
371+
error.message,
372+
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
373+
);
374+
assert.deepEqual(error.details, {
375+
profileId: profile.id,
376+
client: {
377+
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
378+
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
379+
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
380+
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
381+
},
382+
host: {
383+
compatibilityEpoch: handshake.compatibilityEpoch,
384+
protocolMin: handshake.protocolMin,
385+
protocolMax: handshake.protocolMax,
386+
compositionId: handshake.compositionId,
387+
compositionRevision: handshake.compositionRevision,
388+
},
389+
});
390+
return true;
391+
},
392+
);
393+
}
394+
});
395+
313396
function hostRegistration(
314397
overrides: Partial<{
315398
compatibilityEpoch: number;
@@ -334,3 +417,32 @@ function hostRegistration(
334417
...overrides,
335418
};
336419
}
420+
421+
function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
422+
return {
423+
kind: 'incompatible',
424+
hostEpoch: 'remote-host-epoch',
425+
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
426+
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
427+
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
428+
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
429+
compositionRevision: 'remote-host-revision',
430+
state: 'ready',
431+
replacement: 'blocked_by_residency',
432+
...overrides,
433+
};
434+
}
435+
436+
function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
437+
return {
438+
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
439+
resolve: async (profileId) => {
440+
assert.equal(profileId, profile.id);
441+
return { profile, credential: 'opaque-token' };
442+
},
443+
create: async () => assert.fail('unexpected write'),
444+
save: async () => assert.fail('unexpected write'),
445+
remove: async () => assert.fail('unexpected write'),
446+
removeIfCurrent: async () => assert.fail('unexpected write'),
447+
};
448+
}

0 commit comments

Comments
 (0)