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__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index ce00c11a8a..31b9f5de29 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/__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-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts new file mode 100644 index 0000000000..09cd6a0d4b --- /dev/null +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -0,0 +1,379 @@ +/* + * 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', + ); + 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, + 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; + readonly capabilityOwner?: { readonly principalId: string; readonly clientInstanceId: 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, + capabilityOwner: issued.capabilityOwner, + 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()); +} 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..c6ad434e78 --- /dev/null +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -0,0 +1,223 @@ +/* + * 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?.(); +}); + +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', + }; + 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 a4f967eb7a..40e8e839bb 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -61,7 +61,8 @@ type InputKind = | 'env' | 'headers' | 'edit' - | 'import'; + | 'import' + | 'publication_credential'; type McpOverlayPhase = | { kind: 'list' } @@ -78,6 +79,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. @@ -156,6 +158,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' }); } } @@ -191,7 +198,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)) { @@ -203,7 +211,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); @@ -336,6 +356,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(); @@ -423,6 +447,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') { @@ -467,9 +506,14 @@ export class McpManagementOverlay implements Component { if (!this.management()) { return this.input.locale === 'zh' ? '↑/↓ 滚动 · q/Esc 关闭' : '↑/↓ scroll · q/Esc close'; } + const credential = this.input.surface?.snapshot().canManagePublicationCredential + ? this.input.locale === 'zh' + ? ' · p 设置 Provider 凭据 · x 删除凭据' + : ' · p Set provider credential · x Remove credential' + : ''; return this.input.locale === 'zh' - ? 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭' - : 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close'; + ? `a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除${credential} · Esc 关闭` + : `a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove${credential} · Esc Close`; } private backToList(clearNotice = true): void { @@ -584,6 +628,9 @@ function publicationLine( const publication = { waiting: locale === 'zh' ? '等待发布' : 'waiting to publish', host_unavailable: locale === 'zh' ? 'Runtime Host 重连中' : 'Runtime Host reconnecting', + credential_required: locale === 'zh' ? '需要 Provider 凭据' : 'provider credential required', + credential_rejected: locale === 'zh' ? 'Provider 凭据已被拒绝' : 'provider credential rejected', + target_mismatch: locale === 'zh' ? 'Provider 目标不匹配' : 'provider target mismatch', publishing: locale === 'zh' ? '正在发布' : 'publishing', published: locale === 'zh' ? '已发布' : 'published', not_published: locale === 'zh' ? '未发布' : 'not published', @@ -650,6 +697,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.', @@ -672,6 +720,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。', @@ -730,6 +779,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]; } @@ -739,6 +789,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-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/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index c31a03ee8b..6c0fe61aec 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -66,6 +66,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; @@ -161,7 +162,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'); } @@ -169,6 +170,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-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..c18752c6ca --- /dev/null +++ b/packages/cli/src/tui-mcp-remote-publication.ts @@ -0,0 +1,310 @@ +/* + * 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: 'host_unavailable', + }; + #connection: RuntimeHostReconnectingConnection | undefined; + #disposeAvailability: (() => void) | undefined; + #peerClient: RuntimeHostPeerClient | undefined; + #operation = Promise.resolve(); + #connectAbort: AbortController | undefined; + #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); + }).catch(() => { + if (!this.#closed) this.#setUnavailable('host_unavailable'); + }); + } + + 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 { + this.#cancelConnect(); + 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 { + this.#cancelConnect(); + 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.#cancelConnect(); + 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; + const abort = new AbortController(); + this.#connectAbort = abort; + 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 ? AbortSignal.any([abort.signal, signal]) : abort.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; + } finally { + if (this.#connectAbort === abort) this.#connectAbort = undefined; + } + } + + async #disconnect(): Promise { + this.#cancelConnect(); + 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'); + } + + #cancelConnect(): void { + this.#connectAbort?.abort(new Error('Remote MCP publication target changed')); + } + + #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 34d7b48c00..db5b5fb938 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -30,9 +30,11 @@ import { import { connectRemoteRuntimeHostProfile, createFileRuntimeHostProfileCatalog, + createRuntimeHostCapabilityProviderCredentialStore, createRuntimeHostProfileCredentialStore, decodeRuntimeHostProfileDocument, RUNTIME_HOST_PLAINTEXT_ACKNOWLEDGEMENT, + RuntimeHostProfileConnectionError, sameRemoteRuntimeHostProfileTarget, type RemoteRuntimeHostProfile, type RuntimeHostProfileCredentialStore, @@ -403,6 +405,48 @@ 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'); + 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'); + }); + + 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']); @@ -674,7 +718,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; + }, ); }); @@ -858,6 +906,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 b364645890..f6b7ccd3dc 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'; @@ -203,6 +204,31 @@ 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 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, @@ -232,12 +258,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), @@ -245,11 +269,45 @@ export function createRuntimeHostProfileCredentialStore( credential, ); }, - delete: (profile) => - credentials.deleteSecret(profileCredentialSlot(profile), 'runtime_host_access'), + delete: (profile) => credentials.deleteSecret(profileCredentialSlot(profile)), + }; +} + +export function createRuntimeHostCapabilityProviderCredentialStore( + credentials: Pick, +): RuntimeHostCapabilityProviderCredentialStore { + return { + get: async (profile, ownerClientInstanceId) => { + const stored = await credentials.getSecret( + profileCredentialSlot(profile), + 'runtime_host_capability_provider', + ); + 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', + JSON.stringify({ + schemaVersion: 1, + ownerClientInstanceId: requireClientInstanceId(ownerClientInstanceId), + credential: requireRuntimeHostAccessCredential(credential), + }), + ); + }, + delete: (profile, ownerClientInstanceId) => + deleteCapabilityProviderCredential(credentials, profile, ownerClientInstanceId), }; } +export function runtimeHostProfileTargetFingerprint(profile: RemoteRuntimeHostProfile): string { + return profileCredentialBinding(profile); +} + export async function connectRuntimeHostProfile( input: { readonly profile: PersistedRuntimeHostProfile; @@ -294,7 +352,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`, ); } @@ -400,6 +459,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, @@ -466,7 +539,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`, ); } @@ -491,6 +565,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; @@ -1004,6 +1086,62 @@ function profileCredentialSlot(profile: RemoteRuntimeHostProfile): string { return `runtime-host-profile:${requireProfileId(profile.id)}:${profileCredentialBinding(profile)}`; } +async function deleteCapabilityProviderCredential( + credentials: Pick, + profile: RemoteRuntimeHostProfile, + ownerClientInstanceId: string, +): 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 { + 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 966cc2e789..e68cac3cf8 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, @@ -51,6 +52,7 @@ export { decodePersistedRuntimeHostProfile, decodeRemoteRuntimeHostProfile, remoteRuntimeHostUnavailableError, + runtimeHostProfileTargetFingerprint, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, type EnvironmentRuntimeHostProfile, @@ -60,6 +62,9 @@ export { type ResolvedRuntimeHostProfile, type RuntimeHostProfile, type RuntimeHostProfileCatalog, + type RuntimeHostCapabilityProviderCredentialStore, + RuntimeHostProfileConnectionError, + type RuntimeHostProfileConnectionFailureReason, 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'; } }