Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,14 @@ import { afterEach, test } from "node:test";
import {
createClientRuntimeHostProfileCatalog,
LOCAL_RUNTIME_HOST_PROFILE,
RuntimeHostRemoteCompatibilityError,
type ResolvedRuntimeHostProfile,
} from "@maka/runtime-host/client";
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
} from "@maka/runtime-host/protocol";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
Expand All@@ -22,6 +28,13 @@ const PROFILE = {
transport: { kind: "tls" as const, url: "wss://runtime.example.com" },
rootId: ROOT_ID,
};
const READY_PROFILE = {
id: "backup",
name: "Backup",
kind: "remote" as const,
transport: { kind: "tls" as const, url: "wss://backup.example.com" },
rootId: "b".repeat(64),
};
const temporaryDirectories: string[] = [];

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

test("projects a shared compatibility error through an unavailable enabled remote profile", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(PROFILE, "office-token");
await catalog.create(READY_PROFILE, "backup-token");
const compatibilityError = new RuntimeHostRemoteCompatibilityError("office", {
kind: "incompatible",
hostEpoch: "host-epoch",
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: "host-revision",
state: "ready",
replacement: "blocked_by_residency",
});
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [PROFILE.id, READY_PROFILE.id],
},
remotes: [
{ profile: PROFILE, credential: "office-token" },
{ profile: READY_PROFILE, credential: "backup-token" },
],
unavailable: new Map(),
},
states: () => [
connectingLocal(),
unavailable({ profile: PROFILE, credential: "office-token" }, compatibilityError),
ready({ profile: READY_PROFILE, credential: "backup-token" }),
],
enable: async () => undefined,
disable: async () => undefined,
setDefault: () => undefined,
catalog,
});

const snapshot = await service.getSnapshot();
const office = snapshot.entries.find((entry) => entry.profile.id === PROFILE.id);
const backup = snapshot.entries.find((entry) => entry.profile.id === READY_PROFILE.id);
const local = snapshot.entries.find((entry) => entry.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id);

assert.equal(office?.enabled, true);
assert.equal(office?.readiness, "unavailable");
assert.equal(office?.message, compatibilityError.message);
assert.equal(local?.enabled, true);
assert.equal(local?.readiness, "connecting");
assert.equal(backup?.enabled, true);
assert.equal(backup?.readiness, "ready");
assert.equal(backup?.message, undefined);
});

test("keeps enablement, default selection, and removal as separate states", async () => {
const root = await clientRoot();
await createClientRuntimeHostProfileCatalog(root).create(PROFILE, "token");
Expand DownExpand Up@@ -264,3 +333,26 @@ function connecting(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTarge
readiness: "connecting",
};
}

function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "ready",
candidate: {
client: { hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID },
} as never,
};
}

function unavailable(
target: ResolvedRuntimeHostProfile,
error: Error,
): RuntimeHostDesktopTargetState {
return {
epoch: `epoch-${target.profile.id}`,
target,
readiness: "unavailable",
error,
};
}
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,14 @@ maka run --host office --project '<projectId>' "Summarize this project"

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

## Compatibility troubleshooting

`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).

Use compatible Client and Host builds. After updating the Host, the operator must restart its remote Runtime Host service, then retry the connection.

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.

## Security boundaries

- Do not put credentials on the command line or in Profile JSON.
Expand Down
8 changes: 8 additions & 0 deletions docs/runtime-host-remote-access.zh-CN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,14 @@ maka run --host office --project '<projectId>' "总结这个项目"

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

## 兼容性排查

`RUNTIME_HOST_REMOTE_INCOMPATIBLE` 表示 Client 与远程 Runtime Host 无法安全通信。先比较 Client 与 Host 的 compatibility epoch;当诊断中提供相关信息时,也应检查 Client 和 Host 的 protocol range、composition ID,以及 Host 的 composition revision。

请使用彼此兼容的 Client 和 Host build。更新 Host 后,由 Host 的 operator 重启远程 Runtime Host service,然后重试连接。

Remote Client 不会自动升级或重启 Host、降级 transport、修改 Profile、默认 Host 或 Session,也不会在此诊断中暴露 credential、endpoint、path 或 State Root。

## 安全边界

- 不要把 credential 放在命令行或 Profile JSON 中。
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,19 @@ import { fileURLToPath } from 'node:url';
import {
connectRemoteRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
RuntimeHostRemoteCompatibilityError,
RuntimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfileCatalog,
type RemoteRuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type ClientSurface,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
Expand DownExpand Up@@ -310,6 +316,83 @@ test('remote CLI profile state and Client identity use the explicit Client Data
await context.close();
});

test('CLI and TUI remote profiles preserve shared compatibility errors', async () => {
const cases: readonly {
readonly surface: Extract<ClientSurface, 'run' | 'tui'>;
readonly handshake: HostIncompatible;
}[] = [
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
}),
},
{
surface: 'tui',
handshake: incompatibleRemoteHandshake({
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION + 1,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION + 2,
}),
},
{
surface: 'run',
handshake: incompatibleRemoteHandshake({
compositionId: 'maka.other-composition',
compositionRevision: 'other-revision',
}),
},
];

for (const { surface, handshake } of cases) {
const profile: RemoteRuntimeHostProfile = {
id: `office-${surface}-${handshake.compositionRevision}`,
name: 'Office',
kind: 'remote',
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
rootId: 'c'.repeat(64),
};
await assert.rejects(
() =>
connectRuntimeHostCli(
{ rootPath: '/unused-local-root', surface, profileId: profile.id },
{
connectRemoteProfile: (input) =>
connectRemoteRuntimeHostProfile(input, {
connect: async () => ({ kind: 'incompatible', handshake }),
}),
profileCatalog: singleRemoteProfileCatalog(profile),
loadClientInstanceId: async () => '33333333-3333-4333-8333-333333333333',
},
),
(error: unknown) => {
assert.ok(error instanceof RuntimeHostRemoteCompatibilityError);
assert.equal(error.code, 'RUNTIME_HOST_REMOTE_INCOMPATIBLE');
assert.equal(
error.message,
new RuntimeHostRemoteCompatibilityError(profile.id, handshake).message,
);
assert.deepEqual(error.details, {
profileId: profile.id,
client: {
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
},
host: {
compatibilityEpoch: handshake.compatibilityEpoch,
protocolMin: handshake.protocolMin,
protocolMax: handshake.protocolMax,
compositionId: handshake.compositionId,
compositionRevision: handshake.compositionRevision,
},
});
return true;
},
);
}
});

function hostRegistration(
overrides: Partial<{
compatibilityEpoch: number;
Expand All@@ -334,3 +417,32 @@ function hostRegistration(
...overrides,
};
}

function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}): HostIncompatible {
return {
kind: 'incompatible',
hostEpoch: 'remote-host-epoch',
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'remote-host-revision',
state: 'ready',
replacement: 'blocked_by_residency',
...overrides,
};
}

function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
return {
read: async () => ({ schemaVersion: 1, profiles: [profile] }),
resolve: async (profileId) => {
assert.equal(profileId, profile.id);
return { profile, credential: 'opaque-token' };
},
create: async () => assert.fail('unexpected write'),
save: async () => assert.fail('unexpected write'),
remove: async () => assert.fail('unexpected write'),
removeIfCurrent: async () => assert.fail('unexpected write'),
};
}
Loading
Loading