From b2ae4d028eaa9992a7d2fa128cf12b8a00b6ee5d Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 23:50:32 +0800 Subject: [PATCH 1/2] feat(runtime-host): bind providers to Client owners Resolve provider ownership from an active Client-bound owner credential and persist only the Host-verified identity. Fence the durable schema and protocol epoch so older Hosts cannot silently drop the association. Generated-by: Codex --- .../runtime-host-operator-command.test.ts | 34 ++++++ packages/cli/src/cli-core.ts | 4 + .../cli/src/runtime-host-access-command.ts | 6 + packages/cli/src/runtime-host-cli.ts | 22 +++- .../__tests__/authenticated-websocket.test.ts | 109 ++++++++++++++++++ .../src/__tests__/protocol.test.ts | 33 ++++++ .../src/protocol/access-authority.ts | 85 +++++++++++--- packages/runtime-host/src/protocol/index.ts | 5 +- .../src/server/access-authority.ts | 49 +++++++- .../src/server/access-credential-store.ts | 41 ++++++- .../src/server/client-capability-service.ts | 2 + .../src/server/connection-authority.ts | 25 +++- .../src/server/connection-session.ts | 3 + 13 files changed, 390 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index b99cedecb4..7612cc5456 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -317,6 +317,40 @@ describe('Runtime Host operator commands', () => { ]).kind, 'error', ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'access', + 'issue', + '--kind', + 'capability-provider', + '--principal', + 'terminal-mcp-provider', + '--capability-owner-credential', + 'terminal-owner-credential', + ]), + { + kind: 'runtime-host-access-issue', + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: 'terminal-owner-credential', + }, + ); + assert.equal( + parseRuntimeHostCommand([ + 'access', + 'issue', + '--principal', + 'terminal-owner', + '--grant', + 'session.catalog.query', + '--capability-owner-credential', + 'terminal-owner-credential', + ]).kind, + 'error', + ); assert.deepEqual( parseRuntimeHostCommand([ 'access', diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 0dd8e4c268..416f65e9f2 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -228,6 +228,7 @@ function helpText(cliCommand: string): string { ' --preset Grant the desktop-client or terminal-client operation set', ' --publish-client-capabilities Allow Client Capability publication', ' --allow-host-paths Allow operations that submit Host paths', + ' --capability-owner-credential Bind a provider to one Client-bound owner credential', '', 'Runtime Host capability provider options:', ' --url Connect to an authenticated Runtime Host WebSocket', @@ -624,6 +625,9 @@ export async function runMakaCli( operationGrants: command.operationGrants, canPublishClientCapabilities: command.canPublishClientCapabilities, canUseHostPaths: command.canUseHostPaths, + ...(command.capabilityOwnerCredentialId + ? { capabilityOwnerCredentialId: command.capabilityOwnerCredentialId } + : {}), ...(command.preset ? { preset: command.preset } : {}), }); } diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 47311f16af..0be1ee81d9 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -28,6 +28,7 @@ import { RUNTIME_HOST_PROTOCOL_VERSION, type AccessCredentialRotationRevokeInput, type AccessCredentialPrincipalKind, + type ClientCapabilityOwnerIdentity, type OperationKey, } from '@maka/runtime-host/protocol'; import { @@ -63,6 +64,7 @@ export interface RuntimeHostAccessIssueOptions { readonly operationGrants: readonly string[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; readonly preset?: RuntimeHostAccessPreset; readonly bindClientInstance?: boolean; } @@ -74,6 +76,7 @@ export interface ResolvedRuntimeHostAccessIssue { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; } const CLIENT_CAPABILITY_PUBLICATION_OPERATIONS = new Set([ @@ -228,6 +231,9 @@ async function mutateRuntimeHostAccessCredential( operationGrants: resolved.operationGrants, canPublishClientCapabilities: resolved.canPublishClientCapabilities, canUseHostPaths: resolved.canUseHostPaths, + ...(operation !== 'access.credential.prepare' && options.capabilityOwnerCredentialId + ? { capabilityOwnerCredentialId: options.capabilityOwnerCredentialId } + : {}), ...(operation === 'access.credential.prepare' && options.bindClientInstance ? { bindClientInstance: true } : {}), diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index afaa84de78..825f0e4130 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -247,6 +247,7 @@ export type RuntimeHostCliCommand = operationGrants: string[]; canPublishClientCapabilities: boolean; canUseHostPaths: boolean; + capabilityOwnerCredentialId?: string; preset?: 'desktop-client' | 'terminal-client'; } | { @@ -1945,6 +1946,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { let principalKind: 'remote_owner' | 'capability_provider' = 'remote_owner'; let principalKindSpecified = false; let credentialId: string | undefined; + let capabilityOwnerCredentialId: string | undefined; let currentCredentialFingerprint: string | undefined; const operationGrants: string[] = []; let canPublishClientCapabilities = false; @@ -1973,6 +1975,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { argument === '--principal' || argument === '--grant' || argument === '--credential' || + argument === '--capability-owner-credential' || argument === '--current-fingerprint' ) { const parsed = optionValue(argv, index, argument); @@ -1995,6 +1998,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { if (argument === '--principal') principalId = parsed; if (argument === '--grant') operationGrants.push(parsed); if (argument === '--credential') credentialId = parsed; + if (argument === '--capability-owner-credential') capabilityOwnerCredentialId = parsed; if (argument === '--current-fingerprint') currentCredentialFingerprint = parsed; index += 1; continue; @@ -2013,6 +2017,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canUseHostPaths || preset || credentialId || + capabilityOwnerCredentialId || currentCredentialFingerprint ) { return error('Credential mutation options are not valid for access list'); @@ -2037,7 +2042,8 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canPublishClientCapabilities || canUseHostPaths || preset || - credentialId + credentialId || + capabilityOwnerCredentialId ) { return error('Credential issue options are not valid for access prepare'); } @@ -2064,6 +2070,9 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return error('--preset cannot be combined with --kind, --grant, or authority flags'); } if (preset) { + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential requires --kind capability-provider'); + } return { kind: 'runtime-host-access-issue', ...(rootPath ? { rootPath } : {}), @@ -2087,8 +2096,11 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return error('A capability provider may grant only Client Capability publication'); } canPublishClientCapabilities = true; - } else if (operationGrants.length === 0) { - return error('At least one --grant is required'); + } else { + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential requires --kind capability-provider'); + } + if (operationGrants.length === 0) return error('At least one --grant is required'); } return { kind: 'runtime-host-access-issue', @@ -2099,8 +2111,12 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { operationGrants, canPublishClientCapabilities, canUseHostPaths, + ...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}), }; } + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential is only valid for access issue'); + } if (!credentialId) return error('--credential is required'); if (framed && !currentCredentialFingerprint) { return error('--current-fingerprint is required for framed access revoke'); diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index 878675d6c7..ed6826bf54 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -598,6 +598,115 @@ test('access credentials persist only as hashes and stay revoked after reload', } }); +test('capability-provider credentials retain a Host-verified Client owner identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-provider-owner-')); + try { + const authority = await openRuntimeHostAccessAuthority(directory); + const owner = await authority.prepare({ + principalKind: 'remote_owner', + principalId: 'terminal-owner', + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + await authority.finalize(owner.credentialId, 'terminal-client'); + const unboundOwner = await authority.issue({ + principalKind: 'remote_owner', + principalId: 'unbound-owner', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + const providerInput = { + principalKind: 'capability_provider' as const, + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'] as const, + canPublishClientCapabilities: true, + canUseHostPaths: false, + }; + const expectedOwner = { + principalId: 'terminal-owner', + clientInstanceId: 'terminal-client', + } as const; + await assert.rejects( + authority.issue({ + ...providerInput, + capabilityOwnerCredentialId: unboundOwner.credentialId, + }), + /must be bound to one Client identity/u, + ); + await assert.rejects( + authority.issue({ + principalKind: 'remote_owner', + principalId: 'invalid-owner-reference', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + capabilityOwnerCredentialId: owner.credentialId, + }), + /Only a capability provider/u, + ); + + const provider = await authority.issue({ + ...providerInput, + capabilityOwnerCredentialId: owner.credentialId, + }); + assert.deepEqual(provider.capabilityOwner, expectedOwner); + assert.equal( + JSON.parse(await readFile(join(directory, 'runtime-host-access.json'), 'utf8')).schemaVersion, + 2, + ); + const { consumeAccessCredentialDeliveryFromControlDirectory } = await import( + '../control/access-credential-delivery.js' + ); + const credential = await consumeAccessCredentialDeliveryFromControlDirectory( + directory, + provider.deliveryId, + provider.credentialId, + ); + assert.deepEqual(authority.authenticate(credential)?.capabilityOwner, expectedOwner); + + const replacementOwner = await authority.prepareRotation({ + replacementOfCredentialId: owner.credentialId, + }); + await authority.finalize(replacementOwner.credentialId, 'terminal-client'); + const replacementProvider = await authority.issue({ + ...providerInput, + principalId: 'rotated-terminal-mcp-provider', + capabilityOwnerCredentialId: replacementOwner.credentialId, + }); + assert.deepEqual(replacementProvider.capabilityOwner, provider.capabilityOwner); + + const reopened = await openRuntimeHostAccessAuthority(directory); + assert.deepEqual(reopened.authenticate(credential)?.capabilityOwner, expectedOwner); + await assert.rejects( + reopened.issue({ + ...providerInput, + principalId: 'missing-owner-provider', + capabilityOwnerCredentialId: 'missing-owner-credential', + }), + /active remote-owner credential/u, + ); + await authority.close(); + await reopened.close(); + const downgraded = JSON.parse( + await readFile(join(directory, 'runtime-host-access.json'), 'utf8'), + ) as Record; + await writeFile( + join(directory, 'runtime-host-access.json'), + `${JSON.stringify({ ...downgraded, schemaVersion: 1 })}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + openRuntimeHostAccessAuthority(directory), + /Legacy Runtime Host access files cannot declare Client Capability owners/u, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + test('credential rotation preserves authority and cannot outlive its active source', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-rotation-')); const authority = await openRuntimeHostAccessAuthority(directory); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index bc348ac164..81bcfd7543 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -302,6 +302,39 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('decodes Host-bound capability-provider ownership at a new compatibility boundary', () => { + const input = { + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: 'terminal-owner-credential', + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeInput(input), input); + assert.throws(() => + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({ + ...input, + bindClientInstance: true, + }), + ); + const output = { + credentialId: 'provider-credential', + deliveryId: 'provider-delivery', + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwner: { + principalId: 'terminal-owner', + clientInstanceId: 'terminal-client', + }, + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeOutput(output), output); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 67); + }); + test('decodes atomic principal revocation and publishes its compatibility boundary', () => { assert.deepEqual( HOST_OPERATION_SPECS['access.principal.revoke'].decodeInput({ diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts index 78ffc7789d..83dc771bcf 100644 --- a/packages/runtime-host/src/protocol/access-authority.ts +++ b/packages/runtime-host/src/protocol/access-authority.ts @@ -32,6 +32,11 @@ export const ACCESS_CREDENTIAL_MAX_GRANTS = 256; export type AccessCredentialPrincipalKind = 'remote_owner' | 'capability_provider'; +export interface ClientCapabilityOwnerIdentity { + readonly principalId: string; + readonly clientInstanceId: string; +} + const ACCESS_ERRORS = [ 'host_not_ready', 'host_draining', @@ -48,6 +53,7 @@ export interface AccessCredentialIssueInput { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; } export interface AccessCredentialIssueResult { @@ -58,11 +64,13 @@ export interface AccessCredentialIssueResult { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; } export type AccessCredentialReplaceInput = AccessCredentialIssueInput; export type AccessCredentialReplaceResult = AccessCredentialIssueResult; -export interface AccessCredentialPrepareInput extends AccessCredentialIssueInput { +export interface AccessCredentialPrepareInput + extends Omit { readonly bindClientInstance?: boolean; } export type AccessCredentialPrepareResult = AccessCredentialIssueResult; @@ -195,15 +203,28 @@ export const ACCESS_AUTHORITY_OPERATION_SPECS = { } as const; export function decodeAccessCredentialIssueInput(value: unknown): AccessCredentialIssueInput { - const record = requireExactRecord(value, 'access credential issue input', [ - 'principalKind', - 'principalId', - 'operationGrants', - 'canPublishClientCapabilities', - 'canUseHostPaths', - ]); + const record = requireShapedRecord( + value, + 'access credential issue input', + [ + 'principalKind', + 'principalId', + 'operationGrants', + 'canPublishClientCapabilities', + 'canUseHostPaths', + ], + ['capabilityOwnerCredentialId'], + ); + const decodedPrincipalKind = principalKind(record.principalKind); + const capabilityOwnerCredentialId = + record.capabilityOwnerCredentialId === undefined + ? undefined + : requireId(record.capabilityOwnerCredentialId, 'capabilityOwnerCredentialId'); + if (capabilityOwnerCredentialId && decodedPrincipalKind !== 'capability_provider') { + throw invalidProtocolFrame('Only a capability provider credential may declare a Client owner'); + } return { - principalKind: principalKind(record.principalKind), + principalKind: decodedPrincipalKind, principalId: principalId(record.principalId), operationGrants: operationGrants(record.operationGrants), canPublishClientCapabilities: boolean( @@ -211,6 +232,7 @@ export function decodeAccessCredentialIssueInput(value: unknown): AccessCredenti 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}), }; } @@ -243,19 +265,32 @@ export function decodeAccessCredentialPrepareInput(value: unknown): AccessCreden } export function decodeAccessCredentialIssueResult(value: unknown): AccessCredentialIssueResult { - const record = requireExactRecord(value, 'access credential issue result', [ - 'credentialId', - 'deliveryId', - 'principalKind', - 'principalId', - 'operationGrants', - 'canPublishClientCapabilities', - 'canUseHostPaths', - ]); + const record = requireShapedRecord( + value, + 'access credential issue result', + [ + 'credentialId', + 'deliveryId', + 'principalKind', + 'principalId', + 'operationGrants', + 'canPublishClientCapabilities', + 'canUseHostPaths', + ], + ['capabilityOwner'], + ); + const decodedPrincipalKind = principalKind(record.principalKind); + const capabilityOwner = + record.capabilityOwner === undefined + ? undefined + : clientCapabilityOwnerIdentity(record.capabilityOwner); + if (capabilityOwner && decodedPrincipalKind !== 'capability_provider') { + throw invalidProtocolFrame('Only a capability provider credential may declare a Client owner'); + } return { credentialId: requireId(record.credentialId, 'credentialId'), deliveryId: requireId(record.deliveryId, 'deliveryId'), - principalKind: principalKind(record.principalKind), + principalKind: decodedPrincipalKind, principalId: principalId(record.principalId), operationGrants: operationGrants(record.operationGrants), canPublishClientCapabilities: boolean( @@ -263,6 +298,7 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwner ? { capabilityOwner } : {}), }; } @@ -370,6 +406,17 @@ function principalId(value: unknown): string { return principal; } +function clientCapabilityOwnerIdentity(value: unknown): ClientCapabilityOwnerIdentity { + const record = requireExactRecord(value, 'Client Capability owner identity', [ + 'principalId', + 'clientInstanceId', + ]); + return { + principalId: principalId(record.principalId), + clientInstanceId: requireId(record.clientInstanceId, 'clientInstanceId'), + }; +} + function boolean(value: unknown, label: string): boolean { if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${label}`); return value; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 5c208bf3d8..8bf2b65d84 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 69 as const; +// 69: Capability-provider credentials may carry one Host-authenticated owner +// identity. Older peers cannot preserve the association and could select an +// unrelated provider for an interactive Session. // 68: Connection onboarding replaces nullable canonical-slug targeting with // explicit create/existing identity and returns the committed Connection. // Older peers reject the closed target and saved-result shapes. diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index b5c7f78072..df3f2d5bdf 100644 --- a/packages/runtime-host/src/server/access-authority.ts +++ b/packages/runtime-host/src/server/access-authority.ts @@ -23,6 +23,7 @@ import { runtimeHostAccessCredentialHash } from '../access-credential-identity.j import { type AccessCredentialIssueInput, type AccessCredentialIssueResult, + type ClientCapabilityOwnerIdentity, type AccessCredentialFinalizeResult, type AccessCredentialPrepareInput, type AccessCredentialPrepareResult, @@ -153,6 +154,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { !match.bindClientInstanceOnFinalize && match.canPublishClientCapabilities, canUseHostPaths: !match.bindClientInstanceOnFinalize && match.canUseHostPaths, ...(match.clientInstanceId ? { clientInstanceId: match.clientInstanceId } : {}), + ...(match.capabilityOwner ? { capabilityOwner: match.capabilityOwner } : {}), }) : undefined; } @@ -181,7 +183,14 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { if (!current) { throw new RuntimeHostAccessInputError('The credential being rotated is no longer active'); } - return this.#createCredential(current, 'prepare', current.operationGrants); + return this.#createCredential( + { + ...current, + ...(current.clientInstanceId ? { bindClientInstance: true } : {}), + }, + 'prepare', + current.operationGrants, + ); }); } @@ -198,6 +207,9 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants = issuedAccessGrants(input.operationGrants), ): Promise { assertCredentialAuthority(input, operationGrants); + const capabilityOwner = this.#resolveCapabilityOwner( + 'capabilityOwnerCredentialId' in input ? input.capabilityOwnerCredentialId : undefined, + ); if ( mode === 'prepare' && (input.principalKind !== 'remote_owner' || @@ -215,6 +227,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: input.canPublishClientCapabilities, canUseHostPaths: input.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), }); const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`; const createdAt = new Date(); @@ -227,6 +240,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: input.canPublishClientCapabilities, canUseHostPaths: input.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt: createdAt.toISOString(), ...(mode === 'prepare' ? { @@ -272,9 +286,33 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: stored.canPublishClientCapabilities, canUseHostPaths: stored.canUseHostPaths, + ...(stored.capabilityOwner ? { capabilityOwner: stored.capabilityOwner } : {}), }; } + #resolveCapabilityOwner( + credentialId: string | undefined, + ): ClientCapabilityOwnerIdentity | undefined { + if (!credentialId) return undefined; + const owner = this.#file.credentials.find( + (credential) => credential.credentialId === credentialId && credential.status === 'active', + ); + if (!owner || owner.principalKind !== 'remote_owner') { + throw new RuntimeHostAccessInputError( + 'A capability provider owner must be one active remote-owner credential', + ); + } + if (!owner.clientInstanceId) { + throw new RuntimeHostAccessInputError( + 'A capability provider owner credential must be bound to one Client identity', + ); + } + return Object.freeze({ + principalId: owner.principalId, + clientInstanceId: owner.clientInstanceId, + }); + } + revoke(input: AccessCredentialRevokeInput): Promise { return this.#mutate(async () => { return this.#revoke(input.credentialId); @@ -524,7 +562,14 @@ function assertCredentialAuthority( input: AccessCredentialIssueInput, operationGrants: readonly string[], ): void { - if (input.principalKind !== 'capability_provider') return; + if (input.principalKind !== 'capability_provider') { + if (input.capabilityOwnerCredentialId) { + throw new RuntimeHostAccessInputError( + 'Only a capability provider credential may declare a Client owner', + ); + } + return; + } if (!input.canPublishClientCapabilities || input.canUseHostPaths) { throw new RuntimeHostAccessInputError( 'A capability provider must publish Client Capabilities without Host path authority', diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 210d9899d0..4058a097d5 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -22,12 +22,16 @@ import { chmod, open, rename, rm, type FileHandle } from 'node:fs/promises'; import { dirname } from 'node:path'; import { type AccessCredentialPrincipalKind, + type ClientCapabilityOwnerIdentity, HOST_OPERATION_SPECS, operationAllowsRemoteOwner, type OperationKey, } from '../protocol/index.js'; -const ACCESS_FILE_SCHEMA_VERSION = 1; +// Schema 2 makes provider ownership downgrade-safe: a pre-association Host +// rejects the file instead of silently treating a bound provider as global. +const ACCESS_FILE_SCHEMA_VERSION = 2; +const LEGACY_ACCESS_FILE_SCHEMA_VERSION = 1; const ACCESS_FILE_MAX_BYTES = 512 * 1024; const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query'; const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ @@ -63,6 +67,7 @@ export interface StoredAccessCredential { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; readonly createdAt: string; readonly bindClientInstanceOnFinalize?: true; readonly clientInstanceId?: string; @@ -178,11 +183,21 @@ function serializeAccessCredentialFile(file: AccessCredentialFile): string { } function decodeAccessFile(value: unknown): AccessCredentialFile { - if (!isRecord(value) || value.schemaVersion !== ACCESS_FILE_SCHEMA_VERSION) { + if ( + !isRecord(value) || + (value.schemaVersion !== LEGACY_ACCESS_FILE_SCHEMA_VERSION && + value.schemaVersion !== ACCESS_FILE_SCHEMA_VERSION) + ) { throw new Error('Unsupported Runtime Host access file'); } if (!Array.isArray(value.credentials)) throw new Error('Invalid Runtime Host access file'); const credentials = value.credentials.map(decodeStoredCredential); + if ( + value.schemaVersion === LEGACY_ACCESS_FILE_SCHEMA_VERSION && + credentials.some((credential) => credential.capabilityOwner !== undefined) + ) { + throw new Error('Legacy Runtime Host access files cannot declare Client Capability owners'); + } if ( new Set(credentials.map((credential) => credential.credentialId)).size !== credentials.length ) { @@ -253,6 +268,10 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { ) { throw new Error('Invalid access credential Client binding state'); } + const capabilityOwner = decodeCapabilityOwner(value.capabilityOwner); + if (capabilityOwner && principalKind !== 'capability_provider') { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } const expiresAt = value.expiresAt; if (value.status === 'pending') { if (typeof expiresAt !== 'string' || !Number.isFinite(Date.parse(expiresAt))) { @@ -274,6 +293,7 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { operationGrants, canPublishClientCapabilities: value.canPublishClientCapabilities, canUseHostPaths: value.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt, ...(bindClientInstanceOnFinalize === true ? { bindClientInstanceOnFinalize } : {}), ...(typeof clientInstanceId === 'string' ? { clientInstanceId } : {}), @@ -282,6 +302,23 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { }; } +function decodeCapabilityOwner(value: unknown): ClientCapabilityOwnerIdentity | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error('Invalid Client Capability owner identity'); + const principalId = requireStoredString(value.principalId, 'capabilityOwner.principalId'); + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(principalId)) { + throw new Error('Invalid capabilityOwner.principalId'); + } + const clientInstanceId = requireStoredString( + value.clientInstanceId, + 'capabilityOwner.clientInstanceId', + ); + if (clientInstanceId.length > 128) { + throw new Error('Invalid capabilityOwner.clientInstanceId'); + } + return Object.freeze({ principalId, clientInstanceId }); +} + function migrateStoredOperationGrants(grants: readonly string[]): readonly string[] { const migrated: string[] = []; const seen = new Set(); diff --git a/packages/runtime-host/src/server/client-capability-service.ts b/packages/runtime-host/src/server/client-capability-service.ts index 58ea30ec09..046ac6d464 100644 --- a/packages/runtime-host/src/server/client-capability-service.ts +++ b/packages/runtime-host/src/server/client-capability-service.ts @@ -21,6 +21,7 @@ import type { AccessCredentialPrincipalKind, ClientCapabilityClientFrame, ClientCapabilityHostFrame, + ClientCapabilityOwnerIdentity, } from '../protocol/index.js'; export interface ClientCapabilityConnectionSender { @@ -32,6 +33,7 @@ export interface ClientCapabilityConnectionIdentity { readonly principalId: string; readonly clientInstanceId: string; readonly principalKind: 'local_owner' | AccessCredentialPrincipalKind; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; } export interface ClientCapabilityConnection { diff --git a/packages/runtime-host/src/server/connection-authority.ts b/packages/runtime-host/src/server/connection-authority.ts index 3b7c9f5b02..bb453337f5 100644 --- a/packages/runtime-host/src/server/connection-authority.ts +++ b/packages/runtime-host/src/server/connection-authority.ts @@ -22,6 +22,7 @@ import { operationAllowsRemoteOwner, operationUsesHostPaths, type AccessCredentialPrincipalKind, + type ClientCapabilityOwnerIdentity, type ClientCapabilityClientFrame, type OperationKey, type RequestFrame, @@ -32,6 +33,7 @@ export interface RuntimeHostConnectionAuthority { readonly principalId: string; readonly credentialId?: string; readonly clientInstanceId?: string; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; readonly operationGrants: 'all' | readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; @@ -63,6 +65,20 @@ export function createRuntimeHostConnectionAuthority( ) { throw new Error('Runtime Host bound Client identity is invalid'); } + if (input.capabilityOwner) { + if (input.principalKind !== 'capability_provider') { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.capabilityOwner.principalId)) { + throw new Error('Runtime Host Client Capability owner principal is invalid'); + } + if ( + input.capabilityOwner.clientInstanceId.length === 0 || + input.capabilityOwner.clientInstanceId.length > 128 + ) { + throw new Error('Runtime Host Client Capability owner identity is invalid'); + } + } const operationGrants = input.operationGrants === 'all' ? 'all' @@ -74,7 +90,14 @@ export function createRuntimeHostConnectionAuthority( return operation; }), ); - return Object.freeze({ ...input, operationGrants }); + const capabilityOwner = input.capabilityOwner + ? Object.freeze({ ...input.capabilityOwner }) + : undefined; + return Object.freeze({ + ...input, + operationGrants, + ...(capabilityOwner ? { capabilityOwner } : {}), + }); } export function authorizeRuntimeHostOperation( diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index 4f54f122d3..aaf267e806 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -291,6 +291,9 @@ export class RuntimeHostConnectionSession { principalId: this.#options.connection.authority.principalId, clientInstanceId: this.#options.connection.clientInstanceId, principalKind: this.#options.connection.authority.principalKind, + ...(this.#options.connection.authority.capabilityOwner + ? { capabilityOwner: this.#options.connection.authority.capabilityOwner } + : {}), }, { send: (frame) => { From 799c343d79f57b4af7492ea70cdebb240beea136 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 23:50:38 +0800 Subject: [PATCH 2/2] feat(runtime-host): select providers by Client owner Prefer the initiating Client's direct provider, then one exact Host-bound companion. Keep unrelated providers invisible to remote Clients and fail closed when more than one companion claims the same owner. Generated-by: Codex --- .../client-capability-coordinator.test.ts | 130 ++++++++++++++++++ .../__tests__/fixtures/client-capability.ts | 9 +- .../server/client-capability-coordinator.ts | 108 ++++++++++++--- 3 files changed, 227 insertions(+), 20 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index b8537d82ea..262acc2f77 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -301,6 +301,119 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); + test('selects only the provider bound to the exact initiating Client identity', async () => { + const coordinator = createCoordinator(); + const owner = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'owner-connection', + 'owner-client', + 'owner-principal', + 'remote_owner', + ), + { send: async () => {} }, + ); + const unrelated = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'unrelated-provider', + 'unrelated-provider-client', + 'unrelated-provider-principal', + 'capability_provider', + { principalId: 'other-principal', clientInstanceId: 'other-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'unrelated-provider', + 'unrelated-registration', + 'inspect', + ); + + assert.deepEqual(await coordinator.bindSession('unrelated-only', 'owner-connection'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('unrelated-only'), undefined); + + const associated = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'associated-provider', + 'associated-provider-client', + 'associated-provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'associated-provider', + 'associated-registration', + 'inspect', + ); + assert.throws( + () => + coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'changed-owner-provider', + 'associated-provider-client', + 'associated-provider-principal', + 'capability_provider', + { principalId: 'other-principal', clientInstanceId: 'other-client' }, + ), + { send: async () => {} }, + ), + /provider owner changed/u, + ); + + assert.deepEqual(await coordinator.bindSession('associated', 'owner-connection'), { ok: true }); + const snapshot = coordinator.snapshotForSession('associated'); + assert.deepEqual(snapshot?.registrationIds, ['associated-registration']); + snapshot?.release(); + + const otherClient = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'other-client-connection', + 'different-client', + 'owner-principal', + 'remote_owner', + ), + { send: async () => {} }, + ); + assert.deepEqual(await coordinator.bindSession('different-client', 'other-client-connection'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('different-client'), undefined); + + const duplicate = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'duplicate-provider', + 'duplicate-provider-client', + 'duplicate-provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'duplicate-provider', + 'duplicate-registration', + 'inspect', + ); + const ambiguous = await coordinator.bindSession('duplicate-owner', 'owner-connection'); + assert.equal(ambiguous.ok, false); + if (!ambiguous.ok) assert.match(ambiguous.message, /bound to the initiating Client/u); + + await Promise.all([ + owner.close(), + unrelated.close(), + associated.close(), + otherClient.close(), + duplicate.close(), + ]); + await coordinator.close(); + }); + test('previews the initiating provider without persisting a Session binding', async () => { const coordinator = createCoordinator(); const connection = coordinator.attachConnection( @@ -1190,6 +1303,23 @@ async function replace( assert.equal(outcome.ok, true); } +async function replaceTrustedProvider( + coordinator: HostClientCapabilityCoordinator, + connectionId: string, + registrationId: string, + toolName: string, +): Promise { + const input = replacementInput(registrationId, toolName); + const outcome = await coordinator.handlers['client.capability.replace']( + { + ...input, + offers: input.offers.map((offer) => ({ ...offer, hostPathAccess: 'none' as const })), + }, + connectionContext(connectionId), + ); + assert.equal(outcome.ok, true); +} + function replacementInput( registrationId: string, toolName: string, diff --git a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts index eb4ced75d9..d743609b7d 100644 --- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts +++ b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts @@ -24,6 +24,13 @@ export function clientCapabilityConnectionIdentity( clientInstanceId = connectionId, principalId = 'test-principal', principalKind: ClientCapabilityConnectionIdentity['principalKind'] = 'local_owner', + capabilityOwner?: ClientCapabilityConnectionIdentity['capabilityOwner'], ): ClientCapabilityConnectionIdentity { - return { connectionId, principalId, clientInstanceId, principalKind }; + return { + connectionId, + principalId, + clientInstanceId, + principalKind, + ...(capabilityOwner ? { capabilityOwner } : {}), + }; } diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 1e54236d92..f036185b52 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -25,6 +25,7 @@ import type { RootExecutionDescriptor } from '@maka/core/agent-run'; import { type ToolGroup } from '@maka/runtime/tool-availability'; import { type ClientCapabilityOffer, + type ClientCapabilityOwnerIdentity, type ClientCapabilityReplaceInput, type ClientCapabilityServiceOffer, type ClientCapabilityToolDescriptor, @@ -65,7 +66,9 @@ interface ClientProviderState { readonly providerId: string; readonly principalId: string; readonly clientInstanceId: string; + readonly principalKind: ClientCapabilityConnectionIdentity['principalKind']; readonly trustedProvider: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; activeConnectionId?: string; current?: CapabilityRegistration; readonly registrations: Map; @@ -333,14 +336,41 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService initiatingConnectionId: string, mode: SessionBindingMode, ): SessionBindingSelection { - const initiatingProviderId = this.#connections.get(initiatingConnectionId)?.provider.providerId; - const initiatingProvider = initiatingProviderId - ? this.#providers.get(initiatingProviderId) - : undefined; + const initiatingProvider = this.#connections.get(initiatingConnectionId)?.provider; + const directProvider = + initiatingProvider?.current && this.#activeConnection(initiatingProvider) + ? initiatingProvider + : undefined; + const associatedProviders = initiatingProvider + ? [...this.#providers.values()].filter( + (provider) => + provider.trustedProvider && + provider.current !== undefined && + this.#activeConnection(provider) !== undefined && + provider.capabilityOwner?.principalId === initiatingProvider.principalId && + provider.capabilityOwner.clientInstanceId === initiatingProvider.clientInstanceId, + ) + : []; + if (!directProvider && associatedProviders.length > 1) { + return { + ok: false, + message: 'Multiple Client Capability providers are bound to the initiating Client', + }; + } + const selectedInitiatingProvider = directProvider ?? associatedProviders[0]; + // A remote Client must never inherit an unrelated provider merely because + // it is the only candidate. Local-owner and recovery flows retain their + // existing provider-independent fallback when no provider was selected. + const initiatingProviderId = + selectedInitiatingProvider?.providerId ?? + (initiatingProvider?.principalKind === 'remote_owner' + ? initiatingProvider.providerId + : undefined); const previousState = this.#sessions.get(sessionId); const serviceProviderId = previousState?.serviceProviderId ?? - (initiatingProvider?.current && initiatingProvider.current.servicesByContract.size > 0 + (selectedInitiatingProvider?.current && + selectedInitiatingProvider.current.servicesByContract.size > 0 ? initiatingProviderId : undefined); const previous = previousState?.sessionBindings ?? new Map(); @@ -388,10 +418,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } } else { - candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); - if (!candidate && candidates.length > 1) { + candidate = selectProviderCandidate(candidates, initiatingProviderId); + if (!candidate && initiatingProviderId === undefined && candidates.length > 1) { if (mode === 'degrade') continue; return { ok: false, @@ -436,9 +464,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService left.localeCompare(right), )) { if (candidates[0]?.offer.offer.affinity !== 'turn') continue; - const candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); + const candidate = selectProviderCandidate(candidates, initiatingProviderId); if (!candidate || offerConflictsWithProxyNames(candidate.offer, proxyNames)) continue; nextTurn.set(contractId, { kind: 'bound', @@ -510,7 +536,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService for (const [contractId, candidates] of [...eligible].sort(([left], [right]) => left.localeCompare(right), )) { - const offer = candidates[0]?.offer; + // A provider-independent snapshot keeps one representative descriptor so + // a dynamic call can report ambiguity. A remote Client's explicit + // selector must instead hide every unrelated provider. + const offer = + state?.initiatingProviderId === undefined + ? candidates[0]?.offer + : selectProviderCandidate(candidates, state.initiatingProviderId)?.offer; if ( !offer || offer.offer.affinity !== 'call' || @@ -987,18 +1019,32 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState { const providerId = clientProviderId(identity.principalId, identity.clientInstanceId); const trustedProvider = identity.principalKind === 'capability_provider'; + if (identity.capabilityOwner && !trustedProvider) { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } let provider = this.#providers.get(providerId); if (!provider) { provider = { providerId, principalId: identity.principalId, clientInstanceId: identity.clientInstanceId, + principalKind: identity.principalKind, trustedProvider, + ...(identity.capabilityOwner + ? { capabilityOwner: Object.freeze({ ...identity.capabilityOwner }) } + : {}), registrations: new Map(), }; this.#providers.set(providerId, provider); - } else if (provider.trustedProvider !== trustedProvider) { + } else if ( + provider.principalKind !== identity.principalKind || + provider.trustedProvider !== trustedProvider + ) { throw new Error('Client Capability provider authority changed across connections'); + } else if ( + !clientCapabilityOwnerIdentitiesEqual(provider.capabilityOwner, identity.capabilityOwner) + ) { + throw new Error('Client Capability provider owner changed across connections'); } return provider; } @@ -1033,13 +1079,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly tool: FrozenToolBinding; } { const candidates = this.#eligibleOffersByContract().get(contractId) ?? []; - const candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); + const candidate = selectProviderCandidate(candidates, initiatingProviderId); if (!candidate) { throw new ClientCapabilityInvocationError( - candidates.length > 1 ? 'capability_ambiguous' : 'capability_lost', - candidates.length > 1 + initiatingProviderId === undefined && candidates.length > 1 + ? 'capability_ambiguous' + : 'capability_lost', + initiatingProviderId === undefined && candidates.length > 1 ? 'Multiple Client Capability providers offer this call-affine contract' : 'Client Capability provider is unavailable', ); @@ -1370,6 +1416,30 @@ function rememberOfferProxyNames(offer: FrozenOfferBinding, proxyNames: Map candidate.registration.providerId === initiatingProviderId); +} + +function clientCapabilityOwnerIdentitiesEqual( + left: ClientProviderState['capabilityOwner'], + right: ClientCapabilityConnectionIdentity['capabilityOwner'], +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.principalId === right.principalId && + left.clientInstanceId === right.clientInstanceId) + ); +} + function canonicalJson(value: unknown): string { if (value === null || typeof value === 'boolean' || typeof value === 'number') { return JSON.stringify(value);