From 9b7e40279fea84cdb34bfa124f2da7518c6509bd Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 00:11:55 +0800 Subject: [PATCH 01/19] feat(runtime-host): bind provider secrets to Client profiles Store TUI capability-provider credentials separately from terminal access credentials and key them by both the immutable remote target and the owning Client identity. Expose the established CLI Client identity to the TUI assembly without placing either credential in profile metadata. Generated-by: Codex --- .../runtime-host-cli-context.test.ts | 2 + packages/cli/src/runtime-host-cli-context.ts | 8 +- .../src/__tests__/host-profile.test.ts | 25 ++++++ .../runtime-host/src/client/host-profile.ts | 77 +++++++++++++++++-- packages/runtime-host/src/client/index.ts | 3 + packages/storage/src/credential-store.ts | 9 ++- 6 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 9c64a96b38..d7b200b8f5 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -94,6 +94,7 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () = assert.ok(candidateEntrypoint instanceof URL); assert.equal(basename(fileURLToPath(candidateEntrypoint)), 'execution-candidate-main.js'); assert.ok(clientInstanceId); + assert.equal(context.clientInstanceId, clientInstanceId); await context.close(); assert.equal(closes, 1); }); @@ -309,6 +310,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p assert.equal(remoteInput?.profile.rootId, rootId); assert.equal(remoteInput?.credential, 'opaque-token'); assert.equal(remoteInput?.clientInstanceId, '11111111-1111-4111-8111-111111111111'); + assert.equal(context.clientInstanceId, '11111111-1111-4111-8111-111111111111'); assert.equal(Object.hasOwn(context.profile, 'credential'), false); await context.close(); }); diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index 41c64c5cb9..10d3edd114 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -90,6 +90,11 @@ export interface RuntimeHostCliConnectionContext { close(): Promise; } +export interface RuntimeHostCliConnectionContextWithIdentity + extends RuntimeHostCliConnectionContext { + readonly clientInstanceId: string; +} + export interface RuntimeHostCliTarget { readonly connection: ConnectionCatalogEntry; readonly model: string; @@ -114,7 +119,7 @@ export async function connectRuntimeHostCli( readonly interactiveSsh?: boolean; }, overrides: Partial = {}, -): Promise { +): Promise { const deps: RuntimeHostCliContextDeps = { connectOrSpawn: connectOrSpawnRuntimeHost, connectProfile: connectRuntimeHostProfile, @@ -201,6 +206,7 @@ export async function connectRuntimeHostCli( connection: liveConnection, catalog: await deps.readConnectionCatalog(liveConnection), profile, + clientInstanceId, close: async () => { try { await liveConnection.close(); diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 23a6197b46..76a08b4115 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -30,6 +30,7 @@ import { import { connectRemoteRuntimeHostProfile, createFileRuntimeHostProfileCatalog, + createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, decodeRuntimeHostProfileDocument, RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT, @@ -418,6 +419,30 @@ describe('Runtime Host profiles', () => { assert.equal(await credentials.get(targetA), 'token-a'); }); + test('isolates capability-provider credentials by target and owning Client', async () => { + const path = await profilePath(); + const credentials = createRuntimeHostCapabilityProviderCredentialStore( + createFileCredentialStore(join(dirname(path), 'credentials')), + ); + const targetA = remoteProfile('office', 'wss://a.example.com', ROOT_A); + const targetB = remoteProfile('office', 'wss://b.example.com', ROOT_B); + + await assert.rejects( + () => credentials.set(targetA, 'owner-a', 'not a token'), + /credential is invalid/, + ); + await credentials.set(targetA, 'owner-a', 'provider-a'); + await credentials.set(targetA, 'owner-b', 'provider-b'); + await credentials.set(targetB, 'owner-a', 'provider-other-target'); + + assert.equal(await credentials.get(targetA, 'owner-a'), 'provider-a'); + assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); + assert.equal(await credentials.get(targetB, 'owner-a'), 'provider-other-target'); + await credentials.delete(targetA, 'owner-a'); + assert.equal(await credentials.get(targetA, 'owner-a'), null); + assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); + }); + test('pins a direct-peer profile to its PeerId while allowing route discovery to change', () => { const original = directPeerProfile('peer-a', ['/ip4/192.0.2.10/udp/4001/quic-v1']); const moved = directPeerProfile('peer-a', ['/ip6/2001:db8::10/udp/4001/quic-v1']); diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index f3230ff452..4af31547d1 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -26,6 +26,7 @@ import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, isCanonicalRuntimeHostWebSocketPath, RUNTIME_HOST_PROTOCOL_VERSION, + requireClientInstanceId, requireHostRootId, } from '../protocol/index.js'; import type { RuntimeHostProfileOfKind } from '../profile-kind.js'; @@ -220,6 +221,16 @@ export interface RuntimeHostProfileCredentialStore { delete(profile: RemoteRuntimeHostProfile): Promise; } +export interface RuntimeHostCapabilityProviderCredentialStore { + get(profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string): Promise; + set( + profile: RemoteRuntimeHostProfile, + ownerClientInstanceId: string, + credential: string, + ): Promise; + delete(profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string): Promise; +} + export function createFileRuntimeHostProfileCatalog( path: string, credentials: RuntimeHostProfileCredentialStore, @@ -249,12 +260,10 @@ export function createRuntimeHostProfileCredentialStore( return credentials.getSecret(profileCredentialSlot(profile), 'runtime_host_access'); }, set: (profile, credential) => { - if ( - !credential || - /\s/u.test(credential) || - Buffer.byteLength(credential, 'utf8') > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES - ) { - return Promise.reject(new Error('Runtime Host access credential is invalid')); + try { + requireRuntimeHostAccessCredential(credential); + } catch (error) { + return Promise.reject(error); } return credentials.setSecret( profileCredentialSlot(profile), @@ -267,6 +276,39 @@ export function createRuntimeHostProfileCredentialStore( }; } +export function createRuntimeHostCapabilityProviderCredentialStore( + credentials: Pick, +): RuntimeHostCapabilityProviderCredentialStore { + return { + get: (profile, ownerClientInstanceId) => + credentials.getSecret( + capabilityProviderCredentialSlot(profile, ownerClientInstanceId), + 'runtime_host_capability_provider', + ), + set: (profile, ownerClientInstanceId, credential) => { + try { + requireRuntimeHostAccessCredential(credential); + } catch (error) { + return Promise.reject(error); + } + return credentials.setSecret( + capabilityProviderCredentialSlot(profile, ownerClientInstanceId), + 'runtime_host_capability_provider', + credential, + ); + }, + delete: (profile, ownerClientInstanceId) => + credentials.deleteSecret( + capabilityProviderCredentialSlot(profile, ownerClientInstanceId), + 'runtime_host_capability_provider', + ), + }; +} + +export function runtimeHostProfileTargetFingerprint(profile: RemoteRuntimeHostProfile): string { + return profileCredentialBinding(profile); +} + export async function connectRuntimeHostProfile( input: { readonly profile: PersistedRuntimeHostProfile; @@ -1074,6 +1116,29 @@ function profileCredentialSlot(profile: RemoteRuntimeHostProfile): string { return `runtime-host-profile:${requireProfileId(profile.id)}:${profileCredentialBinding(profile)}`; } +function capabilityProviderCredentialSlot( + profile: RemoteRuntimeHostProfile, + ownerClientInstanceId: string, +): string { + return [ + 'runtime-host-profile-capability-provider', + requireProfileId(profile.id), + profileCredentialBinding(profile), + createHash('sha256').update(requireClientInstanceId(ownerClientInstanceId)).digest('hex'), + ].join(':'); +} + +function requireRuntimeHostAccessCredential(credential: string): string { + if ( + !credential || + /\s/u.test(credential) || + Buffer.byteLength(credential, 'utf8') > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES + ) { + throw new Error('Runtime Host access credential is invalid'); + } + return credential; +} + function profileTargetBinding(profile: PersistedRuntimeHostProfile): string { if (profile.kind === 'remote') return `remote\0${profileCredentialBinding(profile)}`; const normalized = decodeEnvironmentRuntimeHostProfile(profile); diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index e898eb0e87..ebf6518567 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -44,6 +44,7 @@ export { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, createFileRuntimeHostProfileCatalog, + createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, connectRuntimeHostProfile, connectRemoteRuntimeHostProfile, @@ -52,6 +53,7 @@ export { decodeRemoteRuntimeHostProfile, remoteRuntimeHostUnavailableError, runtimeHostProfileAccess, + runtimeHostProfileTargetFingerprint, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, type EnvironmentRuntimeHostProfile, @@ -63,6 +65,7 @@ export { type RuntimeHostProfileAccess, type RuntimeHostProfileCatalog, type RuntimeHostConnectionPhase, + type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostProfileDocument, } from './host-profile.js'; export { diff --git a/packages/storage/src/credential-store.ts b/packages/storage/src/credential-store.ts index eaaf81cd95..325227e8d5 100644 --- a/packages/storage/src/credential-store.ts +++ b/packages/storage/src/credential-store.ts @@ -52,7 +52,8 @@ type StoredCredentialKind = | 'botAppSecret' | 'proxyPassword' | 'tavilyApiKey' - | 'runtimeHostAccess'; + | 'runtimeHostAccess' + | 'runtimeHostCapabilityProvider'; export type CredentialKind = | 'api_key' | 'oauth_token' @@ -61,7 +62,8 @@ export type CredentialKind = | 'app_secret' | 'proxy_password' | 'tavily_api_key' - | 'runtime_host_access'; + | 'runtime_host_access' + | 'runtime_host_capability_provider'; /** Current on-disk schema version. Unknown versions fail closed on read. */ export const CREDENTIAL_SCHEMA_VERSION = 1; @@ -349,6 +351,7 @@ const STORED_CREDENTIAL_KINDS = [ 'proxyPassword', 'tavilyApiKey', 'runtimeHostAccess', + 'runtimeHostCapabilityProvider', ] as const satisfies readonly StoredCredentialKind[]; function toStoredKind(kind: CredentialKind): StoredCredentialKind { @@ -369,5 +372,7 @@ function toStoredKind(kind: CredentialKind): StoredCredentialKind { return 'tavilyApiKey'; case 'runtime_host_access': return 'runtimeHostAccess'; + case 'runtime_host_capability_provider': + return 'runtimeHostCapabilityProvider'; } } From be6a9467e8830277dc8637d79fed2a8ad78f9d52 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 00:21:33 +0800 Subject: [PATCH 02/19] feat(tui): publish MCP tools to remote Hosts Run one profile-bound capability-provider companion for remote TUI profiles while retaining the existing TUI MCP manager and publication queue as the only configuration and publication authorities. Surface missing, rejected, and target-mismatched provider credentials in /mcp, and close the companion deterministically on credential rotation or TUI shutdown. Generated-by: Codex --- .../src/__tests__/pi-tui-mcp-status.test.ts | 22 ++ .../cli/src/__tests__/tui-mcp-control.test.ts | 69 +++- .../tui-mcp-remote-publication.test.ts | 194 ++++++++++++ packages/cli/src/pi-tui-mcp-status.ts | 58 +++- packages/cli/src/runtime-host-tui-context.ts | 12 +- packages/cli/src/tui-copy-catalog.ts | 10 + packages/cli/src/tui-mcp-control.ts | 108 ++++++- .../cli/src/tui-mcp-remote-publication.ts | 295 ++++++++++++++++++ .../src/__tests__/host-profile.test.ts | 9 +- .../runtime-host/src/client/host-profile.ts | 43 ++- packages/runtime-host/src/client/index.ts | 2 + 11 files changed, 798 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts create mode 100644 packages/cli/src/tui-mcp-remote-publication.ts diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts index 81770f71bb..8bc43bf5ed 100644 --- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts +++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts @@ -68,6 +68,28 @@ describe('MCP management overlay', () => { assert.doesNotMatch(text, /尚未配置/u); }); + test('surfaces remote provider credential state without rendering a secret value', () => { + const overlay = new McpManagementOverlay({ + locale: 'en', + surface: surface({ + initialization: 'ready', + configuration: 'ready', + publication: 'credential_rejected', + canManagePublicationCredential: true, + toolCount: 0, + servers: [], + }), + viewportRows: () => 8, + onClose: () => undefined, + onChange: () => undefined, + }); + + const text = overlay.render(160).map(stripAnsi).join('\n'); + assert.match(text, /provider credential rejected/u); + assert.match(text, /p Set provider credential/u); + assert.doesNotMatch(text, /maka_rh_/u); + }); + test('localizes manager states without changing their source values', () => { const overlay = new McpManagementOverlay({ locale: 'zh', diff --git a/packages/cli/src/__tests__/tui-mcp-control.test.ts b/packages/cli/src/__tests__/tui-mcp-control.test.ts index f4f788d259..d9e8ed7dd9 100644 --- a/packages/cli/src/__tests__/tui-mcp-control.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-control.test.ts @@ -29,7 +29,7 @@ import type { RuntimeHostConnectionAvailability, } from '@maka/runtime-host/client'; import { createMcpConfigStore } from '@maka/storage/mcp-config-store'; -import { createTuiMcpController } from '../tui-mcp-control.js'; +import { createTuiMcpController, type TuiMcpPublicationAvailability } from '../tui-mcp-control.js'; import { waitFor } from './tui-terminal-mock.js'; test('TUI MCP startup stays backgrounded and publishes the discovered snapshot', async () => { @@ -71,6 +71,73 @@ test('TUI MCP startup stays backgrounded and publishes the discovered snapshot', assert.equal(manager.closed, 1); }); +test('TUI MCP serializes remote provider credential changes through its publication lane', async () => { + let availability: TuiMcpPublicationAvailability = { + kind: 'unavailable', + reason: 'credential_required', + }; + let listener: ((value: TuiMcpPublicationAvailability) => void) | undefined; + const credentials: string[] = []; + let removed = 0; + let closed = 0; + const connection = { + replaceClientCapabilities: async () => ({ registrationId: 'registration', revision: 1 }), + unregisterClientCapabilities: async () => ({ registrationId: 'registration', revision: 1 }), + subscribeConnectionAvailability: (next: (value: TuiMcpPublicationAvailability) => void) => { + listener = next; + next(availability); + return () => { + if (listener === next) listener = undefined; + }; + }, + setCredential: async (credential: string) => { + credentials.push(credential); + availability = { kind: 'connected', hostEpoch: 'host-1', connectionId: 'provider-1' }; + listener?.(availability); + }, + removeCredential: async () => { + removed += 1; + availability = { kind: 'unavailable', reason: 'credential_required' }; + listener?.(availability); + }, + closePublication: async () => { + closed += 1; + }, + }; + const manager = managerHarness(0, []); + const controller = createTuiMcpController( + { workspaceRoot: '/unused', connection }, + { + configStore: configStoreHarness(async () => emptyConfig()), + manager: manager.manager, + createProvider: () => undefined, + }, + ); + await waitFor( + () => controller.snapshot().initialization === 'ready', + 'remote MCP controller initialization', + ); + assert.equal(controller.snapshot().publication, 'credential_required'); + assert.equal(controller.snapshot().canManagePublicationCredential, true); + + assert.deepEqual( + await controller.execute({ + kind: 'set_publication_credential', + credential: 'provider-secret', + }), + { status: 'applied', effect: 'published' }, + ); + assert.deepEqual(credentials, ['provider-secret']); + assert.deepEqual(await controller.execute({ kind: 'remove_publication_credential' }), { + status: 'applied', + effect: 'pending_host', + }); + assert.equal(removed, 1); + assert.equal(controller.snapshot().publication, 'credential_required'); + await controller.close(); + assert.equal(closed, 1); +}); + test('TUI MCP publication coalesces a discovery change behind the in-flight revision', async () => { const manager = managerHarness(1, [connectedStatus('local', 1)]); const connection = connectionHarness(); diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts new file mode 100644 index 0000000000..ab0d57cc1e --- /dev/null +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + RuntimeHostProfileConnectionError, + type RemoteRuntimeHostProfile, + type RuntimeHostCapabilityProviderCredentialStore, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; +import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; +import { waitFor } from './tui-terminal-mock.js'; + +const PROFILE: RemoteRuntimeHostProfile = { + id: 'office', + name: 'Office', + kind: 'remote', + transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' }, + rootId: 'a'.repeat(64), +}; + +test('remote TUI publication activates, rotates, and removes one profile-bound credential', async () => { + const credentials = credentialHarness(); + const connected: Array<{ credential?: string; clientInstanceId: string }> = []; + const connections: ConnectionHarness[] = []; + const identityPaths: string[] = []; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async (path) => { + identityPaths.push(path); + return 'provider-client'; + }, + connectProfile: async (input) => { + connected.push({ + credential: input.credential, + clientInstanceId: input.clientInstanceId, + }); + const connection = connectionHarness(`connection-${connections.length + 1}`); + connections.push(connection); + return connection.connection; + }, + }, + ); + let latest = await availability(target); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'credential_required' }); + + await target.setCredential?.('provider-secret-a'); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + assert.deepEqual(connected, [ + { credential: 'provider-secret-a', clientInstanceId: 'provider-client' }, + ]); + assert.equal(credentials.values.get('office\0terminal-client'), 'provider-secret-a'); + assert.match(identityPaths[0] ?? '', /capability-provider-identities/u); + + await target.setCredential?.('provider-secret-b'); + await waitFor( + () => latest().kind === 'connected' && connections.length === 2, + 'rotated provider companion to connect', + ); + assert.equal(connections[0]?.unregisters, 0); + assert.equal(connections[0]?.closes, 1); + assert.equal(credentials.values.get('office\0terminal-client'), 'provider-secret-b'); + assert.equal(identityPaths[0], identityPaths[1]); + + await target.removeCredential?.(); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'credential_required' }); + assert.equal(connections[1]?.unregisters, 0); + assert.equal(connections[1]?.closes, 1); + assert.equal(credentials.values.has('office\0terminal-client'), false); + await target.closePublication?.(); +}); + +test('remote TUI publication surfaces rejected credentials without a retry authority', async () => { + const credentials = credentialHarness('revoked-secret'); + let attempts = 0; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => { + attempts += 1; + throw new RuntimeHostProfileConnectionError( + 'credential_rejected', + 'Runtime Host rejected its access credential', + ); + }, + }, + ); + const latest = await availability(target); + await waitFor(() => { + const current = latest(); + return current.kind === 'unavailable' && current.reason === 'credential_rejected'; + }, 'rejected provider credential state'); + assert.equal(attempts, 1); + await target.closePublication?.(); +}); + +async function availability(target: ReturnType) { + let current: Parameters[0]>[0] = { + kind: 'unavailable', + }; + target.subscribeConnectionAvailability((next) => { + current = next; + }); + await new Promise((resolve) => setImmediate(resolve)); + return () => current; +} + +function credentialHarness(initial?: string) { + const values = new Map(); + if (initial) values.set('office\0terminal-client', initial); + const key = (profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string) => + `${profile.id}\0${ownerClientInstanceId}`; + const store: RuntimeHostCapabilityProviderCredentialStore = { + get: async (profile, ownerClientInstanceId) => + values.get(key(profile, ownerClientInstanceId)) ?? null, + set: async (profile, ownerClientInstanceId, credential) => { + values.set(key(profile, ownerClientInstanceId), credential); + }, + delete: async (profile, ownerClientInstanceId) => { + values.delete(key(profile, ownerClientInstanceId)); + }, + }; + return { store, values }; +} + +interface ConnectionHarness { + connection: RuntimeHostConnection; + unregisters: number; + closes: number; +} + +function connectionHarness(connectionId: string): ConnectionHarness { + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const harness: ConnectionHarness = { + connection: undefined as unknown as RuntimeHostConnection, + unregisters: 0, + closes: 0, + }; + harness.connection = { + rootId: PROFILE.rootId, + hostEpoch: 'host-epoch', + connectionId, + selectedProtocol: 0, + compositionId: 'maka.interactive', + compositionRevision: 'composition-revision', + closed, + replaceClientCapabilities: async () => ({ registrationIds: [] }), + unregisterClientCapabilities: async () => { + harness.unregisters += 1; + return { registrationIds: [] }; + }, + subscribeConfigurationChanges: () => () => undefined, + subscribeProjectCatalogChanges: () => () => undefined, + subscribeSessionCatalogChanges: () => () => undefined, + subscribeScheduledTaskChanges: () => () => undefined, + close: async () => { + harness.closes += 1; + resolveClosed(); + }, + } as unknown as RuntimeHostConnection; + return harness; +} diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 8f9f03b9b1..284e995eed 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -52,6 +52,7 @@ interface TuiMcpStatusCopy { readonly back: string; readonly readOnly: string; readonly manage: string; + readonly managePublication: string; }; readonly unavailableTitle: string; readonly unavailableDetail: string; @@ -92,7 +93,8 @@ type InputKind = | 'env' | 'headers' | 'edit' - | 'import'; + | 'import' + | 'publication_credential'; type McpOverlayPhase = | { kind: 'list' } @@ -109,6 +111,7 @@ type McpOverlayPhase = | { kind: 'confirm_add'; draft: GuidedDraft } | { kind: 'confirm_import'; preview: TuiMcpImportPreview } | { kind: 'confirm_remove'; serverId: string } + | { kind: 'confirm_remove_publication_credential' } | { kind: 'busy'; label: string }; /** One in-frame state machine for status, editing, confirmation, and errors. @@ -187,6 +190,11 @@ export class McpManagementOverlay implements Component { void this.runAction({ kind: 'commit_import', previewId: this.phase.preview.previewId }); } else if (this.phase.kind === 'confirm_remove' && matchesKey(data, 'y')) { void this.runAction({ kind: 'remove', serverId: this.phase.serverId }); + } else if ( + this.phase.kind === 'confirm_remove_publication_credential' && + matchesKey(data, 'y') + ) { + void this.runAction({ kind: 'remove_publication_credential' }); } } @@ -222,7 +230,8 @@ export class McpManagementOverlay implements Component { } private handleListInput(data: string): void { - const servers = this.input.surface?.snapshot().servers ?? []; + const snapshot = this.input.surface?.snapshot(); + const servers = snapshot?.servers ?? []; if (matchesKey(data, Key.up)) { this.selected = clamp(this.selected - 1, 0, servers.length - 1); } else if (matchesKey(data, Key.down)) { @@ -234,7 +243,19 @@ export class McpManagementOverlay implements Component { } else if (matchesKey(data, Key.home)) this.selected = 0; else if (matchesKey(data, Key.end)) this.selected = Math.max(0, servers.length - 1); else if (matchesKey(data, 'a') && this.management()) this.phase = { kind: 'add_choice' }; - else { + else if ( + matchesKey(data, 'p') && + this.management() && + snapshot?.canManagePublicationCredential + ) { + this.startInput('publication_credential'); + } else if ( + matchesKey(data, 'x') && + this.management() && + snapshot?.canManagePublicationCredential + ) { + this.phase = { kind: 'confirm_remove_publication_credential' }; + } else { const server = servers[this.selected]; if (!server || !this.management()) return; if (matchesKey(data, Key.enter)) this.startEdit(server.serverId); @@ -367,6 +388,10 @@ export class McpManagementOverlay implements Component { expectedRevision: phase.revision, config, }); + } else if (phase.input === 'publication_credential') { + if (!trimmed) throw new Error(); + this.clearEditor(); + void this.runAction({ kind: 'set_publication_credential', credential: trimmed }); } else { const preview = this.management()?.previewImport(value); if (!preview || preview.status !== 'ready') throw new Error(); @@ -454,6 +479,21 @@ export class McpManagementOverlay implements Component { confirmCopy(this.input.locale), ]; } + if (this.phase.kind === 'confirm_remove_publication_credential') { + return [ + heading( + this.input.locale, + 'Remove the remote provider credential?', + '删除远程 Provider 凭据?', + ), + '', + this.input.locale === 'zh' + ? '这会停止向所选 Runtime Host 发布 MCP 工具。' + : 'This stops publishing MCP tools to the selected Runtime Host.', + '', + confirmCopy(this.input.locale), + ]; + } if (this.phase.kind === 'busy') return [ansi.yellow(this.phase.label)]; const lines = [publicationLine(snapshot, this.input.locale)]; if (snapshot.configuration !== 'ready') { @@ -497,7 +537,9 @@ export class McpManagementOverlay implements Component { const copy = MCP_STATUS_COPY[this.input.locale].footer; if (this.phase.kind !== 'list') return copy.back; if (!this.management()) return copy.readOnly; - return copy.manage; + return this.input.surface?.snapshot().canManagePublicationCredential + ? copy.managePublication + : copy.manage; } private backToList(clearNotice = true): void { @@ -669,6 +711,7 @@ function copy(locale: UiLocale, key: string): string { 'invalid-config': 'The server configuration is invalid.', 'credential-cleanup-failed': 'Stored credentials could not be removed; the configuration was not changed.', + 'publication-credential-failed': 'The provider credential could not be stored or applied.', 'persist-failed': 'The configuration could not be saved.', 'manager-failed': 'The MCP connection action failed.', turn_active: 'MCP cannot be changed while a turn or another control action is running.', @@ -691,6 +734,7 @@ function copy(locale: UiLocale, key: string): string { closed: 'MCP 控制器已关闭。', 'invalid-config': '服务器配置无效。', 'credential-cleanup-failed': '无法删除旧凭据,配置未修改。', + 'publication-credential-failed': '无法保存或应用 Provider 凭据。', 'persist-failed': '无法保存配置。', 'manager-failed': 'MCP 连接操作失败。', turn_active: 'Turn 或其他控制操作运行期间不能修改 MCP。', @@ -749,6 +793,7 @@ function inputLabel(kind: InputKind, locale: UiLocale): string { headers: ['Request headers', '请求头'], edit: ['Edit server JSON', '编辑服务器 JSON'], import: ['Paste MCP JSON', '粘贴 MCP JSON'], + publication_credential: ['Provider credential', 'Provider 凭据'], }; return labels[kind][locale === 'zh' ? 1 : 0]; } @@ -758,6 +803,11 @@ function inputHint(kind: InputKind, locale: UiLocale): string { if (kind === 'args') return `JSON string array, ${optional}`; if (kind === 'env' || kind === 'headers') return `JSON string map, ${optional}`; if (kind === 'cwd') return optional; + if (kind === 'publication_credential') { + return locale === 'zh' + ? '仅保存到本机凭据存储;不会写入 profile、参数或对话 · Enter 提交 · Esc 返回' + : 'Stored only in the local credential store; never written to profiles, arguments, or chat · Enter submit · Esc back'; + } return locale === 'zh' ? 'Enter 提交 · Esc 返回' : 'Enter submit · Esc back'; } diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index 1247ebc169..07bcf73e7c 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -68,6 +68,7 @@ import { type TuiMcpController, type TuiMcpManagement, } from './tui-mcp-control.js'; +import { createRemoteTuiMcpPublicationTarget } from './tui-mcp-remote-publication.js'; export interface RuntimeHostTuiContext { readonly connection: RuntimeHostConnection; @@ -159,7 +160,7 @@ export async function createRuntimeHostTuiContext( }; const driver = createRuntimeHostMakaSessionDriver(driverInput); await driver.recoverSideConversations(); - if (!runtimeHostProfileUsesHostWorkspace(connected.profile.kind)) { + if (connected.profile.kind === 'local') { if (!isRuntimeHostReconnectingConnection(connection)) { throw new Error('Local Runtime Host TUI connection is not reconnectable'); } @@ -167,6 +168,15 @@ export async function createRuntimeHostTuiContext( workspaceRoot: input.rootPath, connection, }); + } else if (connected.profile.kind === 'remote') { + mcp = createTuiMcpController({ + workspaceRoot: input.rootPath, + connection: createRemoteTuiMcpPublicationTarget({ + clientDataRoot: input.clientDataRoot, + profile: connected.profile, + ownerClientInstanceId: connected.clientInstanceId, + }), + }); } const modelContextWindow = selectedTarget.connection?.models.find( (model) => model.id === selectedTarget.model, diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 7576f96b38..350e688590 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -26,6 +26,8 @@ export const TUI_COPY_RESOURCES = { readOnly: '↑/↓ scroll · q/Esc close', manage: 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close', + managePublication: + 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · p Set provider credential · x Remove credential · Esc Close', }, unavailableTitle: 'This TUI is not connected to a local MCP control plane.', unavailableDetail: @@ -37,6 +39,9 @@ export const TUI_COPY_RESOURCES = { publication: { waiting: 'waiting to publish', host_unavailable: 'Runtime Host reconnecting', + credential_required: 'provider credential required', + credential_rejected: 'provider credential rejected', + target_mismatch: 'provider target mismatch', publishing: 'publishing', published: 'published', not_published: 'not published', @@ -60,6 +65,8 @@ export const TUI_COPY_RESOURCES = { back: 'Esc 返回', readOnly: '↑/↓ 滚动 · q/Esc 关闭', manage: 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭', + managePublication: + 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · p 设置 Provider 凭据 · x 删除凭据 · Esc 关闭', }, unavailableTitle: '当前 TUI 未连接本地 MCP 控制面。', unavailableDetail: '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', @@ -69,6 +76,9 @@ export const TUI_COPY_RESOURCES = { publication: { waiting: '等待发布', host_unavailable: 'Runtime Host 重连中', + credential_required: '需要 Provider 凭据', + credential_rejected: 'Provider 凭据已被拒绝', + target_mismatch: 'Provider 目标不匹配', publishing: '正在发布', published: '已发布', not_published: '未发布', diff --git a/packages/cli/src/tui-mcp-control.ts b/packages/cli/src/tui-mcp-control.ts index dfaab64064..a85f339b2e 100644 --- a/packages/cli/src/tui-mcp-control.ts +++ b/packages/cli/src/tui-mcp-control.ts @@ -51,6 +51,9 @@ const RUNTIME_HOST_CREDENTIAL_ENV = 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'; export type TuiMcpPublicationState = | 'waiting' | 'host_unavailable' + | 'credential_required' + | 'credential_rejected' + | 'target_mismatch' | 'publishing' | 'published' | 'not_published' @@ -74,6 +77,7 @@ export interface TuiMcpSnapshot { readonly initialization: 'loading' | 'ready' | 'error'; readonly configuration: 'ready' | 'synchronizing' | 'out_of_sync'; readonly publication: TuiMcpPublicationState; + readonly canManagePublicationCredential?: boolean; readonly toolCount: number; readonly servers: readonly TuiMcpServerSnapshot[]; } @@ -119,7 +123,9 @@ export type TuiMcpAction = | { readonly kind: 'set_enabled'; readonly serverId: string; readonly enabled: boolean } | { readonly kind: 'remove'; readonly serverId: string } | { readonly kind: 'test'; readonly serverId: string } - | { readonly kind: 'reconnect'; readonly serverId: string }; + | { readonly kind: 'reconnect'; readonly serverId: string } + | { readonly kind: 'set_publication_credential'; readonly credential: string } + | { readonly kind: 'remove_publication_credential' }; export type TuiMcpActionEffect = | 'published' @@ -140,6 +146,7 @@ export type TuiMcpActionResult = | 'closed' | 'invalid-config' | 'credential-cleanup-failed' + | 'publication-credential-failed' | 'persist-failed' | 'manager-failed'; }; @@ -168,10 +175,31 @@ type TuiMcpManager = Pick< | 'close' >; -type TuiMcpConnection = Pick< - RuntimeHostReconnectingConnection, - 'replaceClientCapabilities' | 'unregisterClientCapabilities' | 'subscribeConnectionAvailability' ->; +export type TuiMcpPublicationUnavailableReason = + | 'host_unavailable' + | 'credential_required' + | 'credential_rejected' + | 'target_mismatch'; + +export type TuiMcpPublicationAvailability = + | { + readonly kind: 'unavailable'; + readonly reason?: TuiMcpPublicationUnavailableReason; + } + | Extract; + +export interface TuiMcpPublicationTarget + extends Pick< + RuntimeHostReconnectingConnection, + 'replaceClientCapabilities' | 'unregisterClientCapabilities' + > { + subscribeConnectionAvailability( + listener: (availability: TuiMcpPublicationAvailability) => void, + ): () => void; + setCredential?(credential: string): Promise; + removeCredential?(): Promise; + closePublication?(): Promise; +} interface TuiMcpControllerDeps { readonly configStore: Pick; @@ -182,7 +210,7 @@ interface TuiMcpControllerDeps { export function createTuiMcpController( input: { readonly workspaceRoot: string; - readonly connection: TuiMcpConnection; + readonly connection: TuiMcpPublicationTarget; }, overrides: Partial = {}, ): TuiMcpController { @@ -201,13 +229,13 @@ export function createTuiMcpController( } class TuiMcpControllerImpl implements TuiMcpController { - readonly #connection: TuiMcpConnection; + readonly #connection: TuiMcpPublicationTarget; readonly #deps: TuiMcpControllerDeps; readonly #listeners = new Set<() => void>(); readonly #disposeManagerChange: () => void; readonly #disposeConnectionAvailability: () => void; readonly #initialization: Promise; - #availability: RuntimeHostConnectionAvailability = { kind: 'unavailable' }; + #availability: TuiMcpPublicationAvailability = { kind: 'unavailable' }; #closed = false; #config: McpConfigFile | undefined; #preparedImport: @@ -232,13 +260,20 @@ class TuiMcpControllerImpl implements TuiMcpController { initialization: 'loading', configuration: 'synchronizing', publication: 'waiting', + canManagePublicationCredential: false, toolCount: 0, servers: [], }); - constructor(connection: TuiMcpConnection, deps: TuiMcpControllerDeps) { + constructor(connection: TuiMcpPublicationTarget, deps: TuiMcpControllerDeps) { this.#connection = connection; this.#deps = deps; + this.#snapshot = freezeSnapshot({ + ...this.#snapshot, + canManagePublicationCredential: Boolean( + connection.setCredential && connection.removeCredential, + ), + }); this.#disposeManagerChange = deps.manager.onChange(() => { try { this.#refreshManagerSnapshot(); @@ -254,7 +289,7 @@ class TuiMcpControllerImpl implements TuiMcpController { this.#availability = availability; if (availability.kind === 'unavailable') { this.#published = undefined; - this.#updateSnapshot({ publication: 'host_unavailable' }); + this.#updateSnapshot({ publication: availability.reason ?? 'host_unavailable' }); } else { this.#updateSnapshot({ publication: 'waiting' }); if (this.#snapshot.initialization === 'ready') this.#requestPublication(); @@ -338,6 +373,7 @@ class TuiMcpControllerImpl implements TuiMcpController { await this.#connection.unregisterClientCapabilities().catch(() => undefined); } this.#published = undefined; + await this.#connection.closePublication?.().catch(() => undefined); await managerClosing; await this.#initialization.catch(() => undefined); } @@ -368,6 +404,28 @@ class TuiMcpControllerImpl implements TuiMcpController { async #executeAction(action: TuiMcpAction): Promise { if (this.#closed) return { status: 'failed', reason: 'closed' }; + if (action.kind === 'set_publication_credential') { + if (!this.#connection.setCredential) { + return { status: 'failed', reason: 'publication-credential-failed' }; + } + try { + await this.#connection.setCredential(action.credential); + return { status: 'applied', effect: await this.#settlePublication() }; + } catch { + return { status: 'failed', reason: 'publication-credential-failed' }; + } + } + if (action.kind === 'remove_publication_credential') { + if (!this.#connection.removeCredential) { + return { status: 'failed', reason: 'publication-credential-failed' }; + } + try { + await this.#connection.removeCredential(); + return { status: 'applied', effect: 'pending_host' }; + } catch { + return { status: 'failed', reason: 'publication-credential-failed' }; + } + } if (action.kind === 'test') { try { const test = await this.#deps.manager.test(action.serverId); @@ -391,7 +449,11 @@ class TuiMcpControllerImpl implements TuiMcpController { } async #commitMutation( - action: Exclude, + action: Exclude< + TuiMcpAction, + | { kind: 'test' | 'reconnect' } + | { kind: 'set_publication_credential' | 'remove_publication_credential' } + >, ): Promise { let committed: McpConfigFile; try { @@ -452,7 +514,11 @@ class TuiMcpControllerImpl implements TuiMcpController { #prepareMutation( current: McpConfigFile, - action: Exclude, + action: Exclude< + TuiMcpAction, + | { kind: 'test' | 'reconnect' } + | { kind: 'set_publication_credential' | 'remove_publication_credential' } + >, ): | { readonly next: McpConfigFile } | Extract { @@ -502,8 +568,19 @@ class TuiMcpControllerImpl implements TuiMcpController { while (!this.#closed && (this.#publicationTask || this.#publicationRequested)) { await this.#publicationTask?.catch(() => undefined); } - if (this.#snapshot.publication === 'error') return 'publication_failed'; - if (this.#snapshot.publication === 'host_unavailable') return 'pending_host'; + if ( + this.#snapshot.publication === 'error' || + this.#snapshot.publication === 'credential_rejected' || + this.#snapshot.publication === 'target_mismatch' + ) { + return 'publication_failed'; + } + if ( + this.#snapshot.publication === 'host_unavailable' || + this.#snapshot.publication === 'credential_required' + ) { + return 'pending_host'; + } return 'published'; } @@ -521,6 +598,7 @@ class TuiMcpControllerImpl implements TuiMcpController { initialization, configuration, publication: this.#snapshot.publication, + canManagePublicationCredential: this.#snapshot.canManagePublicationCredential, toolCount: this.#deps.manager.toolSnapshot().tools.length, servers: [...serverIds] .sort((left, right) => left.localeCompare(right)) @@ -560,7 +638,7 @@ class TuiMcpControllerImpl implements TuiMcpController { async #publishCurrentSnapshot(): Promise { const availability = this.#availability; if (availability.kind !== 'connected') { - this.#updateSnapshot({ publication: 'host_unavailable' }); + this.#updateSnapshot({ publication: availability.reason ?? 'host_unavailable' }); return; } const identity = connectionIdentity(availability); diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts new file mode 100644 index 0000000000..4eb1e9b0cc --- /dev/null +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { + connectRuntimeHostProfile, + createClientRuntimeHostCredentialStore, + createRuntimeHostCapabilityProviderCredentialStore, + createRuntimeHostPeerClientFromEnvironment, + createRuntimeHostReconnectingConnection, + loadOrCreateRuntimeHostClientInstanceId, + RuntimeHostPermanentReconnectError, + RuntimeHostProfileConnectionError, + RuntimeHostRemoteCompatibilityError, + runtimeHostProfileTargetFingerprint, + type RemoteRuntimeHostProfile, + type RuntimeHostCapabilityProviderCredentialStore, + type RuntimeHostConnection, + type RuntimeHostPeerClient, + type RuntimeHostReconnectingConnection, +} from '@maka/runtime-host/client'; +import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; +import type { + TuiMcpPublicationAvailability, + TuiMcpPublicationTarget, + TuiMcpPublicationUnavailableReason, +} from './tui-mcp-control.js'; + +interface RemoteTuiMcpPublicationDeps { + readonly credentials: RuntimeHostCapabilityProviderCredentialStore; + readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId; + readonly connectProfile: typeof connectRuntimeHostProfile; + readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; +} + +export function createRemoteTuiMcpPublicationTarget( + input: { + readonly clientDataRoot: string; + readonly profile: RemoteRuntimeHostProfile; + readonly ownerClientInstanceId: string; + }, + overrides: Partial = {}, +): TuiMcpPublicationTarget { + const deps: RemoteTuiMcpPublicationDeps = { + credentials: createRuntimeHostCapabilityProviderCredentialStore( + createClientRuntimeHostCredentialStore(input.clientDataRoot), + ), + loadClientInstanceId: loadOrCreateRuntimeHostClientInstanceId, + connectProfile: connectRuntimeHostProfile, + createPeerClient: createRuntimeHostPeerClientFromEnvironment, + ...overrides, + }; + return new RemoteTuiMcpPublicationTarget(input, deps); +} + +class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { + readonly #input: { + readonly clientDataRoot: string; + readonly profile: RemoteRuntimeHostProfile; + readonly ownerClientInstanceId: string; + }; + readonly #deps: RemoteTuiMcpPublicationDeps; + readonly #listeners = new Set<(availability: TuiMcpPublicationAvailability) => void>(); + #availability: TuiMcpPublicationAvailability = { + kind: 'unavailable', + reason: 'credential_required', + }; + #connection: RuntimeHostReconnectingConnection | undefined; + #disposeAvailability: (() => void) | undefined; + #peerClient: RuntimeHostPeerClient | undefined; + #operation = Promise.resolve(); + #generation = 0; + #closed = false; + #closeTask: Promise | undefined; + + constructor( + input: { + readonly clientDataRoot: string; + readonly profile: RemoteRuntimeHostProfile; + readonly ownerClientInstanceId: string; + }, + deps: RemoteTuiMcpPublicationDeps, + ) { + this.#input = input; + this.#deps = deps; + void this.#serialize(async () => { + const credential = await deps.credentials.get(input.profile, input.ownerClientInstanceId); + if (this.#closed) return; + if (!credential) { + this.#setUnavailable('credential_required'); + return; + } + await this.#connect(credential); + }); + } + + replaceClientCapabilities(provider: ClientCapabilityProvider, timeoutMs?: number) { + return this.#requireConnection().replaceClientCapabilities(provider, timeoutMs); + } + + unregisterClientCapabilities(timeoutMs?: number) { + return this.#requireConnection().unregisterClientCapabilities(timeoutMs); + } + + subscribeConnectionAvailability( + listener: (availability: TuiMcpPublicationAvailability) => void, + ): () => void { + this.#listeners.add(listener); + try { + listener(this.#availability); + } catch { + // Presentation cannot invalidate the companion lifecycle. + } + return () => this.#listeners.delete(listener); + } + + setCredential(credential: string): Promise { + return this.#serialize(async () => { + if (this.#closed) throw new Error('Remote MCP publication is closed'); + await this.#deps.credentials.set( + this.#input.profile, + this.#input.ownerClientInstanceId, + credential, + ); + await this.#disconnect(); + await this.#connect(credential); + }); + } + + removeCredential(): Promise { + return this.#serialize(async () => { + if (this.#closed) throw new Error('Remote MCP publication is closed'); + await this.#disconnect(); + await this.#deps.credentials.delete(this.#input.profile, this.#input.ownerClientInstanceId); + this.#setUnavailable('credential_required'); + }); + } + + closePublication(): Promise { + this.#closeTask ??= this.#close(); + return this.#closeTask; + } + + #serialize(work: () => Promise): Promise { + const pending = this.#operation.then(work, work); + this.#operation = pending.then( + () => undefined, + () => undefined, + ); + return pending; + } + + async #connect(credential: string): Promise { + const generation = ++this.#generation; + this.#setUnavailable('host_unavailable'); + try { + const clientInstanceId = await this.#deps.loadClientInstanceId( + providerIdentityPath(this.#input), + ); + const peerClient = + this.#input.profile.transport.kind === 'libp2p-direct' + ? this.#deps.createPeerClient() + : undefined; + this.#peerClient = peerClient; + const connect = (signal?: AbortSignal): Promise => + this.#deps.connectProfile({ + profile: this.#input.profile, + credential, + clientInstanceId, + sshInteraction: 'batch', + ...(peerClient ? { peerClient } : {}), + ...(signal ? { signal } : {}), + }); + const initial = await connect(); + if (this.#closed || generation !== this.#generation) { + await initial.close().catch(() => undefined); + await peerClient?.close().catch(() => undefined); + return; + } + const connection = await createRuntimeHostReconnectingConnection({ + initialConnection: initial, + connect, + onFatalError: (error) => { + if (!this.#closed && generation === this.#generation) { + this.#setUnavailable(classifyUnavailable(error)); + } + }, + }); + if (this.#closed || generation !== this.#generation) { + await connection.close().catch(() => undefined); + await peerClient?.close().catch(() => undefined); + return; + } + this.#connection = connection; + this.#disposeAvailability = connection.subscribeConnectionAvailability((availability) => { + if (this.#closed || generation !== this.#generation) return; + this.#setAvailability( + availability.kind === 'connected' + ? availability + : { kind: 'unavailable', reason: 'host_unavailable' }, + ); + }); + } catch (error) { + if (!this.#closed && generation === this.#generation) { + this.#setUnavailable(classifyUnavailable(error)); + } + await this.#peerClient?.close().catch(() => undefined); + this.#peerClient = undefined; + } + } + + async #disconnect(): Promise { + this.#generation += 1; + this.#disposeAvailability?.(); + this.#disposeAvailability = undefined; + const connection = this.#connection; + this.#connection = undefined; + await connection?.close().catch(() => undefined); + await this.#peerClient?.close().catch(() => undefined); + this.#peerClient = undefined; + if (!this.#closed) this.#setUnavailable('host_unavailable'); + } + + async #close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#operation.catch(() => undefined); + await this.#disconnect(); + this.#listeners.clear(); + } + + #requireConnection(): RuntimeHostReconnectingConnection { + if (this.#connection && this.#availability.kind === 'connected') return this.#connection; + throw new RuntimeHostPermanentReconnectError('Remote MCP publication is unavailable'); + } + + #setUnavailable(reason: TuiMcpPublicationUnavailableReason): void { + this.#setAvailability({ kind: 'unavailable', reason }); + } + + #setAvailability(availability: TuiMcpPublicationAvailability): void { + this.#availability = availability; + for (const listener of this.#listeners) { + try { + listener(availability); + } catch { + // Presentation cannot invalidate the companion lifecycle. + } + } + } +} + +function providerIdentityPath(input: { + readonly clientDataRoot: string; + readonly profile: RemoteRuntimeHostProfile; + readonly ownerClientInstanceId: string; +}): string { + const identity = createHash('sha256') + .update('tui-mcp-capability-provider') + .update('\0') + .update(runtimeHostProfileTargetFingerprint(input.profile)) + .update('\0') + .update(input.ownerClientInstanceId) + .digest('hex') + .slice(0, 24); + return join( + input.clientDataRoot, + 'runtime-host-client', + 'capability-provider-identities', + `${identity}.json`, + ); +} + +function classifyUnavailable(error: unknown): TuiMcpPublicationUnavailableReason { + if (error instanceof RuntimeHostProfileConnectionError) return error.reason; + if (error instanceof RuntimeHostRemoteCompatibilityError) return 'target_mismatch'; + return 'host_unavailable'; +} diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 76a08b4115..6c25a6f32d 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -34,6 +34,7 @@ import { createRuntimeHostProfileCredentialStore, decodeRuntimeHostProfileDocument, RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT, + RuntimeHostProfileConnectionError, sameRemoteRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, type RuntimeHostProfileCredentialStore, @@ -714,7 +715,11 @@ describe('Runtime Host profiles', () => { connect: async () => ({ kind: 'unavailable', reason: 'root_mismatch' }), }, ), - RuntimeHostPermanentReconnectError, + (error: unknown) => { + assert.ok(error instanceof RuntimeHostProfileConnectionError); + assert.equal(error.reason, 'target_mismatch'); + return true; + }, ); }); @@ -898,6 +903,8 @@ describe('Runtime Host profiles', () => { ), (error: unknown) => { assert.ok(error instanceof RuntimeHostPermanentReconnectError); + assert.ok(error instanceof RuntimeHostProfileConnectionError); + assert.equal(error.reason, 'credential_rejected'); assert.match(error.message, /rejected its access credential/u); return true; }, diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 4af31547d1..643f7d942f 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -231,6 +231,21 @@ export interface RuntimeHostCapabilityProviderCredentialStore { delete(profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string): Promise; } +export type RuntimeHostProfileConnectionFailureReason = + | 'credential_required' + | 'credential_rejected' + | 'target_mismatch'; + +export class RuntimeHostProfileConnectionError extends RuntimeHostPermanentReconnectError { + constructor( + readonly reason: RuntimeHostProfileConnectionFailureReason, + message: string, + ) { + super(message); + this.name = 'RuntimeHostProfileConnectionError'; + } +} + export function createFileRuntimeHostProfileCatalog( path: string, credentials: RuntimeHostProfileCredentialStore, @@ -354,7 +369,8 @@ export async function connectRuntimeHostProfile( ); } if (!input.credential) { - throw new RuntimeHostPermanentReconnectError( + throw new RuntimeHostProfileConnectionError( + 'credential_required', `Runtime Host profile ${input.profile.id} has no access credential`, ); } @@ -465,6 +481,20 @@ export async function connectRemoteRuntimeHostProfile( if (connected.kind === 'draining') { throw new Error(`Runtime Host profile ${input.profile.id} is draining`); } + if (connected.reason === 'authentication_failed') { + throw new RuntimeHostProfileConnectionError( + 'credential_rejected', + `Runtime Host profile ${input.profile.id} rejected its access credential`, + ); + } + if (connected.reason === 'root_mismatch' || connected.reason === 'composition_mismatch') { + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + connected.reason === 'root_mismatch' + ? `Runtime Host profile ${input.profile.id} connected to an unexpected State Root` + : `Runtime Host profile ${input.profile.id} has an incompatible Host composition`, + ); + } throw remoteRuntimeHostUnavailableError( `Runtime Host profile ${input.profile.id}`, connected.reason, @@ -535,7 +565,8 @@ export async function connectPeerRuntimeHost(input: { input.handshakeTimeoutMs, ); if (!authentication.accepted) { - throw new RuntimeHostPermanentReconnectError( + throw new RuntimeHostProfileConnectionError( + 'credential_rejected', `Runtime Host profile ${input.profileId} rejected its access credential`, ); } @@ -561,6 +592,14 @@ export async function connectPeerRuntimeHost(input: { } if (result.kind === 'draining') throw new Error('Runtime Host direct peer is draining'); if (result.kind === 'unavailable') { + if (result.reason === 'root_mismatch' || result.reason === 'composition_mismatch') { + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + result.reason === 'root_mismatch' + ? `Runtime Host profile ${input.profileId} connected to an unexpected State Root` + : `Runtime Host profile ${input.profileId} has an incompatible Host composition`, + ); + } throw remoteRuntimeHostUnavailableError('Runtime Host direct peer', result.reason); } transferred = true; diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index ebf6518567..9560294465 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -66,6 +66,8 @@ export { type RuntimeHostProfileCatalog, type RuntimeHostConnectionPhase, type RuntimeHostCapabilityProviderCredentialStore, + RuntimeHostProfileConnectionError, + type RuntimeHostProfileConnectionFailureReason, type RuntimeHostProfileDocument, } from './host-profile.js'; export { From a3ef941c8d239b210b3c82969eae3c3781758e1a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 00:26:55 +0800 Subject: [PATCH 03/19] test(tui): exercise remote MCP publication lifecycle Cover two concurrently associated providers over authenticated WebSockets, an exact-root rejection, Host restart and republish, credential revocation, and final MCP child cleanup. Generated-by: Codex --- .../tui-mcp-remote-integration.test.ts | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts new file mode 100644 index 0000000000..a978ce323e --- /dev/null +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -0,0 +1,366 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createServer } from 'node:net'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { + connectRemoteRuntimeHost, + connectRuntimeHost, + consumeAccessCredentialDelivery, + type RemoteRuntimeHostProfile, + type RuntimeHostCapabilityProviderCredentialStore, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, +} from '@maka/runtime-host/protocol'; +import { startExecutionRuntimeHostService } from '@maka/runtime-host/server'; +import { createMcpConfigStore } from '@maka/storage/mcp-config-store'; +import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; +import { createTuiMcpController, type TuiMcpController } from '../tui-mcp-control.js'; + +const PROTOCOL = { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, +} as const; + +test('remote TUI publication keeps its owner association across reconnect and revocation', { + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-tui-remote-mcp-')); + const hostRoot = join(base, 'host'); + const clientRoot = join(base, 'client'); + const eventLog = join(base, 'stdio-events.jsonl'); + const port = await reservePort(); + let host = await startHost(hostRoot, port); + let local: RuntimeHostConnection | undefined; + let terminal: RuntimeHostConnection | undefined; + let otherTerminal: RuntimeHostConnection | undefined; + let otherProvider: RuntimeHostConnection | undefined; + let controller: TuiMcpController | undefined; + try { + const capability = await resolveStorageRoot({ path: hostRoot, kind: 'interactive' }); + local = await connectLocal(hostRoot, 'local-owner'); + const firstOwner = await provisionOwner( + local, + hostRoot, + host.websocketEndpoints[0]!, + capability.rootId, + 'terminal-a', + ); + terminal = firstOwner.connection; + const firstProvider = await provisionProvider( + local, + hostRoot, + firstOwner.credentialId, + 'terminal-a-mcp', + ); + const secondOwner = await provisionOwner( + local, + hostRoot, + host.websocketEndpoints[0]!, + capability.rootId, + 'terminal-b', + ); + otherTerminal = secondOwner.connection; + const secondProvider = await provisionProvider( + local, + hostRoot, + secondOwner.credentialId, + 'terminal-b-mcp', + ); + otherProvider = await connectRemote( + host.websocketEndpoints[0]!, + capability.rootId, + secondProvider.credential, + 'provider-b', + ); + await otherProvider.replaceClientCapabilities(dummyProvider('provider-b')); + + const fixturePath = fileURLToPath( + new URL(import.meta.resolve('@maka/mcp/test-only/stdio-server')), + ); + await createMcpConfigStore(clientRoot).upsert('fixture', { + command: process.execPath, + args: [fixturePath], + env: { MAKA_MCP_STDIO_EVENT_LOG: eventLog }, + protocol: 'legacy', + }); + const credentials = credentialStore(firstProvider.credential); + const profile = remoteProfile(host.websocketEndpoints[0]!, capability.rootId); + const publication = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: clientRoot, + profile, + ownerClientInstanceId: 'terminal-a', + }, + { + credentials, + loadClientInstanceId: async () => 'provider-a', + }, + ); + controller = createTuiMcpController({ workspaceRoot: clientRoot, connection: publication }); + await waitFor(() => controller?.snapshot().publication === 'published'); + assert.equal(host.connectionCount, 5); + + const wrongTarget = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: clientRoot, + profile: remoteProfile(host.websocketEndpoints[0]!, 'f'.repeat(64)), + ownerClientInstanceId: 'terminal-a', + }, + { + credentials, + loadClientInstanceId: async () => 'provider-wrong-root', + }, + ); + let wrongTargetState = 'host_unavailable'; + const disposeWrongTarget = wrongTarget.subscribeConnectionAvailability((availability) => { + wrongTargetState = + availability.kind === 'unavailable' + ? (availability.reason ?? 'host_unavailable') + : 'connected'; + }); + try { + await waitFor(() => wrongTargetState === 'target_mismatch'); + assert.equal(wrongTargetState, 'target_mismatch'); + } finally { + disposeWrongTarget(); + await wrongTarget.closePublication?.(); + } + + await Promise.all([ + otherProvider.close(), + otherTerminal.close(), + terminal.close(), + local.close(), + ]); + otherProvider = undefined; + otherTerminal = undefined; + terminal = undefined; + local = undefined; + await host.close(); + await waitFor(() => controller?.snapshot().publication === 'host_unavailable'); + host = await startHost(hostRoot, port); + await waitFor(() => controller?.snapshot().publication === 'published'); + + local = await connectLocal(hostRoot, 'local-owner-after-restart'); + await local.request('access.credential.revoke', { + credentialId: firstProvider.credentialId, + }); + await waitFor(() => controller?.snapshot().publication === 'credential_rejected'); + assert.equal(credentials.current(), firstProvider.credential); + + await controller.close(); + controller = undefined; + await waitFor(async () => + (await fixtureEvents(eventLog)).some((event) => event.event === 'exit'), + ); + const events = await fixtureEvents(eventLog); + assert.equal(events.filter((event) => event.event === 'start').length, 1); + assert.equal(events.filter((event) => event.event === 'exit').length, 1); + } finally { + await controller?.close().catch(() => undefined); + await otherProvider?.close().catch(() => undefined); + await otherTerminal?.close().catch(() => undefined); + await terminal?.close().catch(() => undefined); + await local?.close().catch(() => undefined); + await host.close().catch(() => undefined); + await rm(base, { recursive: true, force: true }); + } +}); + +async function startHost(rootPath: string, port: number) { + return startExecutionRuntimeHostService({ + rootPath, + websocket: { host: '127.0.0.1', port, allowInsecureRemote: true }, + }); +} + +async function provisionOwner( + local: RuntimeHostConnection, + rootPath: string, + url: string, + rootId: string, + clientInstanceId: string, +): Promise<{ readonly credentialId: string; readonly connection: RuntimeHostConnection }> { + const candidate = await local.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: clientInstanceId, + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + const credential = await consumeAccessCredentialDelivery( + rootPath, + candidate.deliveryId, + candidate.credentialId, + ); + const pairing = await connectRemote(url, rootId, credential, clientInstanceId); + assert.deepEqual(await pairing.request('access.credential.finalize', {}), { + reconnectRequired: true, + }); + await pairing.close(); + return { + credentialId: candidate.credentialId, + connection: await connectRemote(url, rootId, credential, clientInstanceId), + }; +} + +async function provisionProvider( + local: RuntimeHostConnection, + rootPath: string, + ownerCredentialId: string, + principalId: string, +): Promise<{ readonly credentialId: string; readonly credential: string }> { + const issued = await local.request('access.credential.issue', { + principalKind: 'capability_provider', + principalId, + operationGrants: ['host.status', 'client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: ownerCredentialId, + }); + return { + credentialId: issued.credentialId, + credential: await consumeAccessCredentialDelivery( + rootPath, + issued.deliveryId, + issued.credentialId, + ), + }; +} + +async function connectLocal(rootPath: string, clientInstanceId: string) { + const result = await connectRuntimeHost({ rootPath, clientInstanceId, protocol: PROTOCOL }); + assert.equal(result.kind, 'connected'); + if (result.kind !== 'connected') throw new Error('Unable to connect to local Runtime Host'); + return result.connection; +} + +async function connectRemote( + url: string, + expectedRootId: string, + credential: string, + clientInstanceId: string, +): Promise { + const result = await connectRemoteRuntimeHost({ + url, + allowInsecureRemote: true, + credential, + expectedRootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId, + protocol: PROTOCOL, + }); + assert.equal(result.kind, 'connected'); + if (result.kind !== 'connected') throw new Error('Unable to connect to remote Runtime Host'); + return result.connection; +} + +function remoteProfile(url: string, rootId: string): RemoteRuntimeHostProfile { + return { + id: 'office', + name: 'Office', + kind: 'remote', + transport: { kind: 'plaintext', url, acknowledgement: 'plaintext-bearer-v1' }, + rootId, + }; +} + +function credentialStore(initial: string): RuntimeHostCapabilityProviderCredentialStore & { + current(): string | null; +} { + let credential: string | null = initial; + return { + get: async () => credential, + set: async (_profile, _ownerClientInstanceId, next) => { + credential = next; + }, + delete: async () => { + credential = null; + }, + current: () => credential, + }; +} + +function dummyProvider(id: string) { + return { + offers: () => [ + { + offerId: id, + version: '1', + affinity: 'session' as const, + hostPathAccess: 'none' as const, + label: id, + tools: [ + { + serverId: id, + name: 'echo', + inputSchema: { type: 'object' }, + }, + ], + }, + ], + call: async () => ({ content: [{ type: 'text' as const, text: id }] }), + }; +} + +async function fixtureEvents(path: string): Promise> { + try { + return (await readFile(path, 'utf8')) + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { event: string }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +async function reservePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return port; +} + +async function waitFor(condition: () => boolean | Promise): Promise { + for (let attempt = 0; attempt < 1_500 && !(await condition()); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.ok(await condition()); +} From c4dd99e10cc8883efddabd7381a2c8a48985c2b3 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 00:30:38 +0800 Subject: [PATCH 04/19] fix(tui): retire remote MCP companion state Abort initial companion connection attempts during replacement or shutdown, and store the owner-bound provider credential in the profile target slot so profile removal retires every associated secret. Generated-by: Codex --- .../tui-mcp-remote-integration.test.ts | 15 +++- .../tui-mcp-remote-publication.test.ts | 29 ++++++ .../cli/src/tui-mcp-remote-publication.ts | 19 +++- .../src/__tests__/host-profile.test.ts | 20 ++++- .../runtime-host/src/client/host-profile.ts | 88 +++++++++++++------ 5 files changed, 140 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts index a978ce323e..09cd6a0d4b 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -92,6 +92,14 @@ test('remote TUI publication keeps its owner association across reconnect and re secondOwner.credentialId, 'terminal-b-mcp', ); + assert.deepEqual(firstProvider.capabilityOwner, { + principalId: 'terminal-a', + clientInstanceId: 'terminal-a', + }); + assert.deepEqual(secondProvider.capabilityOwner, { + principalId: 'terminal-b', + clientInstanceId: 'terminal-b', + }); otherProvider = await connectRemote( host.websocketEndpoints[0]!, capability.rootId, @@ -236,7 +244,11 @@ async function provisionProvider( rootPath: string, ownerCredentialId: string, principalId: string, -): Promise<{ readonly credentialId: string; readonly credential: string }> { +): Promise<{ + readonly credentialId: string; + readonly credential: string; + readonly capabilityOwner?: { readonly principalId: string; readonly clientInstanceId: string }; +}> { const issued = await local.request('access.credential.issue', { principalKind: 'capability_provider', principalId, @@ -247,6 +259,7 @@ async function provisionProvider( }); return { credentialId: issued.credentialId, + capabilityOwner: issued.capabilityOwner, credential: await consumeAccessCredentialDelivery( rootPath, issued.deliveryId, diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index ab0d57cc1e..c6ad434e78 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -123,6 +123,35 @@ test('remote TUI publication surfaces rejected credentials without a retry autho await target.closePublication?.(); }); +test('remote TUI publication aborts an in-flight connection before closing', async () => { + const credentials = credentialHarness('provider-secret'); + let observedSignal: AbortSignal | undefined; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async (input) => { + observedSignal = input.signal; + return new Promise((_resolve, reject) => { + input.signal?.addEventListener('abort', () => reject(input.signal?.reason), { + once: true, + }); + }); + }, + }, + ); + await waitFor(() => observedSignal !== undefined, 'provider connection attempt to start'); + + await target.closePublication?.(); + + assert.equal(observedSignal?.aborted, true); +}); + async function availability(target: ReturnType) { let current: Parameters[0]>[0] = { kind: 'unavailable', diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index 4eb1e9b0cc..c18752c6ca 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -80,12 +80,13 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { readonly #listeners = new Set<(availability: TuiMcpPublicationAvailability) => void>(); #availability: TuiMcpPublicationAvailability = { kind: 'unavailable', - reason: 'credential_required', + reason: 'host_unavailable', }; #connection: RuntimeHostReconnectingConnection | undefined; #disposeAvailability: (() => void) | undefined; #peerClient: RuntimeHostPeerClient | undefined; #operation = Promise.resolve(); + #connectAbort: AbortController | undefined; #generation = 0; #closed = false; #closeTask: Promise | undefined; @@ -108,6 +109,8 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { return; } await this.#connect(credential); + }).catch(() => { + if (!this.#closed) this.#setUnavailable('host_unavailable'); }); } @@ -132,6 +135,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } setCredential(credential: string): Promise { + this.#cancelConnect(); return this.#serialize(async () => { if (this.#closed) throw new Error('Remote MCP publication is closed'); await this.#deps.credentials.set( @@ -145,6 +149,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } removeCredential(): Promise { + this.#cancelConnect(); return this.#serialize(async () => { if (this.#closed) throw new Error('Remote MCP publication is closed'); await this.#disconnect(); @@ -154,6 +159,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } closePublication(): Promise { + this.#cancelConnect(); this.#closeTask ??= this.#close(); return this.#closeTask; } @@ -169,6 +175,8 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { async #connect(credential: string): Promise { const generation = ++this.#generation; + const abort = new AbortController(); + this.#connectAbort = abort; this.#setUnavailable('host_unavailable'); try { const clientInstanceId = await this.#deps.loadClientInstanceId( @@ -186,7 +194,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { clientInstanceId, sshInteraction: 'batch', ...(peerClient ? { peerClient } : {}), - ...(signal ? { signal } : {}), + signal: signal ? AbortSignal.any([abort.signal, signal]) : abort.signal, }); const initial = await connect(); if (this.#closed || generation !== this.#generation) { @@ -223,10 +231,13 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } await this.#peerClient?.close().catch(() => undefined); this.#peerClient = undefined; + } finally { + if (this.#connectAbort === abort) this.#connectAbort = undefined; } } async #disconnect(): Promise { + this.#cancelConnect(); this.#generation += 1; this.#disposeAvailability?.(); this.#disposeAvailability = undefined; @@ -251,6 +262,10 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { throw new RuntimeHostPermanentReconnectError('Remote MCP publication is unavailable'); } + #cancelConnect(): void { + this.#connectAbort?.abort(new Error('Remote MCP publication target changed')); + } + #setUnavailable(reason: TuiMcpPublicationUnavailableReason): void { this.#setAvailability({ kind: 'unavailable', reason }); } diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 6c25a6f32d..f8f9f7c42a 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -433,10 +433,11 @@ describe('Runtime Host profiles', () => { /credential is invalid/, ); await credentials.set(targetA, 'owner-a', 'provider-a'); + assert.equal(await credentials.get(targetA, 'owner-b'), null); await credentials.set(targetA, 'owner-b', 'provider-b'); await credentials.set(targetB, 'owner-a', 'provider-other-target'); - assert.equal(await credentials.get(targetA, 'owner-a'), 'provider-a'); + assert.equal(await credentials.get(targetA, 'owner-a'), null); assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); assert.equal(await credentials.get(targetB, 'owner-a'), 'provider-other-target'); await credentials.delete(targetA, 'owner-a'); @@ -444,6 +445,23 @@ describe('Runtime Host profiles', () => { assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); }); + test('removing a profile retires its terminal and provider credentials together', async () => { + const path = await profilePath(); + const credentialStore = createFileCredentialStore(join(dirname(path), 'credentials')); + const catalog = createFileRuntimeHostProfileCatalog( + path, + createRuntimeHostProfileCredentialStore(credentialStore), + ); + const providers = createRuntimeHostCapabilityProviderCredentialStore(credentialStore); + const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); + await catalog.save(profile, 'terminal-token'); + await providers.set(profile, 'owner-a', 'provider-token'); + + await catalog.remove(profile.id); + + assert.equal(await providers.get(profile, 'owner-a'), null); + }); + test('pins a direct-peer profile to its PeerId while allowing route discovery to change', () => { const original = directPeerProfile('peer-a', ['/ip4/192.0.2.10/udp/4001/quic-v1']); const moved = directPeerProfile('peer-a', ['/ip6/2001:db8::10/udp/4001/quic-v1']); diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 643f7d942f..b4acb66881 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -286,8 +286,7 @@ export function createRuntimeHostProfileCredentialStore( credential, ); }, - delete: (profile) => - credentials.deleteSecret(profileCredentialSlot(profile), 'runtime_host_access'), + delete: (profile) => credentials.deleteSecret(profileCredentialSlot(profile)), }; } @@ -295,28 +294,30 @@ export function createRuntimeHostCapabilityProviderCredentialStore( credentials: Pick, ): RuntimeHostCapabilityProviderCredentialStore { return { - get: (profile, ownerClientInstanceId) => - credentials.getSecret( - capabilityProviderCredentialSlot(profile, ownerClientInstanceId), + get: async (profile, ownerClientInstanceId) => { + const stored = await credentials.getSecret( + profileCredentialSlot(profile), 'runtime_host_capability_provider', - ), - set: (profile, ownerClientInstanceId, credential) => { - try { - requireRuntimeHostAccessCredential(credential); - } catch (error) { - return Promise.reject(error); - } - return credentials.setSecret( - capabilityProviderCredentialSlot(profile, ownerClientInstanceId), + ); + if (stored === null) return null; + const decoded = decodeCapabilityProviderCredential(stored); + return decoded.ownerClientInstanceId === requireClientInstanceId(ownerClientInstanceId) + ? decoded.credential + : null; + }, + set: async (profile, ownerClientInstanceId, credential) => { + await credentials.setSecret( + profileCredentialSlot(profile), 'runtime_host_capability_provider', - credential, + JSON.stringify({ + schemaVersion: 1, + ownerClientInstanceId: requireClientInstanceId(ownerClientInstanceId), + credential: requireRuntimeHostAccessCredential(credential), + }), ); }, delete: (profile, ownerClientInstanceId) => - credentials.deleteSecret( - capabilityProviderCredentialSlot(profile, ownerClientInstanceId), - 'runtime_host_capability_provider', - ), + deleteCapabilityProviderCredential(credentials, profile, ownerClientInstanceId), }; } @@ -1155,16 +1156,49 @@ function profileCredentialSlot(profile: RemoteRuntimeHostProfile): string { return `runtime-host-profile:${requireProfileId(profile.id)}:${profileCredentialBinding(profile)}`; } -function capabilityProviderCredentialSlot( +async function deleteCapabilityProviderCredential( + credentials: Pick, profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string, -): string { - return [ - 'runtime-host-profile-capability-provider', - requireProfileId(profile.id), - profileCredentialBinding(profile), - createHash('sha256').update(requireClientInstanceId(ownerClientInstanceId)).digest('hex'), - ].join(':'); +): Promise { + const slot = profileCredentialSlot(profile); + const stored = await credentials.getSecret(slot, 'runtime_host_capability_provider'); + if (stored === null) return; + const decoded = decodeCapabilityProviderCredential(stored); + if (decoded.ownerClientInstanceId !== requireClientInstanceId(ownerClientInstanceId)) return; + await credentials.deleteSecret(slot, 'runtime_host_capability_provider'); +} + +function decodeCapabilityProviderCredential(value: string): { + readonly ownerClientInstanceId: string; + readonly credential: string; +} { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error('Runtime Host capability-provider credential is invalid', { cause: error }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Runtime Host capability-provider credential is invalid'); + } + const record = parsed as Record; + if ( + record.schemaVersion !== 1 || + Object.keys(record).some( + (key) => !['schemaVersion', 'ownerClientInstanceId', 'credential'].includes(key), + ) + ) { + throw new Error('Runtime Host capability-provider credential is invalid'); + } + try { + return { + ownerClientInstanceId: requireClientInstanceId(record.ownerClientInstanceId), + credential: requireRuntimeHostAccessCredential(record.credential as string), + }; + } catch (error) { + throw new Error('Runtime Host capability-provider credential is invalid', { cause: error }); + } } function requireRuntimeHostAccessCredential(credential: string): string { From 93ff4e37d2883343c123e0793414c13c73b8b612 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 13:49:57 +0800 Subject: [PATCH 05/19] fix(tui): fence remote MCP companion lifecycles Re-read the canonical profile catalog after cross-process changes and retire the companion when its bound profile disappears or changes target. Close the direct peer endpoint when reconnect reaches a permanent failure. Generated-by: Codex --- .../tui-mcp-remote-publication.test.ts | 130 ++++++++++++++++++ .../cli/src/tui-mcp-remote-publication.ts | 71 +++++++++- .../runtime-host/src/client/host-profile.ts | 15 +- packages/runtime-host/src/client/index.ts | 1 + 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index c6ad434e78..38270499fd 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -24,6 +24,7 @@ import { type RemoteRuntimeHostProfile, type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostConnection, + type RuntimeHostProfileCatalog, } from '@maka/runtime-host/client'; import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; import { waitFor } from './tui-terminal-mock.js'; @@ -48,6 +49,7 @@ test('remote TUI publication activates, rotates, and removes one profile-bound c ownerClientInstanceId: 'terminal-client', }, { + ...profileDeps(), credentials: credentials.store, loadClientInstanceId: async (path) => { identityPaths.push(path); @@ -103,6 +105,7 @@ test('remote TUI publication surfaces rejected credentials without a retry autho ownerClientInstanceId: 'terminal-client', }, { + ...profileDeps(), credentials: credentials.store, loadClientInstanceId: async () => 'provider-client', connectProfile: async () => { @@ -133,6 +136,7 @@ test('remote TUI publication aborts an in-flight connection before closing', asy ownerClientInstanceId: 'terminal-client', }, { + ...profileDeps(), credentials: credentials.store, loadClientInstanceId: async () => 'provider-client', connectProfile: async (input) => { @@ -152,6 +156,87 @@ test('remote TUI publication aborts an in-flight connection before closing', asy assert.equal(observedSignal?.aborted, true); }); +test('remote TUI publication retires when another process removes its profile', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(); + const connection = connectionHarness('connection-1'); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => connection.connection, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + + profiles.remove(); + + await waitFor(() => { + const current = latest(); + return current.kind === 'unavailable' && current.reason === 'target_mismatch'; + }, 'removed profile to retire provider companion'); + assert.equal(connection.closes, 1); + await assert.rejects(async () => { + await target.setCredential?.('replacement-secret'); + }); +}); + +test('remote TUI publication closes its direct peer after a permanent reconnect failure', async () => { + const credentials = credentialHarness('provider-secret'); + const profile: RemoteRuntimeHostProfile = { + ...PROFILE, + transport: { + kind: 'libp2p-direct', + peerId: 'peer-a', + routeHints: ['/ip4/127.0.0.1/tcp/4001'], + coordinationRelays: [], + }, + }; + const profiles = profileHarness(profile); + const initial = connectionHarness('connection-1'); + let peerCloses = 0; + let fatal: ((error: Error) => void) | undefined; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => initial.connection, + createPeerClient: () => + ({ close: async () => void (peerCloses += 1) }) as ReturnType< + typeof import('@maka/runtime-host/client').createRuntimeHostPeerClientFromEnvironment + >, + createReconnectingConnection: async (input) => { + fatal = input.onFatalError; + return reconnectingConnection(initial.connection); + }, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'direct provider companion to connect'); + + fatal?.(new RuntimeHostProfileConnectionError('credential_rejected', 'revoked')); + + await waitFor(() => peerCloses === 1, 'direct peer endpoint to close'); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'credential_rejected' }); + await target.closePublication?.(); + assert.equal(peerCloses, 1); +}); + async function availability(target: ReturnType) { let current: Parameters[0]>[0] = { kind: 'unavailable', @@ -181,6 +266,30 @@ function credentialHarness(initial?: string) { return { store, values }; } +function profileDeps(profile: RemoteRuntimeHostProfile = PROFILE) { + const profiles = profileHarness(profile); + return { profiles: profiles.catalog, subscribeProfileChanges: profiles.subscribe }; +} + +function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { + let profiles: RemoteRuntimeHostProfile[] = [initial]; + const listeners = new Set<(error?: Error) => void>(); + const catalog = { + read: async () => ({ schemaVersion: 3 as const, profiles }), + } as unknown as RuntimeHostProfileCatalog; + return { + catalog, + subscribe: (listener: (error?: Error) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + remove: () => { + profiles = []; + for (const listener of listeners) listener(); + }, + }; +} + interface ConnectionHarness { connection: RuntimeHostConnection; unregisters: number; @@ -221,3 +330,24 @@ function connectionHarness(connectionId: string): ConnectionHarness { } as unknown as RuntimeHostConnection; return harness; } + +function reconnectingConnection(connection: RuntimeHostConnection) { + return { + ...connection, + reconnecting: true as const, + subscribeConnectionAvailability: ( + listener: (availability: { + kind: 'connected'; + hostEpoch: string; + connectionId: string; + }) => void, + ) => { + listener({ + kind: 'connected', + hostEpoch: connection.hostEpoch, + connectionId: connection.connectionId, + }); + return () => undefined; + }, + }; +} diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index c18752c6ca..9bfbacf305 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -22,18 +22,22 @@ import { join } from 'node:path'; import { connectRuntimeHostProfile, createClientRuntimeHostCredentialStore, + createClientRuntimeHostProfileCatalog, createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostPeerClientFromEnvironment, createRuntimeHostReconnectingConnection, loadOrCreateRuntimeHostClientInstanceId, RuntimeHostPermanentReconnectError, RuntimeHostProfileConnectionError, + sameRemoteRuntimeHostProfileTarget, + subscribeClientRuntimeHostProfileCatalogChanges, RuntimeHostRemoteCompatibilityError, runtimeHostProfileTargetFingerprint, type RemoteRuntimeHostProfile, type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostConnection, type RuntimeHostPeerClient, + type RuntimeHostProfileCatalog, type RuntimeHostReconnectingConnection, } from '@maka/runtime-host/client'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; @@ -48,6 +52,9 @@ interface RemoteTuiMcpPublicationDeps { readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId; readonly connectProfile: typeof connectRuntimeHostProfile; readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; + readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; + readonly profiles: RuntimeHostProfileCatalog; + readonly subscribeProfileChanges: (listener: (error?: Error) => void) => () => void; } export function createRemoteTuiMcpPublicationTarget( @@ -65,6 +72,10 @@ export function createRemoteTuiMcpPublicationTarget( loadClientInstanceId: loadOrCreateRuntimeHostClientInstanceId, connectProfile: connectRuntimeHostProfile, createPeerClient: createRuntimeHostPeerClientFromEnvironment, + createReconnectingConnection: createRuntimeHostReconnectingConnection, + profiles: createClientRuntimeHostProfileCatalog(input.clientDataRoot), + subscribeProfileChanges: (listener) => + subscribeClientRuntimeHostProfileCatalogChanges(input.clientDataRoot, listener), ...overrides, }; return new RemoteTuiMcpPublicationTarget(input, deps); @@ -90,6 +101,9 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { #generation = 0; #closed = false; #closeTask: Promise | undefined; + #disposeProfileChanges: (() => void) | undefined; + #profileValidationQueued = false; + #profileValidationError: Error | undefined; constructor( input: { @@ -101,7 +115,19 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { ) { this.#input = input; this.#deps = deps; + try { + this.#disposeProfileChanges = deps.subscribeProfileChanges((error) => { + this.#scheduleProfileValidation(error); + }); + } catch (error) { + this.#scheduleProfileValidation(error instanceof Error ? error : new Error(String(error))); + } void this.#serialize(async () => { + const profileCurrent = await this.#profileStillCurrent().catch(() => undefined); + if (profileCurrent !== true) { + await this.#retire(profileCurrent === false ? 'target_mismatch' : 'host_unavailable'); + return; + } const credential = await deps.credentials.get(input.profile, input.ownerClientInstanceId); if (this.#closed) return; if (!credential) { @@ -202,12 +228,16 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { await peerClient?.close().catch(() => undefined); return; } - const connection = await createRuntimeHostReconnectingConnection({ + const connection = await this.#deps.createReconnectingConnection({ initialConnection: initial, connect, onFatalError: (error) => { if (!this.#closed && generation === this.#generation) { this.#setUnavailable(classifyUnavailable(error)); + if (this.#peerClient === peerClient) { + this.#peerClient = undefined; + void peerClient?.close().catch(() => undefined); + } } }, }); @@ -252,11 +282,50 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { async #close(): Promise { if (this.#closed) return; this.#closed = true; + this.#disposeProfileChanges?.(); + this.#disposeProfileChanges = undefined; await this.#operation.catch(() => undefined); await this.#disconnect(); this.#listeners.clear(); } + #scheduleProfileValidation(error?: Error): void { + if (this.#closed) return; + this.#profileValidationError ??= error; + if (this.#profileValidationQueued) return; + this.#profileValidationQueued = true; + void this.#serialize(async () => { + const profileCurrent = await this.#profileStillCurrent().catch(() => undefined); + const validationError = this.#profileValidationError; + this.#profileValidationError = undefined; + this.#profileValidationQueued = false; + if (this.#closed) return; + if (validationError || profileCurrent !== true) { + await this.#retire( + validationError || profileCurrent === undefined ? 'host_unavailable' : 'target_mismatch', + ); + } + }); + } + + async #profileStillCurrent(): Promise { + const document = await this.#deps.profiles.read(); + const current = document.profiles.find((profile) => profile.id === this.#input.profile.id); + return ( + current?.kind === 'remote' && sameRemoteRuntimeHostProfileTarget(current, this.#input.profile) + ); + } + + async #retire(reason: TuiMcpPublicationUnavailableReason): Promise { + if (this.#closed) return; + this.#closed = true; + this.#disposeProfileChanges?.(); + this.#disposeProfileChanges = undefined; + this.#setUnavailable(reason); + await this.#disconnect(); + this.#listeners.clear(); + } + #requireConnection(): RuntimeHostReconnectingConnection { if (this.#connection && this.#availability.kind === 'connected') return this.#connection; throw new RuntimeHostPermanentReconnectError('Remote MCP publication is unavailable'); diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index b4acb66881..b4279ee800 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -18,6 +18,7 @@ */ import { createHash, randomUUID } from 'node:crypto'; +import { watch } from 'node:fs'; import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import { dirname, join, posix } from 'node:path'; import { createFileCredentialStore, type CredentialStore } from '@maka/storage/credential-store'; @@ -62,6 +63,7 @@ import { } from './wsl-environment.js'; const PROFILE_SCHEMA_VERSION = 3; +const CLIENT_PROFILE_DOCUMENT_NAME = 'runtime-host-profiles.json'; const PROFILE_DOCUMENT_MAX_BYTES = 64 * 1024; const PROFILE_COUNT_MAX = 32; const PROFILE_NAME_MAX_BYTES = 128; @@ -258,11 +260,22 @@ export function createClientRuntimeHostProfileCatalog( credentialStore: CredentialStore = createClientRuntimeHostCredentialStore(clientDataRoot), ): RuntimeHostProfileCatalog { return createFileRuntimeHostProfileCatalog( - join(clientDataRoot, 'runtime-host-profiles.json'), + join(clientDataRoot, CLIENT_PROFILE_DOCUMENT_NAME), createRuntimeHostProfileCredentialStore(credentialStore), ); } +export function subscribeClientRuntimeHostProfileCatalogChanges( + clientDataRoot: string, + listener: (error?: Error) => void, +): () => void { + const watcher = watch(clientDataRoot, (_eventType, filename) => { + if (filename === null || filename.toString() === CLIENT_PROFILE_DOCUMENT_NAME) listener(); + }); + watcher.on('error', (error) => listener(error)); + return () => watcher.close(); +} + export function createClientRuntimeHostCredentialStore(clientDataRoot: string): CredentialStore { return createFileCredentialStore(join(clientDataRoot, 'runtime-host-client')); } diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 9560294465..6ab3177a79 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -56,6 +56,7 @@ export { runtimeHostProfileTargetFingerprint, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, + subscribeClientRuntimeHostProfileCatalogChanges, type EnvironmentRuntimeHostProfile, type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, From bc4f2a9cb4da259f1136b69df68468311b605a7f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 13:49:57 +0800 Subject: [PATCH 06/19] fix(tui): mask remote MCP provider credentials Use a dedicated single-line masked input for provider credentials and clear it before action results or diagnostics are rendered. Generated-by: Codex --- .../src/__tests__/pi-tui-mcp-status.test.ts | 42 +++++++++---- packages/cli/src/pi-tui-mcp-status.ts | 62 ++++++++++++++++++- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts index 8bc43bf5ed..2529da9e18 100644 --- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts +++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import type { TUI } from '@earendil-works/pi-tui'; import { McpManagementOverlay } from '../pi-tui-mcp-status.js'; import type { TuiMcpAction, TuiMcpManagement, TuiMcpSnapshot } from '../tui-mcp-control.js'; import { stripAnsi } from '../tui-ansi.js'; @@ -68,26 +69,45 @@ describe('MCP management overlay', () => { assert.doesNotMatch(text, /尚未配置/u); }); - test('surfaces remote provider credential state without rendering a secret value', () => { + test('masks remote provider credentials while typing and clears them after submission', async () => { + const actions: TuiMcpAction[] = []; + const mcp = surface({ + initialization: 'ready', + configuration: 'ready', + publication: 'credential_rejected', + canManagePublicationCredential: true, + toolCount: 0, + servers: [], + }); + mcp.execute = async (action) => { + actions.push(action); + return { status: 'applied', effect: 'published' }; + }; const overlay = new McpManagementOverlay({ locale: 'en', - surface: surface({ - initialization: 'ready', - configuration: 'ready', - publication: 'credential_rejected', - canManagePublicationCredential: true, - toolCount: 0, - servers: [], - }), + tui: {} as TUI, + surface: mcp, viewportRows: () => 8, onClose: () => undefined, onChange: () => undefined, }); - const text = overlay.render(160).map(stripAnsi).join('\n'); + let text = overlay.render(160).map(stripAnsi).join('\n'); assert.match(text, /provider credential rejected/u); assert.match(text, /p Set provider credential/u); - assert.doesNotMatch(text, /maka_rh_/u); + overlay.handleInput('p'); + overlay.handleInput('maka_rh_secret-marker'); + text = overlay.render(160).map(stripAnsi).join('\n'); + assert.match(text, /•••••••••••••••••••••/u); + assert.doesNotMatch(text, /maka_rh_secret-marker/u); + + overlay.handleInput('\n'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(actions, [ + { kind: 'set_publication_credential', credential: 'maka_rh_secret-marker' }, + ]); + assert.doesNotMatch(overlay.render(160).map(stripAnsi).join('\n'), /maka_rh_secret-marker/u); }); test('localizes manager states without changing their source values', () => { diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 284e995eed..152c5bb9bd 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -19,6 +19,7 @@ import { Editor, + Input, Key, matchesKey, truncateToWidth, @@ -72,6 +73,14 @@ const MCP_STATUS_COPY = resolveUiMessageCatalog( defineUiMessageCatalog()(TUI_COPY_RESOURCES['mcp-status']), ); +interface OverlayTextInput extends Component { + focused: boolean; + onSubmit?: (value: string) => void; + onChange?: (value: string) => void; + setText(value: string): void; + handleInput(data: string): void; +} + type GuidedDraft = { serverId: string; transport?: 'stdio' | 'remote'; @@ -115,7 +124,7 @@ type McpOverlayPhase = | { kind: 'busy'; label: string }; /** One in-frame state machine for status, editing, confirmation, and errors. - * Raw config values live only in the editor and are cleared on every exit; + * Raw config values live only in the input component and are cleared on every exit; * no management result is written into the conversation transcript. */ export class McpManagementOverlay implements Component { private top = 0; @@ -126,7 +135,7 @@ export class McpManagementOverlay implements Component { private phase: McpOverlayPhase = { kind: 'list' }; private notice: { level: 'info' | 'error'; text: string } | undefined; private readonly dispose: () => void; - private editor: Editor | undefined; + private editor: OverlayTextInput | undefined; private closed = false; private actionAttempt = 0; @@ -332,7 +341,10 @@ export class McpManagementOverlay implements Component { this.clearEditor(); this.notice = undefined; this.phase = { kind: 'input', input, draft, serverId, revision }; - this.editor = new Editor(this.input.tui, editorTheme(), { paddingX: 0 }); + this.editor = + input === 'publication_credential' + ? new MaskedTextInput() + : new Editor(this.input.tui, editorTheme(), { paddingX: 0 }); this.editor.onSubmit = (submitted) => this.submitInput(submitted); this.editor.setText(value); this.editor.focused = true; @@ -607,6 +619,50 @@ export class McpManagementOverlay implements Component { } } +class MaskedTextInput implements OverlayTextInput { + readonly #input = new Input(); + onSubmit?: (value: string) => void; + onChange?: (value: string) => void; + + constructor() { + this.#input.onSubmit = (value) => this.onSubmit?.(value); + } + + get focused(): boolean { + return this.#input.focused; + } + + set focused(value: boolean) { + this.#input.focused = value; + } + + setText(value: string): void { + this.#input.setValue(value); + } + + handleInput(data: string): void { + this.#input.handleInput(data); + this.onChange?.(this.#input.getValue()); + } + + invalidate(): void { + this.#input.invalidate(); + } + + render(width: number): string[] { + return this.#input.render(width).map(maskInputLine); + } +} + +function maskInputLine(line: string): string { + const prompt = line.slice(0, 2); + const value = line.slice(2); + return `${prompt}${value.replaceAll( + /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*\x07)|[^\s]/gu, + (token) => (token.startsWith('\x1b') ? token : '•'), + )}`; +} + function normalizeOneServer(serverId: string, source: string): McpServerConfig { const value: unknown = JSON.parse(source); return normalizeMcpConfig({ version: 3, mcpServers: { [serverId]: value } }).mcpServers[serverId]; From bb69d0f6f3d52b62b9db6b826ba2a687005f4d1f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 13:49:58 +0800 Subject: [PATCH 07/19] test(tui): invoke remote MCP through a Session Exercise the production Runtime Host composition with a real credential store, authenticated owner association, stdio MCP fixture, Session binding, model tool discovery, and an actual MCP echo call. Generated-by: Codex --- .../tui-mcp-remote-integration.test.ts | 348 ++++++++++++++++-- 1 file changed, 320 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts index 09cd6a0d4b..6d53c54e59 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -18,7 +18,8 @@ */ import assert from 'node:assert/strict'; -import { createServer } from 'node:net'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -28,8 +29,10 @@ import { connectRemoteRuntimeHost, connectRuntimeHost, consumeAccessCredentialDelivery, + createClientRuntimeHostCredentialStore, + createClientRuntimeHostProfileCatalog, + createRuntimeHostCapabilityProviderCredentialStore, type RemoteRuntimeHostProfile, - type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostConnection, } from '@maka/runtime-host/client'; import { @@ -37,8 +40,10 @@ import { RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; import { startExecutionRuntimeHostService } from '@maka/runtime-host/server'; +import { mcpProxyToolName } from '@maka/runtime/mcp-tools'; import { createMcpConfigStore } from '@maka/storage/mcp-config-store'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; import { createTuiMcpController, type TuiMcpController } from '../tui-mcp-control.js'; @@ -55,6 +60,8 @@ test('remote TUI publication keeps its owner association across reconnect and re const clientRoot = join(base, 'client'); const eventLog = join(base, 'stdio-events.jsonl'); const port = await reservePort(); + const model = await startModelProvider(); + await seedModelConnection(hostRoot, model.baseUrl); let host = await startHost(hostRoot, port); let local: RuntimeHostConnection | undefined; let terminal: RuntimeHostConnection | undefined; @@ -117,8 +124,13 @@ test('remote TUI publication keeps its owner association across reconnect and re env: { MAKA_MCP_STDIO_EVENT_LOG: eventLog }, protocol: 'legacy', }); - const credentials = credentialStore(firstProvider.credential); const profile = remoteProfile(host.websocketEndpoints[0]!, capability.rootId); + const profiles = createClientRuntimeHostProfileCatalog(clientRoot); + await profiles.create(profile, firstOwner.credential); + const credentials = createRuntimeHostCapabilityProviderCredentialStore( + createClientRuntimeHostCredentialStore(clientRoot), + ); + await credentials.set(profile, 'terminal-a', firstProvider.credential); const publication = createRemoteTuiMcpPublicationTarget( { clientDataRoot: clientRoot, @@ -134,10 +146,48 @@ test('remote TUI publication keeps its owner association across reconnect and re await waitFor(() => controller?.snapshot().publication === 'published'); assert.equal(host.connectionCount, 5); + const sessionId = 'remote-tui-mcp-session'; + const turnId = 'remote-tui-mcp-turn'; + await terminal.request('session.create', { + sessionId, + workspace: { kind: 'host_path', path: hostRoot }, + modelTarget: { kind: 'default' }, + permissionMode: 'bypass', + }); + const started = await terminal.request('turn.start', { + sessionId, + turnId, + content: { text: 'Call the fixture MCP echo tool.' }, + }); + assert.equal(started.kind, 'started'); + let completedTurn: Awaited> | undefined; + await waitFor(async () => { + const turn = await terminal?.request('turn.query', { sessionId, turnId }); + if ( + turn?.status !== 'completed' && + turn?.status !== 'failed' && + turn?.status !== 'cancelled' + ) { + return false; + } + completedTurn = turn; + return true; + }); + assert.equal( + completedTurn?.status, + 'completed', + JSON.stringify({ completedTurn, modelRequests: model.requestSummary() }), + ); + assert.equal(model.fixtureCalls(), 1); + assert.equal(model.observedToolResult(), 'remote-session-sentinel'); + + const wrongProfile = remoteProfile(host.websocketEndpoints[0]!, 'f'.repeat(64), 'wrong-office'); + await profiles.create(wrongProfile, firstOwner.credential); + await credentials.set(wrongProfile, 'terminal-a', firstProvider.credential); const wrongTarget = createRemoteTuiMcpPublicationTarget( { clientDataRoot: clientRoot, - profile: remoteProfile(host.websocketEndpoints[0]!, 'f'.repeat(64)), + profile: wrongProfile, ownerClientInstanceId: 'terminal-a', }, { @@ -180,7 +230,10 @@ test('remote TUI publication keeps its owner association across reconnect and re credentialId: firstProvider.credentialId, }); await waitFor(() => controller?.snapshot().publication === 'credential_rejected'); - assert.equal(credentials.current(), firstProvider.credential); + assert.equal(await credentials.get(profile, 'terminal-a'), firstProvider.credential); + + await createClientRuntimeHostProfileCatalog(clientRoot).remove(profile.id); + await waitFor(() => controller?.snapshot().publication === 'target_mismatch'); await controller.close(); controller = undefined; @@ -197,6 +250,7 @@ test('remote TUI publication keeps its owner association across reconnect and re await terminal?.close().catch(() => undefined); await local?.close().catch(() => undefined); await host.close().catch(() => undefined); + await model.close().catch(() => undefined); await rm(base, { recursive: true, force: true }); } }); @@ -214,13 +268,23 @@ async function provisionOwner( url: string, rootId: string, clientInstanceId: string, -): Promise<{ readonly credentialId: string; readonly connection: RuntimeHostConnection }> { +): Promise<{ + readonly credentialId: string; + readonly credential: string; + readonly connection: RuntimeHostConnection; +}> { const candidate = await local.request('access.credential.prepare', { principalKind: 'remote_owner', principalId: clientInstanceId, - operationGrants: ['access.credential.finalize', 'session.catalog.query'], + operationGrants: [ + 'access.credential.finalize', + 'session.catalog.query', + 'session.create', + 'turn.start', + 'turn.query', + ], canPublishClientCapabilities: false, - canUseHostPaths: false, + canUseHostPaths: true, bindClientInstance: true, }); const credential = await consumeAccessCredentialDelivery( @@ -235,6 +299,7 @@ async function provisionOwner( await pairing.close(); return { credentialId: candidate.credentialId, + credential, connection: await connectRemote(url, rootId, credential, clientInstanceId), }; } @@ -295,9 +360,9 @@ async function connectRemote( return result.connection; } -function remoteProfile(url: string, rootId: string): RemoteRuntimeHostProfile { +function remoteProfile(url: string, rootId: string, id = 'office'): RemoteRuntimeHostProfile { return { - id: 'office', + id, name: 'Office', kind: 'remote', transport: { kind: 'plaintext', url, acknowledgement: 'plaintext-bearer-v1' }, @@ -305,22 +370,6 @@ function remoteProfile(url: string, rootId: string): RemoteRuntimeHostProfile { }; } -function credentialStore(initial: string): RuntimeHostCapabilityProviderCredentialStore & { - current(): string | null; -} { - let credential: string | null = initial; - return { - get: async () => credential, - set: async (_profile, _ownerClientInstanceId, next) => { - credential = next; - }, - delete: async () => { - credential = null; - }, - current: () => credential, - }; -} - function dummyProvider(id: string) { return { offers: () => [ @@ -356,8 +405,251 @@ async function fixtureEvents(path: string): Promise { + const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to acquire model fixture root'); + try { + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: 'remote-mcp-fixture-model', + name: 'Remote MCP fixture model', + providerType: 'moonshot', + baseUrl, + enabled: true, + enabledModelIds: ['hosted-real-model'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') throw new Error('Model fixture connection did not commit'); + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) throw new Error('Model fixture connection was not persisted'); + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'fixture-model-key', + }) + ).kind, + 'committed', + ); + const prepared = await policy.operations.beginModelFetch(connection.connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') throw new Error('Model fixture discovery was not ready'); + const discovered = await policy.operations.completeModelFetch(prepared.ticket, { + models: [ + { + id: 'hosted-real-model', + capabilities: { chat: true, functionCalling: true }, + contextWindow: 8_192, + maxOutputTokens: 128, + }, + ], + source: 'fetched', + fetchedAt: Date.now(), + }); + assert.equal(discovered.kind, 'committed'); + if (discovered.kind !== 'committed') throw new Error('Model fixture discovery did not commit'); + assert.equal( + ( + await policy.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: discovered.snapshot.revision, + target: { + connectionId: connection.connectionId, + modelId: 'hosted-real-model', + }, + }) + ).kind, + 'committed', + ); + } finally { + await owner.close(); + } +} + +async function startModelProvider(): Promise<{ + readonly baseUrl: string; + fixtureCalls(): number; + observedToolResult(): string | undefined; + requestSummary(): readonly unknown[]; + close(): Promise; +}> { + const proxyToolName = mcpProxyToolName('fixture', 'echo'); + let streamRequests = 0; + let fixtureCalls = 0; + let observedToolResult: string | undefined; + const requestSummary: unknown[] = []; + const server = createServer((request, response) => { + void readRequestBody(request) + .then((body) => { + const input = JSON.parse(body) as Record; + requestSummary.push({ stream: input.stream, tools: modelToolNames(input) }); + if (input.stream !== true) { + respondModelSummary(response); + return; + } + streamRequests += 1; + if (streamRequests === 1) { + assert.ok(modelToolNames(input).includes('tool_search')); + respondModelToolCall(response, streamRequests, 'tool_search', { + query: proxyToolName, + }); + return; + } + if (streamRequests === 2) { + assert.ok(modelToolNames(input).includes(proxyToolName)); + fixtureCalls += 1; + respondModelToolCall(response, streamRequests, proxyToolName, { + value: 'remote-session-sentinel', + }); + return; + } + const serialized = JSON.stringify(input); + if (serialized.includes('remote-session-sentinel')) { + observedToolResult = 'remote-session-sentinel'; + } + respondModelText(response, 'Remote MCP fixture completed.'); + }) + .catch((error) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + fixtureCalls: () => fixtureCalls, + observedToolResult: () => observedToolResult, + requestSummary: () => requestSummary, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function modelToolNames(body: Record): string[] { + return (Array.isArray(body.tools) ? body.tools : []).flatMap((tool) => { + if (!tool || typeof tool !== 'object') return []; + const fn = (tool as { function?: unknown }).function; + if (!fn || typeof fn !== 'object') return []; + const name = (fn as { name?: unknown }).name; + return typeof name === 'string' ? [name] : []; + }); +} + +function respondModelToolCall( + response: ServerResponse, + step: number, + toolName: string, + args: Record, +): void { + respondModelEvents(response, [ + { + id: `chatcmpl-remote-mcp-${step}`, + object: 'chat.completion.chunk', + created: step, + model: 'hosted-real-model', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `remote-mcp-tool-call-${step}`, + type: 'function', + function: { name: toolName, arguments: JSON.stringify(args) }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: `chatcmpl-remote-mcp-${step}`, + object: 'chat.completion.chunk', + created: step, + model: 'hosted-real-model', + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }, + ]); +} + +function respondModelText(response: ServerResponse, text: string): void { + respondModelEvents(response, [ + { + id: 'chatcmpl-remote-mcp-complete', + object: 'chat.completion.chunk', + created: 3, + model: 'hosted-real-model', + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], + }, + { + id: 'chatcmpl-remote-mcp-complete', + object: 'chat.completion.chunk', + created: 3, + model: 'hosted-real-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 }, + }, + ]); +} + +function respondModelEvents(response: ServerResponse, events: readonly unknown[]): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end('data: [DONE]\n\n'); +} + +function respondModelSummary(response: ServerResponse): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'chatcmpl-remote-mcp-summary', + object: 'chat.completion', + created: 1, + model: 'hosted-real-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Remote MCP Session' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }, + }), + ); +} + +function readRequestBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); +} + async function reservePort(): Promise { - const server = createServer(); + const server = createNetServer(); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); From 8841aaa15d4f0c820cc234d97f779b68d7da9749 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 13:51:46 +0800 Subject: [PATCH 08/19] fix(tui): await remote MCP peer cleanup Track permanent-failure peer closure through the publication lifecycle, narrow the profile fence to its read authority, and render credentials from a same-length mask instead of post-processing plaintext output. Generated-by: Codex --- .../tui-mcp-remote-publication.test.ts | 9 +++---- packages/cli/src/pi-tui-mcp-status.ts | 17 ++++++------- .../cli/src/tui-mcp-remote-publication.ts | 25 ++++++++++++------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 38270499fd..de84ffbdb5 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -24,6 +24,7 @@ import { type RemoteRuntimeHostProfile, type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostConnection, + type RuntimeHostPeerClient, type RuntimeHostProfileCatalog, } from '@maka/runtime-host/client'; import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; @@ -215,9 +216,7 @@ test('remote TUI publication closes its direct peer after a permanent reconnect loadClientInstanceId: async () => 'provider-client', connectProfile: async () => initial.connection, createPeerClient: () => - ({ close: async () => void (peerCloses += 1) }) as ReturnType< - typeof import('@maka/runtime-host/client').createRuntimeHostPeerClientFromEnvironment - >, + ({ close: async () => void (peerCloses += 1) }) as RuntimeHostPeerClient, createReconnectingConnection: async (input) => { fatal = input.onFatalError; return reconnectingConnection(initial.connection); @@ -274,9 +273,9 @@ function profileDeps(profile: RemoteRuntimeHostProfile = PROFILE) { function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { let profiles: RemoteRuntimeHostProfile[] = [initial]; const listeners = new Set<(error?: Error) => void>(); - const catalog = { + const catalog: Pick = { read: async () => ({ schemaVersion: 3 as const, profiles }), - } as unknown as RuntimeHostProfileCatalog; + }; return { catalog, subscribe: (listener: (error?: Error) => void) => { diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 152c5bb9bd..5777570ad2 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -650,19 +650,16 @@ class MaskedTextInput implements OverlayTextInput { } render(width: number): string[] { - return this.#input.render(width).map(maskInputLine); + const value = this.#input.getValue(); + this.#input.setValue('•'.repeat(value.length)); + try { + return this.#input.render(width); + } finally { + this.#input.setValue(value); + } } } -function maskInputLine(line: string): string { - const prompt = line.slice(0, 2); - const value = line.slice(2); - return `${prompt}${value.replaceAll( - /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*\x07)|[^\s]/gu, - (token) => (token.startsWith('\x1b') ? token : '•'), - )}`; -} - function normalizeOneServer(serverId: string, source: string): McpServerConfig { const value: unknown = JSON.parse(source); return normalizeMcpConfig({ version: 3, mcpServers: { [serverId]: value } }).mcpServers[serverId]; diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index 9bfbacf305..d9e039f36b 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -53,7 +53,7 @@ interface RemoteTuiMcpPublicationDeps { readonly connectProfile: typeof connectRuntimeHostProfile; readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; - readonly profiles: RuntimeHostProfileCatalog; + readonly profiles: Pick; readonly subscribeProfileChanges: (listener: (error?: Error) => void) => () => void; } @@ -96,6 +96,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { #connection: RuntimeHostReconnectingConnection | undefined; #disposeAvailability: (() => void) | undefined; #peerClient: RuntimeHostPeerClient | undefined; + #peerCloseTask = Promise.resolve(); #operation = Promise.resolve(); #connectAbort: AbortController | undefined; #generation = 0; @@ -225,7 +226,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { const initial = await connect(); if (this.#closed || generation !== this.#generation) { await initial.close().catch(() => undefined); - await peerClient?.close().catch(() => undefined); + await this.#closePeer(peerClient); return; } const connection = await this.#deps.createReconnectingConnection({ @@ -235,15 +236,14 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { if (!this.#closed && generation === this.#generation) { this.#setUnavailable(classifyUnavailable(error)); if (this.#peerClient === peerClient) { - this.#peerClient = undefined; - void peerClient?.close().catch(() => undefined); + void this.#closePeer(peerClient); } } }, }); if (this.#closed || generation !== this.#generation) { await connection.close().catch(() => undefined); - await peerClient?.close().catch(() => undefined); + await this.#closePeer(peerClient); return; } this.#connection = connection; @@ -259,8 +259,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { if (!this.#closed && generation === this.#generation) { this.#setUnavailable(classifyUnavailable(error)); } - await this.#peerClient?.close().catch(() => undefined); - this.#peerClient = undefined; + await this.#closePeer(this.#peerClient); } finally { if (this.#connectAbort === abort) this.#connectAbort = undefined; } @@ -274,8 +273,8 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { const connection = this.#connection; this.#connection = undefined; await connection?.close().catch(() => undefined); - await this.#peerClient?.close().catch(() => undefined); - this.#peerClient = undefined; + await this.#closePeer(this.#peerClient); + await this.#peerCloseTask; if (!this.#closed) this.#setUnavailable('host_unavailable'); } @@ -335,6 +334,14 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { this.#connectAbort?.abort(new Error('Remote MCP publication target changed')); } + #closePeer(peerClient: RuntimeHostPeerClient | undefined): Promise { + if (!peerClient) return this.#peerCloseTask; + if (this.#peerClient === peerClient) this.#peerClient = undefined; + const closing = this.#peerCloseTask.then(() => peerClient.close()).catch(() => undefined); + this.#peerCloseTask = closing; + return closing; + } + #setUnavailable(reason: TuiMcpPublicationUnavailableReason): void { this.#setAvailability({ kind: 'unavailable', reason }); } From ba2785fbeec3fbb195f2ab8fcb350cb76017a99f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 20:40:51 +0800 Subject: [PATCH 09/19] fix(tui): fence remote MCP profile retirement --- .../tui-mcp-remote-publication.test.ts | 159 ++++++++++++++++-- .../cli/src/tui-mcp-remote-publication.ts | 64 ++++--- 2 files changed, 184 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index de84ffbdb5..76138591bc 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -38,6 +38,16 @@ const PROFILE: RemoteRuntimeHostProfile = { rootId: 'a'.repeat(64), }; +const DIRECT_PROFILE: RemoteRuntimeHostProfile = { + ...PROFILE, + transport: { + kind: 'libp2p-direct', + peerId: 'peer-a', + routeHints: ['/ip4/127.0.0.1/tcp/4001'], + coordinationRelays: [], + }, +}; + test('remote TUI publication activates, rotates, and removes one profile-bound credential', async () => { const credentials = credentialHarness(); const connected: Array<{ credential?: string; clientInstanceId: string }> = []; @@ -190,25 +200,109 @@ test('remote TUI publication retires when another process removes its profile', }); }); -test('remote TUI publication closes its direct peer after a permanent reconnect failure', async () => { +test('remote TUI publication revalidates an invalidation received during a profile read', async () => { const credentials = credentialHarness('provider-secret'); - const profile: RemoteRuntimeHostProfile = { - ...PROFILE, - transport: { - kind: 'libp2p-direct', - peerId: 'peer-a', - routeHints: ['/ip4/127.0.0.1/tcp/4001'], - coordinationRelays: [], + const profiles = profileHarness(); + const connection = connectionHarness('connection-1'); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', }, - }; - const profiles = profileHarness(profile); + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => connection.connection, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + + const heldRead = profiles.holdNextRead(); + profiles.invalidate(); + await heldRead.started; + profiles.remove(); + heldRead.release(); + + await waitFor(() => { + const current = latest(); + return current.kind === 'unavailable' && current.reason === 'target_mismatch'; + }, 'later profile invalidation to retire provider companion'); + assert.equal(connection.closes, 1); + await target.closePublication?.(); +}); + +test('remote TUI publication close waits for concurrent profile retirement cleanup', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(DIRECT_PROFILE); + const connectionCloseStarted = deferred(); + const allowConnectionClose = deferred(); + const peerCloseStarted = deferred(); + const allowPeerClose = deferred(); + let peerCloses = 0; + const connection = connectionHarness('connection-1', async () => { + connectionCloseStarted.resolve(); + await allowConnectionClose.promise; + }); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: DIRECT_PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => connection.connection, + createPeerClient: () => + ({ + close: async () => { + peerCloses += 1; + peerCloseStarted.resolve(); + await allowPeerClose.promise; + }, + }) as RuntimeHostPeerClient, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + + profiles.remove(); + await connectionCloseStarted.promise; + let closeSettled = false; + const close = target.closePublication?.().then(() => { + closeSettled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closeSettled, false); + + allowConnectionClose.resolve(); + await peerCloseStarted.promise; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closeSettled, false); + + allowPeerClose.resolve(); + await close; + assert.equal(closeSettled, true); + assert.equal(connection.closes, 1); + assert.equal(peerCloses, 1); +}); + +test('remote TUI publication closes its direct peer after a permanent reconnect failure', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(DIRECT_PROFILE); const initial = connectionHarness('connection-1'); let peerCloses = 0; let fatal: ((error: Error) => void) | undefined; const target = createRemoteTuiMcpPublicationTarget( { clientDataRoot: '/client-data', - profile, + profile: DIRECT_PROFILE, ownerClientInstanceId: 'terminal-client', }, { @@ -273,8 +367,26 @@ function profileDeps(profile: RemoteRuntimeHostProfile = PROFILE) { function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { let profiles: RemoteRuntimeHostProfile[] = [initial]; const listeners = new Set<(error?: Error) => void>(); + let heldRead: + | { + readonly started: ReturnType; + readonly release: ReturnType; + } + | undefined; const catalog: Pick = { - read: async () => ({ schemaVersion: 3 as const, profiles }), + read: async () => { + const snapshot = profiles; + const held = heldRead; + heldRead = undefined; + if (held) { + held.started.resolve(); + await held.release.promise; + } + return { schemaVersion: 3 as const, profiles: snapshot }; + }, + }; + const invalidate = () => { + for (const listener of listeners) listener(); }; return { catalog, @@ -282,9 +394,16 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { listeners.add(listener); return () => listeners.delete(listener); }, + invalidate, remove: () => { profiles = []; - for (const listener of listeners) listener(); + invalidate(); + }, + holdNextRead: () => { + const started = deferred(); + const release = deferred(); + heldRead = { started, release }; + return { started: started.promise, release: release.resolve }; }, }; } @@ -295,7 +414,10 @@ interface ConnectionHarness { closes: number; } -function connectionHarness(connectionId: string): ConnectionHarness { +function connectionHarness( + connectionId: string, + beforeClose: () => Promise = async () => undefined, +): ConnectionHarness { let resolveClosed!: () => void; const closed = new Promise((resolve) => { resolveClosed = resolve; @@ -324,12 +446,21 @@ function connectionHarness(connectionId: string): ConnectionHarness { subscribeScheduledTaskChanges: () => () => undefined, close: async () => { harness.closes += 1; + await beforeClose(); resolveClosed(); }, } as unknown as RuntimeHostConnection; return harness; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + function reconnectingConnection(connection: RuntimeHostConnection) { return { ...connection, diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index d9e039f36b..b08c6e7f7d 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -104,6 +104,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { #closeTask: Promise | undefined; #disposeProfileChanges: (() => void) | undefined; #profileValidationQueued = false; + #profileInvalidationGeneration = 0; #profileValidationError: Error | undefined; constructor( @@ -187,8 +188,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { closePublication(): Promise { this.#cancelConnect(); - this.#closeTask ??= this.#close(); - return this.#closeTask; + return this.#beginClose({ waitForOperations: true }); } #serialize(work: () => Promise): Promise { @@ -278,31 +278,32 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { if (!this.#closed) this.#setUnavailable('host_unavailable'); } - async #close(): Promise { - if (this.#closed) return; - this.#closed = true; - this.#disposeProfileChanges?.(); - this.#disposeProfileChanges = undefined; - await this.#operation.catch(() => undefined); - await this.#disconnect(); - this.#listeners.clear(); - } - #scheduleProfileValidation(error?: Error): void { if (this.#closed) return; + this.#profileInvalidationGeneration += 1; this.#profileValidationError ??= error; if (this.#profileValidationQueued) return; this.#profileValidationQueued = true; void this.#serialize(async () => { - const profileCurrent = await this.#profileStillCurrent().catch(() => undefined); - const validationError = this.#profileValidationError; - this.#profileValidationError = undefined; - this.#profileValidationQueued = false; - if (this.#closed) return; - if (validationError || profileCurrent !== true) { - await this.#retire( - validationError || profileCurrent === undefined ? 'host_unavailable' : 'target_mismatch', - ); + try { + while (!this.#closed) { + const generation = this.#profileInvalidationGeneration; + const profileCurrent = await this.#profileStillCurrent().catch(() => undefined); + const validationError = this.#profileValidationError; + this.#profileValidationError = undefined; + if (this.#closed) return; + if (validationError || profileCurrent !== true) { + await this.#retire( + validationError || profileCurrent === undefined + ? 'host_unavailable' + : 'target_mismatch', + ); + return; + } + if (generation === this.#profileInvalidationGeneration) return; + } + } finally { + this.#profileValidationQueued = false; } }); } @@ -316,13 +317,26 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } async #retire(reason: TuiMcpPublicationUnavailableReason): Promise { - if (this.#closed) return; + await this.#beginClose({ reason, waitForOperations: false }); + } + + #beginClose(input: { + readonly reason?: TuiMcpPublicationUnavailableReason; + readonly waitForOperations: boolean; + }): Promise { + if (this.#closeTask) return this.#closeTask; this.#closed = true; this.#disposeProfileChanges?.(); this.#disposeProfileChanges = undefined; - this.#setUnavailable(reason); - await this.#disconnect(); - this.#listeners.clear(); + if (input.reason) this.#setUnavailable(input.reason); + const operations = input.waitForOperations + ? this.#operation.catch(() => undefined) + : Promise.resolve(); + this.#closeTask = operations.then(async () => { + await this.#disconnect(); + this.#listeners.clear(); + }); + return this.#closeTask; } #requireConnection(): RuntimeHostReconnectingConnection { From 3d2163f85931e06894ec9840780a32afd783bf7b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 30 Aug 2026 21:33:06 +0800 Subject: [PATCH 10/19] fix(tui): serialize remote MCP credential changes --- .../runtime-host-cli-context.test.ts | 4 ++ .../runtime-host-profile-command.test.ts | 1 + .../tui-mcp-remote-publication.test.ts | 58 ++++++++++++++++++- .../cli/src/tui-mcp-remote-publication.ts | 27 +++++++-- .../src/__tests__/host-profile.test.ts | 43 ++++++++++++++ .../runtime-host/src/client/host-profile.ts | 22 +++++++ 6 files changed, 149 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index d7b200b8f5..5e4186c69c 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -300,6 +300,9 @@ test('remote CLI profiles pin root identity and resolve credential outside the p rebindIfCurrent: async () => { throw new Error('unexpected write'); }, + mutateRemoteProfileIfCurrent: async () => { + throw new Error('unexpected write'); + }, }, loadClientInstanceId: async () => '11111111-1111-4111-8111-111111111111', readConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }), @@ -557,5 +560,6 @@ function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeH remove: async () => assert.fail('unexpected write'), removeIfCurrent: async () => assert.fail('unexpected write'), rebindIfCurrent: async () => assert.fail('unexpected write'), + mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected write'), }; } diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts index 4f89d2ce27..27ff85c0aa 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -236,6 +236,7 @@ function createProfileCatalogCapture(): { remove: async () => assert.fail('unexpected profile removal'), removeIfCurrent: async () => assert.fail('unexpected conditional profile removal'), rebindIfCurrent: async () => assert.fail('unexpected conditional profile rebind'), + mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected conditional mutation'), }; return { get document() { diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 76138591bc..4183bb5098 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { RuntimeHostProfileConnectionError, + sameRemoteRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, type RuntimeHostCapabilityProviderCredentialStore, type RuntimeHostConnection, @@ -200,6 +201,37 @@ test('remote TUI publication retires when another process removes its profile', }); }); +test('remote TUI publication cannot restore a credential after profile removal', async () => { + const credentials = credentialHarness(); + const profiles = profileHarness(); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'credential_required' }); + + const heldMutation = profiles.holdNextMutation(); + const setCredential = target.setCredential?.('replacement-secret'); + assert.ok(setCredential); + await heldMutation.started; + profiles.remove(); + heldMutation.release(); + + await assert.rejects(setCredential, /profile is no longer current/u); + assert.equal(credentials.values.has('office\0terminal-client'), false); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'target_mismatch' }); + await target.closePublication?.(); +}); + test('remote TUI publication revalidates an invalidation received during a profile read', async () => { const credentials = credentialHarness('provider-secret'); const profiles = profileHarness(); @@ -373,7 +405,13 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { readonly release: ReturnType; } | undefined; - const catalog: Pick = { + let heldMutation: + | { + readonly started: ReturnType; + readonly release: ReturnType; + } + | undefined; + const catalog: Pick = { read: async () => { const snapshot = profiles; const held = heldRead; @@ -384,6 +422,18 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { } return { schemaVersion: 3 as const, profiles: snapshot }; }, + mutateRemoteProfileIfCurrent: async (expected, mutation) => { + const held = heldMutation; + heldMutation = undefined; + if (held) { + held.started.resolve(); + await held.release.promise; + } + const current = profiles.find((profile) => profile.id === expected.id); + if (!current || !sameRemoteRuntimeHostProfileTarget(current, expected)) return false; + await mutation(current); + return true; + }, }; const invalidate = () => { for (const listener of listeners) listener(); @@ -405,6 +455,12 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { heldRead = { started, release }; return { started: started.promise, release: release.resolve }; }, + holdNextMutation: () => { + const started = deferred(); + const release = deferred(); + heldMutation = { started, release }; + return { started: started.promise, release: release.resolve }; + }, }; } diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index b08c6e7f7d..7cb79b412a 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -53,7 +53,7 @@ interface RemoteTuiMcpPublicationDeps { readonly connectProfile: typeof connectRuntimeHostProfile; readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; - readonly profiles: Pick; + readonly profiles: Pick; readonly subscribeProfileChanges: (listener: (error?: Error) => void) => () => void; } @@ -166,11 +166,18 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { this.#cancelConnect(); return this.#serialize(async () => { if (this.#closed) throw new Error('Remote MCP publication is closed'); - await this.#deps.credentials.set( + const committed = await this.#deps.profiles.mutateRemoteProfileIfCurrent( this.#input.profile, - this.#input.ownerClientInstanceId, - credential, + (profile) => + this.#deps.credentials.set(profile, this.#input.ownerClientInstanceId, credential), ); + if (!committed) { + await this.#retire('target_mismatch'); + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + 'Remote MCP publication profile is no longer current', + ); + } await this.#disconnect(); await this.#connect(credential); }); @@ -181,7 +188,17 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { return this.#serialize(async () => { if (this.#closed) throw new Error('Remote MCP publication is closed'); await this.#disconnect(); - await this.#deps.credentials.delete(this.#input.profile, this.#input.ownerClientInstanceId); + const committed = await this.#deps.profiles.mutateRemoteProfileIfCurrent( + this.#input.profile, + (profile) => this.#deps.credentials.delete(profile, this.#input.ownerClientInstanceId), + ); + if (!committed) { + await this.#retire('target_mismatch'); + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + 'Remote MCP publication profile is no longer current', + ); + } this.#setUnavailable('credential_required'); }); } diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index f8f9f7c42a..69917d6eb2 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -462,6 +462,41 @@ describe('Runtime Host profiles', () => { assert.equal(await providers.get(profile, 'owner-a'), null); }); + test('profile removal excludes a queued provider credential mutation', async () => { + const path = await profilePath(); + const credentialStore = createFileCredentialStore(join(dirname(path), 'credentials')); + const stored = createRuntimeHostProfileCredentialStore(credentialStore); + const removalStarted = deferred(); + const allowRemoval = deferred(); + const credentials: RuntimeHostProfileCredentialStore = { + ...stored, + delete: async (profile) => { + removalStarted.resolve(); + await allowRemoval.promise; + await stored.delete(profile); + }, + }; + const removingCatalog = createFileRuntimeHostProfileCatalog(path, credentials); + const mutatingCatalog = createFileRuntimeHostProfileCatalog(path, credentials); + const providers = createRuntimeHostCapabilityProviderCredentialStore(credentialStore); + const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); + await removingCatalog.save(profile, 'terminal-token'); + + const removal = removingCatalog.remove(profile.id); + await removalStarted.promise; + let mutationRan = false; + const mutation = mutatingCatalog.mutateRemoteProfileIfCurrent(profile, async (current) => { + mutationRan = true; + await providers.set(current, 'owner-a', 'provider-token'); + }); + allowRemoval.resolve(); + + await removal; + assert.equal(await mutation, false); + assert.equal(mutationRan, false); + assert.equal(await providers.get(profile, 'owner-a'), null); + }); + test('pins a direct-peer profile to its PeerId while allowing route discovery to change', () => { const original = directPeerProfile('peer-a', ['/ip4/192.0.2.10/udp/4001/quic-v1']); const moved = directPeerProfile('peer-a', ['/ip6/2001:db8::10/udp/4001/quic-v1']); @@ -996,6 +1031,14 @@ function memoryCredentials(): RuntimeHostProfileCredentialStore { }; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + function incompatibleHandshake(overrides: Partial = {}): HostIncompatible { return { kind: 'incompatible', diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index b4279ee800..8783e7c077 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -215,6 +215,11 @@ export interface RuntimeHostProfileCatalog { readonly rebound: boolean; readonly document: RuntimeHostProfileDocument; }>; + /** Serialize one sidecar mutation with catalog updates while this exact target remains current. */ + mutateRemoteProfileIfCurrent( + target: RemoteRuntimeHostProfile, + mutation: (profile: RemoteRuntimeHostProfile) => Promise, + ): Promise; } export interface RuntimeHostProfileCredentialStore { @@ -946,6 +951,23 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { }); } + mutateRemoteProfileIfCurrent( + target: RemoteRuntimeHostProfile, + mutation: (profile: RemoteRuntimeHostProfile) => Promise, + ): Promise { + const expectedProfile = decodeRemoteRuntimeHostProfile(target); + return this.#exclusive(async () => { + const current = await this.#readSnapshot(); + const profile = current.profiles.find( + (candidate): candidate is RemoteRuntimeHostProfile => + candidate.id === expectedProfile.id && candidate.kind === 'remote', + ); + if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; + await mutation(profile); + return true; + }); + } + async #removeProfile( current: RuntimeHostProfileDocument, profile: PersistedRuntimeHostProfile, From d89c31a1cf10e6a043e0e31ddaeb2f3dfe5c30e8 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 06:41:40 +0800 Subject: [PATCH 11/19] fix(runtime-host): fence profile incarnation reuse --- .../src/__tests__/host-profile.test.ts | 147 ++++++++++-- .../runtime-host/src/client/host-profile.ts | 225 ++++++++++++++---- packages/runtime-host/src/client/index.ts | 1 + 3 files changed, 299 insertions(+), 74 deletions(-) diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 69917d6eb2..4128b86ae0 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -37,6 +37,7 @@ import { RuntimeHostProfileConnectionError, sameRemoteRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, + type RuntimeHostProfileCredential, type RuntimeHostProfileCredentialStore, } from '../client/host-profile.js'; import { RuntimeHostPermanentReconnectError } from '../client/reconnect-lifecycle.js'; @@ -269,6 +270,7 @@ describe('Runtime Host profiles', () => { await desktop.create(profile, 'desktop-token'); const created = await desktop.resolve(profile.id); + assert.ok(created.profileIncarnationId); await assert.rejects(() => cli.create(profile, 'duplicate-token'), /new profile id/u); await cli.save({ ...profile, name: 'Rotated' }, 'rotated-token'); @@ -287,6 +289,7 @@ describe('Runtime Host profiles', () => { }); const rotated = await desktop.resolve(profile.id); assert.equal(rotated.credential, 'rotated-token'); + assert.equal(rotated.profileIncarnationId, created.profileIncarnationId); assert.equal((await desktop.removeIfCurrent(rotated)).removed, true); assert.deepEqual(await desktop.read(), { schemaVersion: 3, profiles: [] }); }); @@ -301,13 +304,16 @@ describe('Runtime Host profiles', () => { await desktop.create(original, 'old-token'); const expected = await desktop.resolve(original.id); + assert.ok(expected.profileIncarnationId); assert.equal((await desktop.rebindIfCurrent(expected, replacement, 'new-token')).rebound, true); - assert.deepEqual(await desktop.resolve(original.id), { + const rebound = await desktop.resolve(original.id); + assert.deepEqual(rebound, { profile: { ...replacement, transport: { kind: 'tls', url: 'wss://runtime.example.com/' }, }, credential: 'new-token', + profileIncarnationId: expected.profileIncarnationId, }); await external.save({ ...replacement, name: 'Externally updated' }, 'external-token'); @@ -413,11 +419,43 @@ describe('Runtime Host profiles', () => { const resolved = await first.resolve('office'); assert.equal(resolved.credential, 'token-a'); - await credentials.set(targetB, 'token-b'); - assert.equal(await credentials.get(targetA), 'token-a'); - assert.equal(await credentials.get(targetB), 'token-b'); + await credentials.set(targetB, { + credential: 'token-b', + profileIncarnationId: 'target-b-incarnation', + }); + assert.equal((await credentials.get(targetA))?.credential, 'token-a'); + assert.equal((await credentials.get(targetB))?.credential, 'token-b'); await credentials.delete(targetB); - assert.equal(await credentials.get(targetA), 'token-a'); + assert.equal((await credentials.get(targetA))?.credential, 'token-a'); + }); + + test('keeps legacy access credentials readable while assigning a stable incarnation', async () => { + const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); + let stored = 'legacy-token'; + const credentials = createRuntimeHostProfileCredentialStore({ + getSecret: async () => stored, + setSecret: async (_slot, _kind, value) => { + stored = value; + }, + deleteSecret: async () => { + stored = ''; + }, + }); + + const first = await credentials.get(profile); + const second = await credentials.get(profile); + assert.equal(first?.credential, 'legacy-token'); + assert.equal(first?.profileIncarnationId, second?.profileIncarnationId); + assert.ok(first?.profileIncarnationId); + + await credentials.set(profile, { + credential: 'rotated-token', + profileIncarnationId: first.profileIncarnationId, + }); + assert.deepEqual(await credentials.get(profile), { + credential: 'rotated-token', + profileIncarnationId: first.profileIncarnationId, + }); }); test('isolates capability-provider credentials by target and owning Client', async () => { @@ -427,22 +465,29 @@ describe('Runtime Host profiles', () => { ); const targetA = remoteProfile('office', 'wss://a.example.com', ROOT_A); const targetB = remoteProfile('office', 'wss://b.example.com', ROOT_B); + const incarnationA = { profile: targetA, profileIncarnationId: 'incarnation-a' }; + const recreatedIncarnationA = { + profile: targetA, + profileIncarnationId: 'incarnation-a-recreated', + }; + const incarnationB = { profile: targetB, profileIncarnationId: 'incarnation-b' }; await assert.rejects( - () => credentials.set(targetA, 'owner-a', 'not a token'), + () => credentials.set(incarnationA, 'owner-a', 'not a token'), /credential is invalid/, ); - await credentials.set(targetA, 'owner-a', 'provider-a'); - assert.equal(await credentials.get(targetA, 'owner-b'), null); - await credentials.set(targetA, 'owner-b', 'provider-b'); - await credentials.set(targetB, 'owner-a', 'provider-other-target'); - - assert.equal(await credentials.get(targetA, 'owner-a'), null); - assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); - assert.equal(await credentials.get(targetB, 'owner-a'), 'provider-other-target'); - await credentials.delete(targetA, 'owner-a'); - assert.equal(await credentials.get(targetA, 'owner-a'), null); - assert.equal(await credentials.get(targetA, 'owner-b'), 'provider-b'); + await credentials.set(incarnationA, 'owner-a', 'provider-a'); + assert.equal(await credentials.get(incarnationA, 'owner-b'), null); + await credentials.set(incarnationA, 'owner-b', 'provider-b'); + await credentials.set(incarnationB, 'owner-a', 'provider-other-target'); + + assert.equal(await credentials.get(incarnationA, 'owner-a'), null); + assert.equal(await credentials.get(incarnationA, 'owner-b'), 'provider-b'); + assert.equal(await credentials.get(recreatedIncarnationA, 'owner-b'), null); + assert.equal(await credentials.get(incarnationB, 'owner-a'), 'provider-other-target'); + await credentials.delete(incarnationA, 'owner-a'); + assert.equal(await credentials.get(incarnationA, 'owner-a'), null); + assert.equal(await credentials.get(incarnationA, 'owner-b'), 'provider-b'); }); test('removing a profile retires its terminal and provider credentials together', async () => { @@ -455,11 +500,14 @@ describe('Runtime Host profiles', () => { const providers = createRuntimeHostCapabilityProviderCredentialStore(credentialStore); const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); await catalog.save(profile, 'terminal-token'); - await providers.set(profile, 'owner-a', 'provider-token'); + const target = await catalog.resolve(profile.id); + assert.ok(target.profileIncarnationId); + const incarnation = { profile, profileIncarnationId: target.profileIncarnationId }; + await providers.set(incarnation, 'owner-a', 'provider-token'); await catalog.remove(profile.id); - assert.equal(await providers.get(profile, 'owner-a'), null); + assert.equal(await providers.get(incarnation, 'owner-a'), null); }); test('profile removal excludes a queued provider credential mutation', async () => { @@ -481,20 +529,68 @@ describe('Runtime Host profiles', () => { const providers = createRuntimeHostCapabilityProviderCredentialStore(credentialStore); const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); await removingCatalog.save(profile, 'terminal-token'); + const resolved = await removingCatalog.resolve(profile.id); + assert.ok(resolved.profileIncarnationId); + const incarnation = { profile, profileIncarnationId: resolved.profileIncarnationId }; const removal = removingCatalog.remove(profile.id); await removalStarted.promise; let mutationRan = false; - const mutation = mutatingCatalog.mutateRemoteProfileIfCurrent(profile, async (current) => { + const mutation = mutatingCatalog.mutateRemoteProfileIfCurrent(incarnation, async (current) => { mutationRan = true; - await providers.set(current, 'owner-a', 'provider-token'); + await providers.set( + { profile: current, profileIncarnationId: incarnation.profileIncarnationId }, + 'owner-a', + 'provider-token', + ); }); allowRemoval.resolve(); await removal; assert.equal(await mutation, false); assert.equal(mutationRan, false); - assert.equal(await providers.get(profile, 'owner-a'), null); + assert.equal(await providers.get(incarnation, 'owner-a'), null); + }); + + test('recreating the same profile id and target assigns a new incarnation', async () => { + const path = await profilePath(); + const credentialStore = createFileCredentialStore(join(dirname(path), 'credentials')); + const catalog = createFileRuntimeHostProfileCatalog( + path, + createRuntimeHostProfileCredentialStore(credentialStore), + ); + const providers = createRuntimeHostCapabilityProviderCredentialStore(credentialStore); + const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); + await catalog.create(profile, 'terminal-token'); + const first = await catalog.resolve(profile.id); + assert.ok(first.profileIncarnationId); + const firstIncarnation = { + profile, + profileIncarnationId: first.profileIncarnationId, + }; + await providers.set(firstIncarnation, 'owner-a', 'provider-token'); + + await catalog.remove(profile.id); + await catalog.create(profile, 'terminal-token'); + const second = await catalog.resolve(profile.id); + assert.ok(second.profileIncarnationId); + const secondIncarnation = { + profile, + profileIncarnationId: second.profileIncarnationId, + }; + + assert.notEqual(second.profileIncarnationId, first.profileIncarnationId); + assert.equal(await catalog.isRemoteProfileIncarnationCurrent(firstIncarnation), false); + assert.equal(await catalog.isRemoteProfileIncarnationCurrent(secondIncarnation), true); + assert.equal(await providers.get(secondIncarnation, 'owner-a'), null); + let staleMutationRan = false; + assert.equal( + await catalog.mutateRemoteProfileIfCurrent(firstIncarnation, async () => { + staleMutationRan = true; + }), + false, + ); + assert.equal(staleMutationRan, false); }); test('pins a direct-peer profile to its PeerId while allowing route discovery to change', () => { @@ -512,7 +608,7 @@ describe('Runtime Host profiles', () => { test('keeps profile metadata when credential removal fails', async () => { const path = await profilePath(); - const values = new Map(); + const values = new Map(); const credentials: RuntimeHostProfileCredentialStore = { get: async (profile) => values.get(profile.id) ?? null, set: async (profile, credential) => { @@ -551,6 +647,8 @@ describe('Runtime Host profiles', () => { const targetA = remoteProfile('office', 'wss://a.example.com', ROOT_A); const targetB = { ...targetA, name: 'updated' }; await catalog.save(targetA, 'token-a'); + const original = await catalog.resolve('office'); + assert.ok(original.profileIncarnationId); rejectNextSet = true; await assert.rejects(() => catalog.save(targetB, 'token-b'), /credential store unavailable/); @@ -560,6 +658,7 @@ describe('Runtime Host profiles', () => { transport: { kind: 'tls', url: 'wss://a.example.com/' }, }, credential: 'token-a', + profileIncarnationId: original.profileIncarnationId, }); }); @@ -1017,7 +1116,7 @@ function directPeerProfile( } function memoryCredentials(): RuntimeHostProfileCredentialStore { - const values = new Map(); + const values = new Map(); const key = (profile: RemoteRuntimeHostProfile) => `${profile.id}\0${JSON.stringify(profile.transport)}\0${profile.rootId}`; return { diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 8783e7c077..63bf333990 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -71,6 +71,8 @@ const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const PEER_ID_MAX_BYTES = 160; const PEER_ADDRESS_MAX_BYTES = 2 * 1024; const PEER_ROUTE_MAX = 16; +const PROFILE_CREDENTIAL_RECORD_PREFIX = 'maka-runtime-host-profile-credential-v1:'; +const PROFILE_INCARNATION_ID_MAX_BYTES = 128; export const RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES = 8 * 1024; export const RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT = 'plaintext-bearer-v1' as const; @@ -157,6 +159,12 @@ export interface RuntimeHostProfileDocument { export interface ResolvedRuntimeHostProfile { readonly profile: RuntimeHostProfile; readonly credential?: string; + readonly profileIncarnationId?: string; +} + +export interface RuntimeHostRemoteProfileIncarnation { + readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; } export type RuntimeHostConnectionPhase = @@ -215,27 +223,37 @@ export interface RuntimeHostProfileCatalog { readonly rebound: boolean; readonly document: RuntimeHostProfileDocument; }>; - /** Serialize one sidecar mutation with catalog updates while this exact target remains current. */ + /** Serialize one sidecar mutation with catalog updates while this profile lifetime remains current. */ mutateRemoteProfileIfCurrent( - target: RemoteRuntimeHostProfile, + target: RuntimeHostRemoteProfileIncarnation, mutation: (profile: RemoteRuntimeHostProfile) => Promise, ): Promise; + isRemoteProfileIncarnationCurrent(target: RuntimeHostRemoteProfileIncarnation): Promise; +} + +export interface RuntimeHostProfileCredential { + readonly credential: string; + /** Stable for updates, replaced when removal and recreation start a new profile lifetime. */ + readonly profileIncarnationId: string; } export interface RuntimeHostProfileCredentialStore { - get(profile: RemoteRuntimeHostProfile): Promise; - set(profile: RemoteRuntimeHostProfile, credential: string): Promise; + get(profile: RemoteRuntimeHostProfile): Promise; + set(profile: RemoteRuntimeHostProfile, credential: RuntimeHostProfileCredential): Promise; delete(profile: RemoteRuntimeHostProfile): Promise; } export interface RuntimeHostCapabilityProviderCredentialStore { - get(profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string): Promise; + get( + target: RuntimeHostRemoteProfileIncarnation, + ownerClientInstanceId: string, + ): Promise; set( - profile: RemoteRuntimeHostProfile, + target: RuntimeHostRemoteProfileIncarnation, ownerClientInstanceId: string, credential: string, ): Promise; - delete(profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string): Promise; + delete(target: RuntimeHostRemoteProfileIncarnation, ownerClientInstanceId: string): Promise; } export type RuntimeHostProfileConnectionFailureReason = @@ -290,19 +308,23 @@ export function createRuntimeHostProfileCredentialStore( ): RuntimeHostProfileCredentialStore { return { get: async (profile) => { - return credentials.getSecret(profileCredentialSlot(profile), 'runtime_host_access'); + const stored = await credentials.getSecret( + profileCredentialSlot(profile), + 'runtime_host_access', + ); + return stored === null ? null : decodeProfileCredential(profile, stored); }, set: (profile, credential) => { try { - requireRuntimeHostAccessCredential(credential); + const encoded = encodeProfileCredential(credential); + return credentials.setSecret( + profileCredentialSlot(profile), + 'runtime_host_access', + encoded, + ); } catch (error) { return Promise.reject(error); } - return credentials.setSecret( - profileCredentialSlot(profile), - 'runtime_host_access', - credential, - ); }, delete: (profile) => credentials.deleteSecret(profileCredentialSlot(profile)), }; @@ -312,30 +334,32 @@ export function createRuntimeHostCapabilityProviderCredentialStore( credentials: Pick, ): RuntimeHostCapabilityProviderCredentialStore { return { - get: async (profile, ownerClientInstanceId) => { + get: async (target, ownerClientInstanceId) => { const stored = await credentials.getSecret( - profileCredentialSlot(profile), + profileCredentialSlot(target.profile), 'runtime_host_capability_provider', ); if (stored === null) return null; const decoded = decodeCapabilityProviderCredential(stored); - return decoded.ownerClientInstanceId === requireClientInstanceId(ownerClientInstanceId) + return decoded.ownerClientInstanceId === requireClientInstanceId(ownerClientInstanceId) && + decoded.profileIncarnationId === requireProfileIncarnationId(target.profileIncarnationId) ? decoded.credential : null; }, - set: async (profile, ownerClientInstanceId, credential) => { + set: async (target, ownerClientInstanceId, credential) => { await credentials.setSecret( - profileCredentialSlot(profile), + profileCredentialSlot(target.profile), 'runtime_host_capability_provider', JSON.stringify({ schemaVersion: 1, + profileIncarnationId: requireProfileIncarnationId(target.profileIncarnationId), ownerClientInstanceId: requireClientInstanceId(ownerClientInstanceId), credential: requireRuntimeHostAccessCredential(credential), }), ); }, - delete: (profile, ownerClientInstanceId) => - deleteCapabilityProviderCredential(credentials, profile, ownerClientInstanceId), + delete: (target, ownerClientInstanceId) => + deleteCapabilityProviderCredential(credentials, target, ownerClientInstanceId), }; } @@ -770,13 +794,17 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { ); } if (profile.kind === 'environment') return { profile }; - const credential = await this.credentials.get(profile); - if (!credential) { + const storedCredential = await this.credentials.get(profile); + if (!storedCredential) { throw new RuntimeHostPermanentReconnectError( `Runtime Host profile ${profile.id} has no access credential`, ); } - return { profile, credential }; + return { + profile, + credential: storedCredential.credential, + profileIncarnationId: storedCredential.profileIncarnationId, + }; } save( @@ -832,7 +860,10 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { : [...current.profiles, profile], }); if (profile.kind === 'remote' && suppliedCredential !== undefined) { - await this.credentials.set(profile, suppliedCredential); + await this.credentials.set(profile, { + credential: suppliedCredential, + profileIncarnationId: previousCredential?.profileIncarnationId ?? randomUUID(), + }); } try { await writeProfileDocument(this.path, next); @@ -886,7 +917,7 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { !samePersistedRuntimeHostProfile(profile, expectedProfile) || (profile.kind === 'remote' && (target.credential === undefined || - (await this.credentials.get(profile)) !== target.credential)) + !sameProfileCredential(await this.credentials.get(profile), target))) ) { return { removed: false, document: current }; } @@ -919,11 +950,14 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { return this.#exclusive(async () => { const current = await this.#readSnapshot(); const stored = current.profiles.find((candidate) => candidate.id === expectedProfile.id); + const storedCredential = + stored?.kind === 'remote' ? await this.credentials.get(stored) : null; if ( !stored || stored.kind !== 'remote' || !sameRemoteRuntimeHostProfile(stored, expectedProfile) || - (await this.credentials.get(stored)) !== target.credential + !storedCredential || + !sameProfileCredential(storedCredential, target) ) { return { rebound: false, document: current }; } @@ -933,11 +967,14 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { candidate.id === profile.id ? profile : candidate, ), }); - await this.credentials.set(profile, credential); + await this.credentials.set(profile, { + credential, + profileIncarnationId: storedCredential.profileIncarnationId, + }); try { await writeProfileDocument(this.path, next); } catch (error) { - await restoreCredential(this.credentials, profile, target.credential).catch( + await restoreCredential(this.credentials, profile, storedCredential).catch( (rollbackError) => { throw new AggregateError( [error, rollbackError], @@ -952,10 +989,11 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { } mutateRemoteProfileIfCurrent( - target: RemoteRuntimeHostProfile, + target: RuntimeHostRemoteProfileIncarnation, mutation: (profile: RemoteRuntimeHostProfile) => Promise, ): Promise { - const expectedProfile = decodeRemoteRuntimeHostProfile(target); + const expectedProfile = decodeRemoteRuntimeHostProfile(target.profile); + const expectedIncarnationId = requireProfileIncarnationId(target.profileIncarnationId); return this.#exclusive(async () => { const current = await this.#readSnapshot(); const profile = current.profiles.find( @@ -963,11 +1001,27 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { candidate.id === expectedProfile.id && candidate.kind === 'remote', ); if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; + const credential = await this.credentials.get(profile); + if (credential?.profileIncarnationId !== expectedIncarnationId) return false; await mutation(profile); return true; }); } + async isRemoteProfileIncarnationCurrent( + target: RuntimeHostRemoteProfileIncarnation, + ): Promise { + const expectedProfile = decodeRemoteRuntimeHostProfile(target.profile); + const expectedIncarnationId = requireProfileIncarnationId(target.profileIncarnationId); + const current = await this.#readSnapshot(); + const profile = current.profiles.find( + (candidate): candidate is RemoteRuntimeHostProfile => + candidate.id === expectedProfile.id && candidate.kind === 'remote', + ); + if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; + return (await this.credentials.get(profile))?.profileIncarnationId === expectedIncarnationId; + } + async #removeProfile( current: RuntimeHostProfileDocument, profile: PersistedRuntimeHostProfile, @@ -1193,49 +1247,108 @@ function profileCredentialSlot(profile: RemoteRuntimeHostProfile): string { async function deleteCapabilityProviderCredential( credentials: Pick, - profile: RemoteRuntimeHostProfile, + target: RuntimeHostRemoteProfileIncarnation, ownerClientInstanceId: string, ): Promise { - const slot = profileCredentialSlot(profile); + const slot = profileCredentialSlot(target.profile); const stored = await credentials.getSecret(slot, 'runtime_host_capability_provider'); if (stored === null) return; const decoded = decodeCapabilityProviderCredential(stored); - if (decoded.ownerClientInstanceId !== requireClientInstanceId(ownerClientInstanceId)) return; + if ( + decoded.ownerClientInstanceId !== requireClientInstanceId(ownerClientInstanceId) || + decoded.profileIncarnationId !== requireProfileIncarnationId(target.profileIncarnationId) + ) { + return; + } await credentials.deleteSecret(slot, 'runtime_host_capability_provider'); } function decodeCapabilityProviderCredential(value: string): { readonly ownerClientInstanceId: string; readonly credential: string; + readonly profileIncarnationId: string; } { - let parsed: unknown; try { - parsed = JSON.parse(value); + const parsed: unknown = JSON.parse(value); + const record = requireExactRecord(parsed, 'Runtime Host capability-provider credential', [ + 'schemaVersion', + 'profileIncarnationId', + 'ownerClientInstanceId', + 'credential', + ]); + if (record.schemaVersion !== 1) { + throw new Error('Runtime Host capability-provider credential schema is unsupported'); + } + return { + ownerClientInstanceId: requireClientInstanceId(record.ownerClientInstanceId), + credential: requireRuntimeHostAccessCredential(record.credential as string), + profileIncarnationId: requireProfileIncarnationId(record.profileIncarnationId), + }; } catch (error) { throw new Error('Runtime Host capability-provider credential is invalid', { cause: error }); } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Runtime Host capability-provider credential is invalid'); - } - const record = parsed as Record; - if ( - record.schemaVersion !== 1 || - Object.keys(record).some( - (key) => !['schemaVersion', 'ownerClientInstanceId', 'credential'].includes(key), - ) - ) { - throw new Error('Runtime Host capability-provider credential is invalid'); +} + +function encodeProfileCredential(credential: RuntimeHostProfileCredential): string { + return `${PROFILE_CREDENTIAL_RECORD_PREFIX}${JSON.stringify({ + schemaVersion: 1, + profileIncarnationId: requireProfileIncarnationId(credential.profileIncarnationId), + credential: requireRuntimeHostAccessCredential(credential.credential), + })}`; +} + +function decodeProfileCredential( + profile: RemoteRuntimeHostProfile, + value: string, +): RuntimeHostProfileCredential { + if (!value.startsWith(PROFILE_CREDENTIAL_RECORD_PREFIX)) { + const credential = requireRuntimeHostAccessCredential(value); + return { + credential, + profileIncarnationId: legacyProfileIncarnationId(profile), + }; } try { + const parsed: unknown = JSON.parse(value.slice(PROFILE_CREDENTIAL_RECORD_PREFIX.length)); + const record = requireExactRecord(parsed, 'Runtime Host profile credential', [ + 'schemaVersion', + 'profileIncarnationId', + 'credential', + ]); + if (record.schemaVersion !== 1) { + throw new Error('Runtime Host profile credential schema is unsupported'); + } return { - ownerClientInstanceId: requireClientInstanceId(record.ownerClientInstanceId), credential: requireRuntimeHostAccessCredential(record.credential as string), + profileIncarnationId: requireProfileIncarnationId(record.profileIncarnationId), }; } catch (error) { - throw new Error('Runtime Host capability-provider credential is invalid', { cause: error }); + throw new Error('Runtime Host profile credential is invalid', { cause: error }); } } +function legacyProfileIncarnationId(profile: RemoteRuntimeHostProfile): string { + // Existing plaintext records predate incarnations. Their target-bound value + // remains stable until the next catalog write migrates the credential record. + return createHash('sha256') + .update('legacy-runtime-host-profile-incarnation') + .update('\0') + .update(profileCredentialSlot(profile)) + .digest('hex'); +} + +function requireProfileIncarnationId(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > PROFILE_INCARNATION_ID_MAX_BYTES || + !/^[A-Za-z0-9._-]+$/u.test(value) + ) { + throw new Error('Runtime Host profile incarnation is invalid'); + } + return value; +} + function requireRuntimeHostAccessCredential(credential: string): string { if ( !credential || @@ -1345,13 +1458,25 @@ function samePersistedRuntimeHostProfile( function restoreCredential( credentials: RuntimeHostProfileCredentialStore, profile: RemoteRuntimeHostProfile, - previousCredential: string | null, + previousCredential: RuntimeHostProfileCredential | null, ): Promise { return previousCredential === null ? credentials.delete(profile) : credentials.set(profile, previousCredential); } +function sameProfileCredential( + stored: RuntimeHostProfileCredential | null, + expected: ResolvedRuntimeHostProfile, +): boolean { + return ( + stored !== null && + stored.credential === expected.credential && + (expected.profileIncarnationId === undefined || + stored.profileIncarnationId === expected.profileIncarnationId) + ); +} + function requireProfileName(value: unknown): string { const name = requireString(value, 'Runtime Host profile name').trim(); if ( diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 6ab3177a79..72ec2478a8 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -66,6 +66,7 @@ export { type RuntimeHostProfileAccess, type RuntimeHostProfileCatalog, type RuntimeHostConnectionPhase, + type RuntimeHostRemoteProfileIncarnation, type RuntimeHostCapabilityProviderCredentialStore, RuntimeHostProfileConnectionError, type RuntimeHostProfileConnectionFailureReason, From d6df96b160372877182e3438d46d209a19cc2930 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 06:41:41 +0800 Subject: [PATCH 12/19] fix(tui): bind remote MCP to profile incarnations --- .../runtime-host-cli-context.test.ts | 12 ++- .../runtime-host-profile-command.test.ts | 1 + .../tui-mcp-remote-integration.test.ts | 20 +++- .../tui-mcp-remote-publication.test.ts | 102 ++++++++++++------ packages/cli/src/runtime-host-cli-context.ts | 4 + packages/cli/src/runtime-host-tui-context.ts | 4 + .../cli/src/tui-mcp-remote-publication.ts | 47 +++++--- 7 files changed, 142 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 5e4186c69c..93e8a6d77e 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -284,6 +284,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p rootId, }, credential: 'opaque-token', + profileIncarnationId: 'incarnation-a', }), create: async () => { throw new Error('unexpected write'); @@ -303,6 +304,9 @@ test('remote CLI profiles pin root identity and resolve credential outside the p mutateRemoteProfileIfCurrent: async () => { throw new Error('unexpected write'); }, + isRemoteProfileIncarnationCurrent: async () => { + throw new Error('unexpected read'); + }, }, loadClientInstanceId: async () => '11111111-1111-4111-8111-111111111111', readConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }), @@ -314,6 +318,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p assert.equal(remoteInput?.credential, 'opaque-token'); assert.equal(remoteInput?.clientInstanceId, '11111111-1111-4111-8111-111111111111'); assert.equal(context.clientInstanceId, '11111111-1111-4111-8111-111111111111'); + assert.equal(context.profileIncarnationId, 'incarnation-a'); assert.equal(Object.hasOwn(context.profile, 'credential'), false); await context.close(); }); @@ -553,7 +558,11 @@ function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeH read: async () => ({ schemaVersion: 3, profiles: [profile] }), resolve: async (profileId) => { assert.equal(profileId, profile.id); - return { profile, credential: 'opaque-token' }; + return { + profile, + credential: 'opaque-token', + profileIncarnationId: 'incarnation-a', + }; }, create: async () => assert.fail('unexpected write'), save: async () => assert.fail('unexpected write'), @@ -561,5 +570,6 @@ function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeH removeIfCurrent: async () => assert.fail('unexpected write'), rebindIfCurrent: async () => assert.fail('unexpected write'), mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected write'), + isRemoteProfileIncarnationCurrent: async () => assert.fail('unexpected read'), }; } diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts index 27ff85c0aa..d34cc7011a 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -237,6 +237,7 @@ function createProfileCatalogCapture(): { removeIfCurrent: async () => assert.fail('unexpected conditional profile removal'), rebindIfCurrent: async () => assert.fail('unexpected conditional profile rebind'), mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected conditional mutation'), + isRemoteProfileIncarnationCurrent: async () => assert.fail('unexpected conditional read'), }; return { get document() { diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts index 6d53c54e59..1609db0745 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -127,14 +127,21 @@ test('remote TUI publication keeps its owner association across reconnect and re const profile = remoteProfile(host.websocketEndpoints[0]!, capability.rootId); const profiles = createClientRuntimeHostProfileCatalog(clientRoot); await profiles.create(profile, firstOwner.credential); + const resolvedProfile = await profiles.resolve(profile.id); + assert.ok(resolvedProfile.profileIncarnationId); + const profileTarget = { + profile, + profileIncarnationId: resolvedProfile.profileIncarnationId, + }; const credentials = createRuntimeHostCapabilityProviderCredentialStore( createClientRuntimeHostCredentialStore(clientRoot), ); - await credentials.set(profile, 'terminal-a', firstProvider.credential); + await credentials.set(profileTarget, 'terminal-a', firstProvider.credential); const publication = createRemoteTuiMcpPublicationTarget( { clientDataRoot: clientRoot, profile, + profileIncarnationId: profileTarget.profileIncarnationId, ownerClientInstanceId: 'terminal-a', }, { @@ -183,11 +190,18 @@ test('remote TUI publication keeps its owner association across reconnect and re const wrongProfile = remoteProfile(host.websocketEndpoints[0]!, 'f'.repeat(64), 'wrong-office'); await profiles.create(wrongProfile, firstOwner.credential); - await credentials.set(wrongProfile, 'terminal-a', firstProvider.credential); + const resolvedWrongProfile = await profiles.resolve(wrongProfile.id); + assert.ok(resolvedWrongProfile.profileIncarnationId); + const wrongProfileTarget = { + profile: wrongProfile, + profileIncarnationId: resolvedWrongProfile.profileIncarnationId, + }; + await credentials.set(wrongProfileTarget, 'terminal-a', firstProvider.credential); const wrongTarget = createRemoteTuiMcpPublicationTarget( { clientDataRoot: clientRoot, profile: wrongProfile, + profileIncarnationId: wrongProfileTarget.profileIncarnationId, ownerClientInstanceId: 'terminal-a', }, { @@ -230,7 +244,7 @@ test('remote TUI publication keeps its owner association across reconnect and re credentialId: firstProvider.credentialId, }); await waitFor(() => controller?.snapshot().publication === 'credential_rejected'); - assert.equal(await credentials.get(profile, 'terminal-a'), firstProvider.credential); + assert.equal(await credentials.get(profileTarget, 'terminal-a'), firstProvider.credential); await createClientRuntimeHostProfileCatalog(clientRoot).remove(profile.id); await waitFor(() => controller?.snapshot().publication === 'target_mismatch'); diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 4183bb5098..f8160a8b98 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -27,6 +27,7 @@ import { type RuntimeHostConnection, type RuntimeHostPeerClient, type RuntimeHostProfileCatalog, + type RuntimeHostRemoteProfileIncarnation, } from '@maka/runtime-host/client'; import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; import { waitFor } from './tui-terminal-mock.js'; @@ -49,6 +50,8 @@ const DIRECT_PROFILE: RemoteRuntimeHostProfile = { }, }; +const PROFILE_INCARNATION_ID = 'incarnation-a'; + test('remote TUI publication activates, rotates, and removes one profile-bound credential', async () => { const credentials = credentialHarness(); const connected: Array<{ credential?: string; clientInstanceId: string }> = []; @@ -58,6 +61,7 @@ test('remote TUI publication activates, rotates, and removes one profile-bound c { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -86,7 +90,10 @@ test('remote TUI publication activates, rotates, and removes one profile-bound c assert.deepEqual(connected, [ { credential: 'provider-secret-a', clientInstanceId: 'provider-client' }, ]); - assert.equal(credentials.values.get('office\0terminal-client'), 'provider-secret-a'); + assert.equal( + credentials.values.get('office\0incarnation-a\0terminal-client'), + 'provider-secret-a', + ); assert.match(identityPaths[0] ?? '', /capability-provider-identities/u); await target.setCredential?.('provider-secret-b'); @@ -96,14 +103,17 @@ test('remote TUI publication activates, rotates, and removes one profile-bound c ); assert.equal(connections[0]?.unregisters, 0); assert.equal(connections[0]?.closes, 1); - assert.equal(credentials.values.get('office\0terminal-client'), 'provider-secret-b'); + assert.equal( + credentials.values.get('office\0incarnation-a\0terminal-client'), + 'provider-secret-b', + ); assert.equal(identityPaths[0], identityPaths[1]); await target.removeCredential?.(); assert.deepEqual(latest(), { kind: 'unavailable', reason: 'credential_required' }); assert.equal(connections[1]?.unregisters, 0); assert.equal(connections[1]?.closes, 1); - assert.equal(credentials.values.has('office\0terminal-client'), false); + assert.equal(credentials.values.has('office\0incarnation-a\0terminal-client'), false); await target.closePublication?.(); }); @@ -114,6 +124,7 @@ test('remote TUI publication surfaces rejected credentials without a retry autho { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -145,6 +156,7 @@ test('remote TUI publication aborts an in-flight connection before closing', asy { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -176,6 +188,7 @@ test('remote TUI publication retires when another process removes its profile', { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -201,13 +214,14 @@ test('remote TUI publication retires when another process removes its profile', }); }); -test('remote TUI publication cannot restore a credential after profile removal', async () => { +test('remote TUI publication cannot write into a recreated profile incarnation', async () => { const credentials = credentialHarness(); const profiles = profileHarness(); const target = createRemoteTuiMcpPublicationTarget( { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -224,15 +238,17 @@ test('remote TUI publication cannot restore a credential after profile removal', assert.ok(setCredential); await heldMutation.started; profiles.remove(); + profiles.recreate('incarnation-b'); heldMutation.release(); await assert.rejects(setCredential, /profile is no longer current/u); - assert.equal(credentials.values.has('office\0terminal-client'), false); + assert.equal(credentials.values.has('office\0incarnation-a\0terminal-client'), false); + assert.equal(credentials.values.has('office\0incarnation-b\0terminal-client'), false); assert.deepEqual(latest(), { kind: 'unavailable', reason: 'target_mismatch' }); await target.closePublication?.(); }); -test('remote TUI publication revalidates an invalidation received during a profile read', async () => { +test('remote TUI publication retires across a coalesced same-target recreation', async () => { const credentials = credentialHarness('provider-secret'); const profiles = profileHarness(); const connection = connectionHarness('connection-1'); @@ -240,6 +256,7 @@ test('remote TUI publication revalidates an invalidation received during a profi { clientDataRoot: '/client-data', profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -253,11 +270,12 @@ test('remote TUI publication revalidates an invalidation received during a profi const latest = await availability(target); await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); - const heldRead = profiles.holdNextRead(); + const heldValidation = profiles.holdNextValidation(); profiles.invalidate(); - await heldRead.started; + await heldValidation.started; profiles.remove(); - heldRead.release(); + profiles.recreate('incarnation-b'); + heldValidation.release(); await waitFor(() => { const current = latest(); @@ -283,6 +301,7 @@ test('remote TUI publication close waits for concurrent profile retirement clean { clientDataRoot: '/client-data', profile: DIRECT_PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -335,6 +354,7 @@ test('remote TUI publication closes its direct peer after a permanent reconnect { clientDataRoot: '/client-data', profile: DIRECT_PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, ownerClientInstanceId: 'terminal-client', }, { @@ -375,17 +395,17 @@ async function availability(target: ReturnType(); - if (initial) values.set('office\0terminal-client', initial); - const key = (profile: RemoteRuntimeHostProfile, ownerClientInstanceId: string) => - `${profile.id}\0${ownerClientInstanceId}`; + if (initial) values.set('office\0incarnation-a\0terminal-client', initial); + const key = (target: RuntimeHostRemoteProfileIncarnation, ownerClientInstanceId: string) => + `${target.profile.id}\0${target.profileIncarnationId}\0${ownerClientInstanceId}`; const store: RuntimeHostCapabilityProviderCredentialStore = { - get: async (profile, ownerClientInstanceId) => - values.get(key(profile, ownerClientInstanceId)) ?? null, - set: async (profile, ownerClientInstanceId, credential) => { - values.set(key(profile, ownerClientInstanceId), credential); + get: async (target, ownerClientInstanceId) => + values.get(key(target, ownerClientInstanceId)) ?? null, + set: async (target, ownerClientInstanceId, credential) => { + values.set(key(target, ownerClientInstanceId), credential); }, - delete: async (profile, ownerClientInstanceId) => { - values.delete(key(profile, ownerClientInstanceId)); + delete: async (target, ownerClientInstanceId) => { + values.delete(key(target, ownerClientInstanceId)); }, }; return { store, values }; @@ -397,9 +417,11 @@ function profileDeps(profile: RemoteRuntimeHostProfile = PROFILE) { } function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { - let profiles: RemoteRuntimeHostProfile[] = [initial]; + let current: + | { readonly profile: RemoteRuntimeHostProfile; readonly profileIncarnationId: string } + | undefined = { profile: initial, profileIncarnationId: PROFILE_INCARNATION_ID }; const listeners = new Set<(error?: Error) => void>(); - let heldRead: + let heldValidation: | { readonly started: ReturnType; readonly release: ReturnType; @@ -411,16 +433,23 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { readonly release: ReturnType; } | undefined; - const catalog: Pick = { - read: async () => { - const snapshot = profiles; - const held = heldRead; - heldRead = undefined; + const catalog: Pick< + RuntimeHostProfileCatalog, + 'isRemoteProfileIncarnationCurrent' | 'mutateRemoteProfileIfCurrent' + > = { + isRemoteProfileIncarnationCurrent: async (expected) => { + const snapshot = current; + const held = heldValidation; + heldValidation = undefined; if (held) { held.started.resolve(); await held.release.promise; } - return { schemaVersion: 3 as const, profiles: snapshot }; + return ( + snapshot !== undefined && + snapshot.profileIncarnationId === expected.profileIncarnationId && + sameRemoteRuntimeHostProfileTarget(snapshot.profile, expected.profile) + ); }, mutateRemoteProfileIfCurrent: async (expected, mutation) => { const held = heldMutation; @@ -429,9 +458,14 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { held.started.resolve(); await held.release.promise; } - const current = profiles.find((profile) => profile.id === expected.id); - if (!current || !sameRemoteRuntimeHostProfileTarget(current, expected)) return false; - await mutation(current); + if ( + !current || + current.profileIncarnationId !== expected.profileIncarnationId || + !sameRemoteRuntimeHostProfileTarget(current.profile, expected.profile) + ) { + return false; + } + await mutation(current.profile); return true; }, }; @@ -446,13 +480,17 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { }, invalidate, remove: () => { - profiles = []; + current = undefined; + invalidate(); + }, + recreate: (profileIncarnationId: string) => { + current = { profile: initial, profileIncarnationId }; invalidate(); }, - holdNextRead: () => { + holdNextValidation: () => { const started = deferred(); const release = deferred(); - heldRead = { started, release }; + heldValidation = { started, release }; return { started: started.promise, release: release.resolve }; }, holdNextMutation: () => { diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index 10d3edd114..535752c3df 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -93,6 +93,7 @@ export interface RuntimeHostCliConnectionContext { export interface RuntimeHostCliConnectionContextWithIdentity extends RuntimeHostCliConnectionContext { readonly clientInstanceId: string; + readonly profileIncarnationId?: string; } export interface RuntimeHostCliTarget { @@ -207,6 +208,9 @@ export async function connectRuntimeHostCli( catalog: await deps.readConnectionCatalog(liveConnection), profile, clientInstanceId, + ...(resolvedProfile.profileIncarnationId + ? { profileIncarnationId: resolvedProfile.profileIncarnationId } + : {}), close: async () => { try { await liveConnection.close(); diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index 07bcf73e7c..4aad47ee27 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -169,11 +169,15 @@ export async function createRuntimeHostTuiContext( connection, }); } else if (connected.profile.kind === 'remote') { + if (!connected.profileIncarnationId) { + throw new Error('Remote Runtime Host profile incarnation is unavailable'); + } mcp = createTuiMcpController({ workspaceRoot: input.rootPath, connection: createRemoteTuiMcpPublicationTarget({ clientDataRoot: input.clientDataRoot, profile: connected.profile, + profileIncarnationId: connected.profileIncarnationId, ownerClientInstanceId: connected.clientInstanceId, }), }); diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index 7cb79b412a..e62c4242d1 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -29,7 +29,6 @@ import { loadOrCreateRuntimeHostClientInstanceId, RuntimeHostPermanentReconnectError, RuntimeHostProfileConnectionError, - sameRemoteRuntimeHostProfileTarget, subscribeClientRuntimeHostProfileCatalogChanges, RuntimeHostRemoteCompatibilityError, runtimeHostProfileTargetFingerprint, @@ -38,6 +37,7 @@ import { type RuntimeHostConnection, type RuntimeHostPeerClient, type RuntimeHostProfileCatalog, + type RuntimeHostRemoteProfileIncarnation, type RuntimeHostReconnectingConnection, } from '@maka/runtime-host/client'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; @@ -53,7 +53,10 @@ interface RemoteTuiMcpPublicationDeps { readonly connectProfile: typeof connectRuntimeHostProfile; readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; - readonly profiles: Pick; + readonly profiles: Pick< + RuntimeHostProfileCatalog, + 'isRemoteProfileIncarnationCurrent' | 'mutateRemoteProfileIfCurrent' + >; readonly subscribeProfileChanges: (listener: (error?: Error) => void) => () => void; } @@ -61,6 +64,7 @@ export function createRemoteTuiMcpPublicationTarget( input: { readonly clientDataRoot: string; readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; readonly ownerClientInstanceId: string; }, overrides: Partial = {}, @@ -85,6 +89,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { readonly #input: { readonly clientDataRoot: string; readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; readonly ownerClientInstanceId: string; }; readonly #deps: RemoteTuiMcpPublicationDeps; @@ -111,6 +116,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { input: { readonly clientDataRoot: string; readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; readonly ownerClientInstanceId: string; }, deps: RemoteTuiMcpPublicationDeps, @@ -130,7 +136,10 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { await this.#retire(profileCurrent === false ? 'target_mismatch' : 'host_unavailable'); return; } - const credential = await deps.credentials.get(input.profile, input.ownerClientInstanceId); + const credential = await deps.credentials.get( + this.#profileTarget(), + input.ownerClientInstanceId, + ); if (this.#closed) return; if (!credential) { this.#setUnavailable('credential_required'); @@ -167,9 +176,13 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { return this.#serialize(async () => { if (this.#closed) throw new Error('Remote MCP publication is closed'); const committed = await this.#deps.profiles.mutateRemoteProfileIfCurrent( - this.#input.profile, + this.#profileTarget(), (profile) => - this.#deps.credentials.set(profile, this.#input.ownerClientInstanceId, credential), + this.#deps.credentials.set( + { profile, profileIncarnationId: this.#input.profileIncarnationId }, + this.#input.ownerClientInstanceId, + credential, + ), ); if (!committed) { await this.#retire('target_mismatch'); @@ -189,8 +202,12 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { if (this.#closed) throw new Error('Remote MCP publication is closed'); await this.#disconnect(); const committed = await this.#deps.profiles.mutateRemoteProfileIfCurrent( - this.#input.profile, - (profile) => this.#deps.credentials.delete(profile, this.#input.ownerClientInstanceId), + this.#profileTarget(), + (profile) => + this.#deps.credentials.delete( + { profile, profileIncarnationId: this.#input.profileIncarnationId }, + this.#input.ownerClientInstanceId, + ), ); if (!committed) { await this.#retire('target_mismatch'); @@ -326,11 +343,14 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } async #profileStillCurrent(): Promise { - const document = await this.#deps.profiles.read(); - const current = document.profiles.find((profile) => profile.id === this.#input.profile.id); - return ( - current?.kind === 'remote' && sameRemoteRuntimeHostProfileTarget(current, this.#input.profile) - ); + return this.#deps.profiles.isRemoteProfileIncarnationCurrent(this.#profileTarget()); + } + + #profileTarget(): RuntimeHostRemoteProfileIncarnation { + return { + profile: this.#input.profile, + profileIncarnationId: this.#input.profileIncarnationId, + }; } async #retire(reason: TuiMcpPublicationUnavailableReason): Promise { @@ -392,6 +412,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { function providerIdentityPath(input: { readonly clientDataRoot: string; readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; readonly ownerClientInstanceId: string; }): string { const identity = createHash('sha256') @@ -399,6 +420,8 @@ function providerIdentityPath(input: { .update('\0') .update(runtimeHostProfileTargetFingerprint(input.profile)) .update('\0') + .update(input.profileIncarnationId) + .update('\0') .update(input.ownerClientInstanceId) .digest('hex') .slice(0, 24); From 37ae00d7100c1d079125bbf091b23502f34f35e0 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 06:52:00 +0800 Subject: [PATCH 13/19] test(desktop): preserve profile incarnation on rollback --- .../main/__tests__/runtime-host-profile-service.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index 48fffe5211..c6aed2677a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -1301,6 +1301,7 @@ test("restores an existing profile when replacement finalization fails", async ( const root = await clientRoot(); const catalog = createClientRuntimeHostProfileCatalog(root); await catalog.create(PROFILE, "old-token"); + const original = await catalog.resolve(PROFILE.id); await writeFile( join(root, "runtime-host-profile-selection.json"), `${JSON.stringify({ @@ -1338,13 +1339,7 @@ test("restores an existing profile when replacement finalization fails", async ( /finalization failed/u, ); - assert.deepEqual(await catalog.resolve(PROFILE.id), { - profile: { - ...PROFILE, - transport: { kind: "tls", url: "wss://runtime.example.com/" }, - }, - credential: "old-token", - }); + assert.deepEqual(await catalog.resolve(PROFILE.id), original); assert.equal(enabled.at(-1)?.credential, "old-token"); }); From 82993ab04c18e7ba00afaf277dcdef9d3af2c0e4 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 10:15:27 +0800 Subject: [PATCH 14/19] fix(runtime-host): serialize profile incarnation checks --- .../src/__tests__/host-profile.test.ts | 39 +++++++++++++++++++ .../runtime-host/src/client/host-profile.ts | 16 ++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 4128b86ae0..96f7c10a7b 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -552,6 +552,45 @@ describe('Runtime Host profiles', () => { assert.equal(await providers.get(incarnation, 'owner-a'), null); }); + test('profile incarnation validation waits for removal rollback', async () => { + const path = await profilePath(); + const credentialStore = createFileCredentialStore(join(dirname(path), 'credentials')); + const stored = createRuntimeHostProfileCredentialStore(credentialStore); + const removalStarted = deferred(); + const allowRemovalFailure = deferred(); + const credentials: RuntimeHostProfileCredentialStore = { + ...stored, + delete: async () => { + removalStarted.resolve(); + await allowRemovalFailure.promise; + throw new Error('credential store unavailable'); + }, + }; + const removingCatalog = createFileRuntimeHostProfileCatalog(path, credentials); + const validatingCatalog = createFileRuntimeHostProfileCatalog(path, credentials); + const profile = remoteProfile('office', 'wss://a.example.com', ROOT_A); + await removingCatalog.save(profile, 'terminal-token'); + const resolved = await removingCatalog.resolve(profile.id); + assert.ok(resolved.profileIncarnationId); + const incarnation = { profile, profileIncarnationId: resolved.profileIncarnationId }; + + const removal = removingCatalog.remove(profile.id); + await removalStarted.promise; + let validationSettled = false; + const validation = validatingCatalog + .isRemoteProfileIncarnationCurrent(incarnation) + .then((current) => { + validationSettled = true; + return current; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(validationSettled, false); + + allowRemovalFailure.resolve(); + await assert.rejects(removal, /credential store unavailable/u); + assert.equal(await validation, true); + }); + test('recreating the same profile id and target assigns a new incarnation', async () => { const path = await profilePath(); const credentialStore = createFileCredentialStore(join(dirname(path), 'credentials')); diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 63bf333990..12873097cd 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -1013,13 +1013,15 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { ): Promise { const expectedProfile = decodeRemoteRuntimeHostProfile(target.profile); const expectedIncarnationId = requireProfileIncarnationId(target.profileIncarnationId); - const current = await this.#readSnapshot(); - const profile = current.profiles.find( - (candidate): candidate is RemoteRuntimeHostProfile => - candidate.id === expectedProfile.id && candidate.kind === 'remote', - ); - if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; - return (await this.credentials.get(profile))?.profileIncarnationId === expectedIncarnationId; + return this.#exclusive(async () => { + const current = await this.#readSnapshot(); + const profile = current.profiles.find( + (candidate): candidate is RemoteRuntimeHostProfile => + candidate.id === expectedProfile.id && candidate.kind === 'remote', + ); + if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; + return (await this.credentials.get(profile))?.profileIncarnationId === expectedIncarnationId; + }); } async #removeProfile( From f7353960abe231b7f6fd44383989339062d62f13 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 10:15:27 +0800 Subject: [PATCH 15/19] fix(tui): fence remote MCP connection results --- .../tui-mcp-remote-publication.test.ts | 94 +++++++++++++++++++ .../cli/src/tui-mcp-remote-publication.ts | 49 +++++++--- 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index f8160a8b98..76b59b40b6 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -180,6 +180,100 @@ test('remote TUI publication aborts an in-flight connection before closing', asy assert.equal(observedSignal?.aborted, true); }); +test('remote TUI publication revalidates its profile before publishing an initial connection', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(); + const connection = connectionHarness('connection-1'); + const wrapperStarted = deferred(); + const allowWrapper = deferred(); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => connection.connection, + createReconnectingConnection: async () => { + wrapperStarted.resolve(); + await allowWrapper.promise; + return reconnectingConnection(connection.connection); + }, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await wrapperStarted.promise; + + profiles.remove(); + profiles.recreate('incarnation-b'); + allowWrapper.resolve(); + + await waitFor(() => { + const current = latest(); + return current.kind === 'unavailable' && current.reason === 'target_mismatch'; + }, 'recreated profile to reject the uninstalled connection'); + assert.equal(connection.closes, 1); + await target.closePublication?.(); +}); + +test('remote TUI publication revalidates every reconnect result', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(); + const initial = connectionHarness('connection-1'); + const replacement = connectionHarness('connection-2'); + const reconnectStarted = deferred(); + const allowReconnect = deferred(); + let attempts = 0; + let reconnect: ((signal: AbortSignal) => Promise) | undefined; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => { + attempts += 1; + if (attempts === 1) return initial.connection; + reconnectStarted.resolve(); + await allowReconnect.promise; + return replacement.connection; + }, + createReconnectingConnection: async (input) => { + reconnect = input.connect; + return reconnectingConnection(initial.connection); + }, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + assert.ok(reconnect); + + const attempt = reconnect(new AbortController().signal); + await reconnectStarted.promise; + profiles.remove(); + profiles.recreate('incarnation-b'); + allowReconnect.resolve(); + + await assert.rejects( + attempt, + (error: unknown) => + error instanceof RuntimeHostProfileConnectionError && error.reason === 'target_mismatch', + ); + assert.equal(replacement.closes, 1); + await target.closePublication?.(); +}); + test('remote TUI publication retires when another process removes its profile', async () => { const credentials = credentialHarness('provider-secret'); const profiles = profileHarness(); diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index e62c4242d1..9f19890cd7 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -248,8 +248,9 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { ? this.#deps.createPeerClient() : undefined; this.#peerClient = peerClient; - const connect = (signal?: AbortSignal): Promise => - this.#deps.connectProfile({ + const connect = async (signal?: AbortSignal): Promise => { + await this.#requireCurrentProfile(); + const connection = await this.#deps.connectProfile({ profile: this.#input.profile, credential, clientInstanceId, @@ -257,24 +258,28 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { ...(peerClient ? { peerClient } : {}), signal: signal ? AbortSignal.any([abort.signal, signal]) : abort.signal, }); + return this.#keepIfProfileCurrent(connection); + }; const initial = await connect(); if (this.#closed || generation !== this.#generation) { await initial.close().catch(() => undefined); await this.#closePeer(peerClient); return; } - const connection = await this.#deps.createReconnectingConnection({ - initialConnection: initial, - connect, - onFatalError: (error) => { - if (!this.#closed && generation === this.#generation) { - this.#setUnavailable(classifyUnavailable(error)); - if (this.#peerClient === peerClient) { - void this.#closePeer(peerClient); + const connection = await this.#keepIfProfileCurrent( + await this.#deps.createReconnectingConnection({ + initialConnection: initial, + connect, + onFatalError: (error) => { + if (!this.#closed && generation === this.#generation) { + this.#setUnavailable(classifyUnavailable(error)); + if (this.#peerClient === peerClient) { + void this.#closePeer(peerClient); + } } - } - }, - }); + }, + }), + ); if (this.#closed || generation !== this.#generation) { await connection.close().catch(() => undefined); await this.#closePeer(peerClient); @@ -346,6 +351,24 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { return this.#deps.profiles.isRemoteProfileIncarnationCurrent(this.#profileTarget()); } + async #requireCurrentProfile(): Promise { + if (await this.#profileStillCurrent()) return; + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + 'Remote MCP publication profile is no longer current', + ); + } + + async #keepIfProfileCurrent(connection: T): Promise { + try { + await this.#requireCurrentProfile(); + return connection; + } catch (error) { + await connection.close().catch(() => undefined); + throw error; + } + } + #profileTarget(): RuntimeHostRemoteProfileIncarnation { return { profile: this.#input.profile, From 4975410f63676cc95bef2224a35d7dbcb7559ea3 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 11:37:22 +0800 Subject: [PATCH 16/19] fix(tui): fence current remote MCP publication --- .../runtime-host-cli-context.test.ts | 4 +- .../runtime-host-profile-command.test.ts | 2 +- .../tui-mcp-remote-publication.test.ts | 115 ++++++++++++++++-- .../cli/src/tui-mcp-remote-publication.ts | 39 ++++-- .../src/__tests__/host-profile.test.ts | 20 +-- .../runtime-host/src/client/host-profile.ts | 17 ++- 6 files changed, 166 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 93e8a6d77e..e64df0fc6f 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -304,7 +304,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p mutateRemoteProfileIfCurrent: async () => { throw new Error('unexpected write'); }, - isRemoteProfileIncarnationCurrent: async () => { + readRemoteProfileIfCurrent: async () => { throw new Error('unexpected read'); }, }, @@ -570,6 +570,6 @@ function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeH removeIfCurrent: async () => assert.fail('unexpected write'), rebindIfCurrent: async () => assert.fail('unexpected write'), mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected write'), - isRemoteProfileIncarnationCurrent: async () => assert.fail('unexpected read'), + readRemoteProfileIfCurrent: async () => assert.fail('unexpected read'), }; } diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts index d34cc7011a..bf972eec2d 100644 --- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts @@ -237,7 +237,7 @@ function createProfileCatalogCapture(): { removeIfCurrent: async () => assert.fail('unexpected conditional profile removal'), rebindIfCurrent: async () => assert.fail('unexpected conditional profile rebind'), mutateRemoteProfileIfCurrent: async () => assert.fail('unexpected conditional mutation'), - isRemoteProfileIncarnationCurrent: async () => assert.fail('unexpected conditional read'), + readRemoteProfileIfCurrent: async () => assert.fail('unexpected conditional read'), }; return { get document() { diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 76b59b40b6..a7b03a7cd5 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -274,6 +274,58 @@ test('remote TUI publication revalidates every reconnect result', async () => { await target.closePublication?.(); }); +test('remote TUI publication reconnects through current direct-peer routes', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(DIRECT_PROFILE); + const connectedProfiles: RemoteRuntimeHostProfile[] = []; + const initial = connectionHarness('connection-1'); + const replacement = connectionHarness('connection-2'); + let reconnect: ((signal: AbortSignal) => Promise) | undefined; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: DIRECT_PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async (input) => { + assert.equal(input.profile.kind, 'remote'); + if (input.profile.kind !== 'remote') throw new Error('expected a remote profile'); + connectedProfiles.push(input.profile); + return connectedProfiles.length === 1 ? initial.connection : replacement.connection; + }, + createPeerClient: () => ({ close: async () => undefined }) as RuntimeHostPeerClient, + createReconnectingConnection: async (input) => { + reconnect = input.connect; + return reconnectingConnection(initial.connection); + }, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'direct provider companion to connect'); + assert.ok(reconnect); + + const moved: RemoteRuntimeHostProfile = { + ...DIRECT_PROFILE, + transport: { + kind: 'libp2p-direct', + peerId: 'peer-a', + routeHints: ['/ip6/2001:db8::10/udp/4001/quic-v1'], + coordinationRelays: ['/dns4/relay.example.com/tcp/443/wss/p2p/relay-a'], + }, + }; + profiles.update(moved); + await reconnect(new AbortController().signal); + + assert.deepEqual(connectedProfiles, [DIRECT_PROFILE, moved]); + await target.closePublication?.(); +}); + test('remote TUI publication retires when another process removes its profile', async () => { const credentials = credentialHarness('provider-secret'); const profiles = profileHarness(); @@ -342,6 +394,45 @@ test('remote TUI publication cannot write into a recreated profile incarnation', await target.closePublication?.(); }); +test('remote TUI publication cannot register capabilities after profile recreation', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(); + const connection = connectionHarness('connection-1'); + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + }, + { + credentials: credentials.store, + loadClientInstanceId: async () => 'provider-client', + connectProfile: async () => connection.connection, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }, + ); + const latest = await availability(target); + await waitFor(() => latest().kind === 'connected', 'provider companion to connect'); + + const heldMutation = profiles.holdNextMutation(); + const replace = target.replaceClientCapabilities({ offers: () => [] }); + await heldMutation.started; + profiles.remove(); + profiles.recreate('incarnation-b'); + heldMutation.release(); + + await assert.rejects( + replace, + (error: unknown) => + error instanceof RuntimeHostProfileConnectionError && error.reason === 'target_mismatch', + ); + assert.equal(connection.replacements, 0); + assert.deepEqual(latest(), { kind: 'unavailable', reason: 'target_mismatch' }); + await target.closePublication?.(); +}); + test('remote TUI publication retires across a coalesced same-target recreation', async () => { const credentials = credentialHarness('provider-secret'); const profiles = profileHarness(); @@ -529,9 +620,9 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { | undefined; const catalog: Pick< RuntimeHostProfileCatalog, - 'isRemoteProfileIncarnationCurrent' | 'mutateRemoteProfileIfCurrent' + 'readRemoteProfileIfCurrent' | 'mutateRemoteProfileIfCurrent' > = { - isRemoteProfileIncarnationCurrent: async (expected) => { + readRemoteProfileIfCurrent: async (expected) => { const snapshot = current; const held = heldValidation; heldValidation = undefined; @@ -539,11 +630,11 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { held.started.resolve(); await held.release.promise; } - return ( - snapshot !== undefined && + return snapshot !== undefined && snapshot.profileIncarnationId === expected.profileIncarnationId && sameRemoteRuntimeHostProfileTarget(snapshot.profile, expected.profile) - ); + ? snapshot.profile + : undefined; }, mutateRemoteProfileIfCurrent: async (expected, mutation) => { const held = heldMutation; @@ -581,6 +672,11 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { current = { profile: initial, profileIncarnationId }; invalidate(); }, + update: (profile: RemoteRuntimeHostProfile) => { + assert.ok(current); + current = { profile, profileIncarnationId: current.profileIncarnationId }; + invalidate(); + }, holdNextValidation: () => { const started = deferred(); const release = deferred(); @@ -598,6 +694,7 @@ function profileHarness(initial: RemoteRuntimeHostProfile = PROFILE) { interface ConnectionHarness { connection: RuntimeHostConnection; + replacements: number; unregisters: number; closes: number; } @@ -612,6 +709,7 @@ function connectionHarness( }); const harness: ConnectionHarness = { connection: undefined as unknown as RuntimeHostConnection, + replacements: 0, unregisters: 0, closes: 0, }; @@ -623,10 +721,13 @@ function connectionHarness( compositionId: 'maka.interactive', compositionRevision: 'composition-revision', closed, - replaceClientCapabilities: async () => ({ registrationIds: [] }), + replaceClientCapabilities: async () => { + harness.replacements += 1; + return { registrationId: 'registration-a', revision: harness.replacements }; + }, unregisterClientCapabilities: async () => { harness.unregisters += 1; - return { registrationIds: [] }; + return { registrationId: 'registration-a', revision: harness.unregisters }; }, subscribeConfigurationChanges: () => () => undefined, subscribeProjectCatalogChanges: () => () => undefined, diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index 9f19890cd7..24d1f5245e 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -55,7 +55,7 @@ interface RemoteTuiMcpPublicationDeps { readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; readonly profiles: Pick< RuntimeHostProfileCatalog, - 'isRemoteProfileIncarnationCurrent' | 'mutateRemoteProfileIfCurrent' + 'readRemoteProfileIfCurrent' | 'mutateRemoteProfileIfCurrent' >; readonly subscribeProfileChanges: (listener: (error?: Error) => void) => () => void; } @@ -152,7 +152,27 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { } replaceClientCapabilities(provider: ClientCapabilityProvider, timeoutMs?: number) { - return this.#requireConnection().replaceClientCapabilities(provider, timeoutMs); + return this.#serialize(async () => { + if (this.#closed) throw new Error('Remote MCP publication is closed'); + let result: + | Awaited> + | undefined; + const committed = await this.#deps.profiles.mutateRemoteProfileIfCurrent( + this.#profileTarget(), + async () => { + result = await this.#requireConnection().replaceClientCapabilities(provider, timeoutMs); + }, + ); + if (!committed) { + await this.#retire('target_mismatch'); + throw new RuntimeHostProfileConnectionError( + 'target_mismatch', + 'Remote MCP publication profile is no longer current', + ); + } + if (!result) throw new Error('Runtime Host did not confirm MCP capability registration'); + return result; + }); } unregisterClientCapabilities(timeoutMs?: number) { @@ -249,9 +269,9 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { : undefined; this.#peerClient = peerClient; const connect = async (signal?: AbortSignal): Promise => { - await this.#requireCurrentProfile(); + const profile = await this.#requireCurrentProfile(); const connection = await this.#deps.connectProfile({ - profile: this.#input.profile, + profile, credential, clientInstanceId, sshInteraction: 'batch', @@ -347,12 +367,17 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { }); } + async #currentProfile(): Promise { + return this.#deps.profiles.readRemoteProfileIfCurrent(this.#profileTarget()); + } + async #profileStillCurrent(): Promise { - return this.#deps.profiles.isRemoteProfileIncarnationCurrent(this.#profileTarget()); + return (await this.#currentProfile()) !== undefined; } - async #requireCurrentProfile(): Promise { - if (await this.#profileStillCurrent()) return; + async #requireCurrentProfile(): Promise { + const profile = await this.#currentProfile(); + if (profile) return profile; throw new RuntimeHostProfileConnectionError( 'target_mismatch', 'Remote MCP publication profile is no longer current', diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 96f7c10a7b..fcf5fd00fa 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -32,6 +32,7 @@ import { createFileRuntimeHostProfileCatalog, createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, + decodeRemoteRuntimeHostProfile, decodeRuntimeHostProfileDocument, RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT, RuntimeHostProfileConnectionError, @@ -577,18 +578,16 @@ describe('Runtime Host profiles', () => { const removal = removingCatalog.remove(profile.id); await removalStarted.promise; let validationSettled = false; - const validation = validatingCatalog - .isRemoteProfileIncarnationCurrent(incarnation) - .then((current) => { - validationSettled = true; - return current; - }); + const validation = validatingCatalog.readRemoteProfileIfCurrent(incarnation).then((current) => { + validationSettled = true; + return current; + }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(validationSettled, false); allowRemovalFailure.resolve(); await assert.rejects(removal, /credential store unavailable/u); - assert.equal(await validation, true); + assert.deepEqual(await validation, decodeRemoteRuntimeHostProfile(profile)); }); test('recreating the same profile id and target assigns a new incarnation', async () => { @@ -619,8 +618,11 @@ describe('Runtime Host profiles', () => { }; assert.notEqual(second.profileIncarnationId, first.profileIncarnationId); - assert.equal(await catalog.isRemoteProfileIncarnationCurrent(firstIncarnation), false); - assert.equal(await catalog.isRemoteProfileIncarnationCurrent(secondIncarnation), true); + assert.equal(await catalog.readRemoteProfileIfCurrent(firstIncarnation), undefined); + assert.deepEqual( + await catalog.readRemoteProfileIfCurrent(secondIncarnation), + decodeRemoteRuntimeHostProfile(profile), + ); assert.equal(await providers.get(secondIncarnation, 'owner-a'), null); let staleMutationRan = false; assert.equal( diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 12873097cd..e9a22ad544 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -228,7 +228,10 @@ export interface RuntimeHostProfileCatalog { target: RuntimeHostRemoteProfileIncarnation, mutation: (profile: RemoteRuntimeHostProfile) => Promise, ): Promise; - isRemoteProfileIncarnationCurrent(target: RuntimeHostRemoteProfileIncarnation): Promise; + /** Return the canonical profile while this exact profile lifetime remains current. */ + readRemoteProfileIfCurrent( + target: RuntimeHostRemoteProfileIncarnation, + ): Promise; } export interface RuntimeHostProfileCredential { @@ -1008,9 +1011,9 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { }); } - async isRemoteProfileIncarnationCurrent( + async readRemoteProfileIfCurrent( target: RuntimeHostRemoteProfileIncarnation, - ): Promise { + ): Promise { const expectedProfile = decodeRemoteRuntimeHostProfile(target.profile); const expectedIncarnationId = requireProfileIncarnationId(target.profileIncarnationId); return this.#exclusive(async () => { @@ -1019,8 +1022,12 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog { (candidate): candidate is RemoteRuntimeHostProfile => candidate.id === expectedProfile.id && candidate.kind === 'remote', ); - if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) return false; - return (await this.credentials.get(profile))?.profileIncarnationId === expectedIncarnationId; + if (!profile || !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)) { + return undefined; + } + return (await this.credentials.get(profile))?.profileIncarnationId === expectedIncarnationId + ? profile + : undefined; }); } From 911b04746ff414d579a8d26a94c5e33ab53eb35c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 18:51:19 +0800 Subject: [PATCH 17/19] feat(storage): support optional file lifetime ownership --- .../src/__tests__/file-lifetime-owner.test.ts | 60 +++++++++++++++++++ .../fixtures/file-lifetime-owner-holder.ts | 29 +++++++++ packages/storage/src/file-lifetime-owner.ts | 37 +++++++++++- 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 packages/storage/src/__tests__/file-lifetime-owner.test.ts create mode 100644 packages/storage/src/__tests__/fixtures/file-lifetime-owner-holder.ts diff --git a/packages/storage/src/__tests__/file-lifetime-owner.test.ts b/packages/storage/src/__tests__/file-lifetime-owner.test.ts new file mode 100644 index 0000000000..1384f09cc2 --- /dev/null +++ b/packages/storage/src/__tests__/file-lifetime-owner.test.ts @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { tryAcquireFileLifetimeOwner } from '../file-lifetime-owner.js'; + +test('a file lifetime owner fails closed and recovers after owner death', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-file-lifetime-owner-')); + const path = join(root, 'nested', 'publication.lease'); + const holder = fork( + new URL('./fixtures/file-lifetime-owner-holder.js', import.meta.url), + [path], + { + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }, + ); + t.after(async () => { + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + holder.once('message', (message) => { + if (message === 'owned') resolve(); + else reject(new Error(`Unexpected file owner message: ${String(message)}`)); + }); + holder.once('error', reject); + holder.once('exit', (code, signal) => { + reject(new Error(`File owner exited before acquisition (${String(code)}, ${signal})`)); + }); + }); + + assert.equal(await tryAcquireFileLifetimeOwner(path), undefined); + + holder.kill('SIGKILL'); + await new Promise((resolve) => holder.once('exit', () => resolve())); + const successor = await tryAcquireFileLifetimeOwner(path); + assert.ok(successor); + await Promise.all([successor.close(), successor.close()]); +}); diff --git a/packages/storage/src/__tests__/fixtures/file-lifetime-owner-holder.ts b/packages/storage/src/__tests__/fixtures/file-lifetime-owner-holder.ts new file mode 100644 index 0000000000..39e2d55c70 --- /dev/null +++ b/packages/storage/src/__tests__/fixtures/file-lifetime-owner-holder.ts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tryAcquireFileLifetimeOwner } from '../../file-lifetime-owner.js'; + +const path = process.argv[2]; +if (!path) throw new Error('Missing file lifetime owner path'); +const owner = await tryAcquireFileLifetimeOwner(path); +if (!owner) throw new Error('File lifetime owner is already active'); +process.send?.('owned'); +setInterval(() => undefined, 1_000).unref(); +await new Promise((resolve) => process.once('disconnect', resolve)); +await owner.close(); diff --git a/packages/storage/src/file-lifetime-owner.ts b/packages/storage/src/file-lifetime-owner.ts index 843ee62af8..c2792e84e2 100644 --- a/packages/storage/src/file-lifetime-owner.ts +++ b/packages/storage/src/file-lifetime-owner.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { FileHandle } from 'node:fs/promises'; +import { chmod, lstat, mkdir, type FileHandle } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { openStableNativeLockFile, releaseNativeFileLock, @@ -29,10 +30,40 @@ export interface FileLifetimeOwner { } export async function acquireFileLifetimeOwner(path: string): Promise { + const owner = await tryAcquireOpenedFileLifetimeOwner(path); + if (!owner) throw new Error(`Another process owns ${path}`); + return owner; +} + +/** Try once to own one named file for the lifetime of this process handle. */ +export async function tryAcquireFileLifetimeOwner( + path: string, +): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error(`File lifetime owner root is not a directory: ${directory}`); + } + if (process.platform !== 'win32') await chmod(directory, 0o700); + + return tryAcquireOpenedFileLifetimeOwner(path); +} + +async function tryAcquireOpenedFileLifetimeOwner( + path: string, +): Promise { const handle = await openStableNativeLockFile(path); - if (!tryAcquireNativeFileLock(handle)) { + let acquired: boolean; + try { + acquired = tryAcquireNativeFileLock(handle); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + if (!acquired) { await handle.close(); - throw new Error(`Another process owns ${path}`); + return undefined; } return new FileLifetimeOwnerImpl(handle); } From ad1c8e398c30a3cb3f84f848819daa6c62e7d8c5 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 18:51:21 +0800 Subject: [PATCH 18/19] fix(tui): fence concurrent remote MCP providers --- .../src/__tests__/pi-tui-mcp-status.test.ts | 18 +++ .../cli/src/__tests__/tui-mcp-control.test.ts | 4 + .../tui-mcp-remote-integration.test.ts | 26 ++++ .../tui-mcp-remote-publication.test.ts | 132 +++++++++++++++++- packages/cli/src/tui-copy-catalog.ts | 2 + packages/cli/src/tui-mcp-control.ts | 10 +- .../cli/src/tui-mcp-remote-publication.ts | 66 +++++++-- 7 files changed, 248 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts index 2529da9e18..d255feb83f 100644 --- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts +++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts @@ -110,6 +110,24 @@ describe('MCP management overlay', () => { assert.doesNotMatch(overlay.render(160).map(stripAnsi).join('\n'), /maka_rh_secret-marker/u); }); + test('renders a competing remote provider as a visible conflict', () => { + const overlay = new McpManagementOverlay({ + locale: 'en', + surface: surface({ + initialization: 'ready', + configuration: 'ready', + publication: 'provider_conflict', + toolCount: 0, + servers: [], + }), + viewportRows: () => 6, + onClose: () => undefined, + onChange: () => undefined, + }); + + assert.match(overlay.render(100).map(stripAnsi).join('\n'), /provider active in another TUI/u); + }); + test('localizes manager states without changing their source values', () => { const overlay = new McpManagementOverlay({ locale: 'zh', diff --git a/packages/cli/src/__tests__/tui-mcp-control.test.ts b/packages/cli/src/__tests__/tui-mcp-control.test.ts index d9e8ed7dd9..3d5716131f 100644 --- a/packages/cli/src/__tests__/tui-mcp-control.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-control.test.ts @@ -134,6 +134,10 @@ test('TUI MCP serializes remote provider credential changes through its publicat }); assert.equal(removed, 1); assert.equal(controller.snapshot().publication, 'credential_required'); + availability = { kind: 'unavailable', reason: 'provider_conflict' }; + listener?.(availability); + assert.equal(controller.snapshot().publication, 'provider_conflict'); + assert.equal(controller.snapshot().canManagePublicationCredential, false); await controller.close(); assert.equal(closed, 1); }); diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts index 1609db0745..b643e24b7b 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -68,6 +68,7 @@ test('remote TUI publication keeps its owner association across reconnect and re let otherTerminal: RuntimeHostConnection | undefined; let otherProvider: RuntimeHostConnection | undefined; let controller: TuiMcpController | undefined; + let competingController: TuiMcpController | undefined; try { const capability = await resolveStorageRoot({ path: hostRoot, kind: 'interactive' }); local = await connectLocal(hostRoot, 'local-owner'); @@ -153,6 +154,30 @@ test('remote TUI publication keeps its owner association across reconnect and re await waitFor(() => controller?.snapshot().publication === 'published'); assert.equal(host.connectionCount, 5); + const competingWorkspace = join(base, 'competing-workspace'); + await createMcpConfigStore(competingWorkspace).upsert('other-fixture', { + command: process.execPath, + args: [fixturePath], + enabled: false, + protocol: 'legacy', + }); + const competingPublication = createRemoteTuiMcpPublicationTarget({ + clientDataRoot: clientRoot, + profile, + profileIncarnationId: profileTarget.profileIncarnationId, + ownerClientInstanceId: 'terminal-a', + }); + competingController = createTuiMcpController({ + workspaceRoot: competingWorkspace, + connection: competingPublication, + }); + await waitFor(() => competingController?.snapshot().publication === 'provider_conflict'); + assert.equal(competingController.snapshot().canManagePublicationCredential, false); + assert.equal(controller.snapshot().publication, 'published'); + assert.equal(host.connectionCount, 5); + await competingController.close(); + competingController = undefined; + const sessionId = 'remote-tui-mcp-session'; const turnId = 'remote-tui-mcp-turn'; await terminal.request('session.create', { @@ -258,6 +283,7 @@ test('remote TUI publication keeps its owner association across reconnect and re assert.equal(events.filter((event) => event.event === 'start').length, 1); assert.equal(events.filter((event) => event.event === 'exit').length, 1); } finally { + await competingController?.close().catch(() => undefined); await controller?.close().catch(() => undefined); await otherProvider?.close().catch(() => undefined); await otherTerminal?.close().catch(() => undefined); diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index a7b03a7cd5..1e366974ad 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -29,7 +29,7 @@ import { type RuntimeHostProfileCatalog, type RuntimeHostRemoteProfileIncarnation, } from '@maka/runtime-host/client'; -import { createRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; +import { createRemoteTuiMcpPublicationTarget as createProductionRemoteTuiMcpPublicationTarget } from '../tui-mcp-remote-publication.js'; import { waitFor } from './tui-terminal-mock.js'; const PROFILE: RemoteRuntimeHostProfile = { @@ -117,6 +117,107 @@ test('remote TUI publication activates, rotates, and removes one profile-bound c await target.closePublication?.(); }); +test('remote TUI publication fails closed while the same provider lifetime is active', async () => { + const credentials = credentialHarness('provider-secret'); + const profiles = profileHarness(); + const leases = publicationLeaseHarness(); + const firstConnection = connectionHarness('connection-1'); + const secondConnection = connectionHarness('connection-2'); + let secondAttempts = 0; + const input = { + clientDataRoot: '/client-data', + profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + } as const; + const first = createRemoteTuiMcpPublicationTarget(input, { + credentials: credentials.store, + loadClientInstanceId: async () => 'shared-provider-client', + connectProfile: async () => firstConnection.connection, + acquirePublicationLease: leases.acquire, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }); + const firstLatest = await availability(first); + await waitFor(() => firstLatest().kind === 'connected', 'first provider companion to connect'); + + const second = createRemoteTuiMcpPublicationTarget(input, { + credentials: credentials.store, + loadClientInstanceId: async () => 'shared-provider-client', + connectProfile: async () => { + secondAttempts += 1; + return secondConnection.connection; + }, + acquirePublicationLease: leases.acquire, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }); + const secondLatest = await availability(second); + await waitFor(() => { + const current = secondLatest(); + return current.kind === 'unavailable' && current.reason === 'provider_conflict'; + }, 'competing provider companion to fail closed'); + assert.equal(secondAttempts, 0); + assert.equal(leases.active.size, 1); + + await first.replaceClientCapabilities({ offers: () => [] }); + assert.equal(firstConnection.replacements, 1); + await first.closePublication?.(); + assert.equal(leases.active.size, 0); + + const successor = createRemoteTuiMcpPublicationTarget(input, { + credentials: credentials.store, + loadClientInstanceId: async () => 'shared-provider-client', + connectProfile: async () => secondConnection.connection, + acquirePublicationLease: leases.acquire, + profiles: profiles.catalog, + subscribeProfileChanges: profiles.subscribe, + }); + const successorLatest = await availability(successor); + await waitFor( + () => successorLatest().kind === 'connected', + 'successor provider companion to connect', + ); + await successor.closePublication?.(); + await second.closePublication?.(); +}); + +test('remote TUI publication cannot bypass a failed lifetime lease', async () => { + const credentials = credentialHarness(); + let attempts = 0; + const target = createRemoteTuiMcpPublicationTarget( + { + clientDataRoot: '/client-data', + profile: PROFILE, + profileIncarnationId: PROFILE_INCARNATION_ID, + ownerClientInstanceId: 'terminal-client', + }, + { + ...profileDeps(), + credentials: credentials.store, + acquirePublicationLease: async () => { + throw new Error('lease storage unavailable'); + }, + connectProfile: async () => { + attempts += 1; + return connectionHarness('unexpected').connection; + }, + }, + ); + const latest = await availability(target); + await waitFor(() => { + const current = latest(); + return current.kind === 'unavailable' && current.reason === 'host_unavailable'; + }, 'lease failure to retire provider companion'); + + const setCredential = target.setCredential?.('provider-secret'); + assert.ok(setCredential); + await assert.rejects(setCredential, /closed/u); + assert.equal(attempts, 0); + assert.equal(credentials.values.size, 0); + await target.closePublication?.(); +}); + test('remote TUI publication surfaces rejected credentials without a retry authority', async () => { const credentials = credentialHarness('revoked-secret'); let attempts = 0; @@ -578,6 +679,35 @@ async function availability(target: ReturnType current; } +function createRemoteTuiMcpPublicationTarget( + input: Parameters[0], + overrides: NonNullable[1]> = {}, +) { + return createProductionRemoteTuiMcpPublicationTarget(input, { + acquirePublicationLease: async () => ({ close: async () => undefined }), + ...overrides, + }); +} + +function publicationLeaseHarness() { + const active = new Set(); + return { + active, + acquire: async (path: string) => { + if (active.has(path)) return undefined; + active.add(path); + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + active.delete(path); + }, + }; + }, + }; +} + function credentialHarness(initial?: string) { const values = new Map(); if (initial) values.set('office\0incarnation-a\0terminal-client', initial); diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 350e688590..6f1302b8a5 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -41,6 +41,7 @@ export const TUI_COPY_RESOURCES = { host_unavailable: 'Runtime Host reconnecting', credential_required: 'provider credential required', credential_rejected: 'provider credential rejected', + provider_conflict: 'provider active in another TUI', target_mismatch: 'provider target mismatch', publishing: 'publishing', published: 'published', @@ -78,6 +79,7 @@ export const TUI_COPY_RESOURCES = { host_unavailable: 'Runtime Host 重连中', credential_required: '需要 Provider 凭据', credential_rejected: 'Provider 凭据已被拒绝', + provider_conflict: 'Provider 已在另一个 TUI 中运行', target_mismatch: 'Provider 目标不匹配', publishing: '正在发布', published: '已发布', diff --git a/packages/cli/src/tui-mcp-control.ts b/packages/cli/src/tui-mcp-control.ts index a85f339b2e..e0df998355 100644 --- a/packages/cli/src/tui-mcp-control.ts +++ b/packages/cli/src/tui-mcp-control.ts @@ -53,6 +53,7 @@ export type TuiMcpPublicationState = | 'host_unavailable' | 'credential_required' | 'credential_rejected' + | 'provider_conflict' | 'target_mismatch' | 'publishing' | 'published' @@ -179,6 +180,7 @@ export type TuiMcpPublicationUnavailableReason = | 'host_unavailable' | 'credential_required' | 'credential_rejected' + | 'provider_conflict' | 'target_mismatch'; export type TuiMcpPublicationAvailability = @@ -289,7 +291,12 @@ class TuiMcpControllerImpl implements TuiMcpController { this.#availability = availability; if (availability.kind === 'unavailable') { this.#published = undefined; - this.#updateSnapshot({ publication: availability.reason ?? 'host_unavailable' }); + this.#updateSnapshot({ + publication: availability.reason ?? 'host_unavailable', + ...(availability.reason === 'provider_conflict' + ? { canManagePublicationCredential: false } + : {}), + }); } else { this.#updateSnapshot({ publication: 'waiting' }); if (this.#snapshot.initialization === 'ready') this.#requestPublication(); @@ -571,6 +578,7 @@ class TuiMcpControllerImpl implements TuiMcpController { if ( this.#snapshot.publication === 'error' || this.#snapshot.publication === 'credential_rejected' || + this.#snapshot.publication === 'provider_conflict' || this.#snapshot.publication === 'target_mismatch' ) { return 'publication_failed'; diff --git a/packages/cli/src/tui-mcp-remote-publication.ts b/packages/cli/src/tui-mcp-remote-publication.ts index 24d1f5245e..b0dec0b88e 100644 --- a/packages/cli/src/tui-mcp-remote-publication.ts +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -41,6 +41,10 @@ import { type RuntimeHostReconnectingConnection, } from '@maka/runtime-host/client'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; +import { + tryAcquireFileLifetimeOwner, + type FileLifetimeOwner, +} from '@maka/storage/file-lifetime-owner'; import type { TuiMcpPublicationAvailability, TuiMcpPublicationTarget, @@ -53,6 +57,7 @@ interface RemoteTuiMcpPublicationDeps { readonly connectProfile: typeof connectRuntimeHostProfile; readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment; readonly createReconnectingConnection: typeof createRuntimeHostReconnectingConnection; + readonly acquirePublicationLease: typeof tryAcquireFileLifetimeOwner; readonly profiles: Pick< RuntimeHostProfileCatalog, 'readRemoteProfileIfCurrent' | 'mutateRemoteProfileIfCurrent' @@ -77,6 +82,7 @@ export function createRemoteTuiMcpPublicationTarget( connectProfile: connectRuntimeHostProfile, createPeerClient: createRuntimeHostPeerClientFromEnvironment, createReconnectingConnection: createRuntimeHostReconnectingConnection, + acquirePublicationLease: tryAcquireFileLifetimeOwner, profiles: createClientRuntimeHostProfileCatalog(input.clientDataRoot), subscribeProfileChanges: (listener) => subscribeClientRuntimeHostProfileCatalogChanges(input.clientDataRoot, listener), @@ -99,6 +105,7 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { reason: 'host_unavailable', }; #connection: RuntimeHostReconnectingConnection | undefined; + #publicationLease: FileLifetimeOwner | undefined; #disposeAvailability: (() => void) | undefined; #peerClient: RuntimeHostPeerClient | undefined; #peerCloseTask = Promise.resolve(); @@ -131,6 +138,22 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { this.#scheduleProfileValidation(error instanceof Error ? error : new Error(String(error))); } void this.#serialize(async () => { + let lease: FileLifetimeOwner | undefined; + try { + lease = await deps.acquirePublicationLease(providerLeasePath(input)); + } catch { + await this.#retire('host_unavailable'); + return; + } + if (!lease) { + await this.#retire('provider_conflict'); + return; + } + if (this.#closed) { + await lease.close(); + return; + } + this.#publicationLease = lease; const profileCurrent = await this.#profileStillCurrent().catch(() => undefined); if (profileCurrent !== true) { await this.#retire(profileCurrent === false ? 'target_mismatch' : 'host_unavailable'); @@ -419,7 +442,13 @@ class RemoteTuiMcpPublicationTarget implements TuiMcpPublicationTarget { : Promise.resolve(); this.#closeTask = operations.then(async () => { await this.#disconnect(); - this.#listeners.clear(); + const lease = this.#publicationLease; + this.#publicationLease = undefined; + try { + await lease?.close(); + } finally { + this.#listeners.clear(); + } }); return this.#closeTask; } @@ -463,7 +492,34 @@ function providerIdentityPath(input: { readonly profileIncarnationId: string; readonly ownerClientInstanceId: string; }): string { - const identity = createHash('sha256') + return join( + input.clientDataRoot, + 'runtime-host-client', + 'capability-provider-identities', + `${providerIdentity(input)}.json`, + ); +} + +function providerLeasePath(input: { + readonly clientDataRoot: string; + readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; + readonly ownerClientInstanceId: string; +}): string { + return join( + input.clientDataRoot, + 'runtime-host-client', + 'capability-provider-leases', + `${providerIdentity(input)}.lease`, + ); +} + +function providerIdentity(input: { + readonly profile: RemoteRuntimeHostProfile; + readonly profileIncarnationId: string; + readonly ownerClientInstanceId: string; +}): string { + return createHash('sha256') .update('tui-mcp-capability-provider') .update('\0') .update(runtimeHostProfileTargetFingerprint(input.profile)) @@ -473,12 +529,6 @@ function providerIdentityPath(input: { .update(input.ownerClientInstanceId) .digest('hex') .slice(0, 24); - return join( - input.clientDataRoot, - 'runtime-host-client', - 'capability-provider-identities', - `${identity}.json`, - ); } function classifyUnavailable(error: unknown): TuiMcpPublicationUnavailableReason { From a334ad10541e2539945079d1a46595c86d62fa84 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 18:58:56 +0800 Subject: [PATCH 19/19] test(desktop): store structured profile credentials --- .../src/main/__tests__/runtime-host-profile-service.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index c6aed2677a..d99b90a262 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -124,7 +124,10 @@ test('removes obsolete experimental Guest profiles and pairing intents at startu const credentials = createClientRuntimeHostCredentialStore(root); const catalog = createClientRuntimeHostProfileCatalog(root, credentials); const guest = { ...PROFILE, id: 'shared-obsolete', access: 'session_guest' as const }; - await createRuntimeHostProfileCredentialStore(credentials).set(guest, 'guest-token'); + await createRuntimeHostProfileCredentialStore(credentials).set(guest, { + credential: 'guest-token', + profileIncarnationId: 'guest-incarnation', + }); await writeFile( join(root, 'runtime-host-profiles.json'), `${JSON.stringify({ schemaVersion: 3, profiles: [guest] })}\n`,