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 9179097b5b..47f058d8fb 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 cedb82178b..1ed4c38329 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', @@ -631,6 +632,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 35c450738b..6864c3ac9e 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -63,6 +63,7 @@ export interface RuntimeHostAccessIssueOptions { readonly operationGrants: readonly string[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; readonly preset?: RuntimeHostAccessPreset; readonly bindClientInstance?: boolean; } @@ -228,6 +229,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 f05a3b0285..96bdee5300 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -254,6 +254,7 @@ export type RuntimeHostCliCommand = operationGrants: string[]; canPublishClientCapabilities: boolean; canUseHostPaths: boolean; + capabilityOwnerCredentialId?: string; preset?: 'desktop-client' | 'terminal-client'; } | { @@ -1994,6 +1995,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; @@ -2022,6 +2024,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); @@ -2044,6 +2047,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; @@ -2062,6 +2066,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canUseHostPaths || preset || credentialId || + capabilityOwnerCredentialId || currentCredentialFingerprint ) { return error('Credential mutation options are not valid for access list'); @@ -2086,7 +2091,8 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canPublishClientCapabilities || canUseHostPaths || preset || - credentialId + credentialId || + capabilityOwnerCredentialId ) { return error('Credential issue options are not valid for access prepare'); } @@ -2113,6 +2119,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 } : {}), @@ -2136,8 +2145,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', @@ -2148,8 +2160,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 99986ea8b2..f62cb2fb60 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -656,6 +656,291 @@ 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, + 4, + ); + 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: 3 })}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + openRuntimeHostAccessAuthority(directory), + /Pre-association Runtime Host access files cannot declare capability owners/u, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('an unbound WebSocket credential cannot claim an existing bound Client identity', { + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-bound-client-websocket-')); + const root = join(base, 'root'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const host = await startExecutionRuntimeHostService({ + rootPath: root, + websocket: { host: '127.0.0.1', port: 0 }, + }); + let local: RuntimeHostConnection | undefined; + let owner: RuntimeHostConnection | undefined; + let provider: RuntimeHostConnection | undefined; + try { + local = requireConnection(await connectRuntimeHost({ rootPath: root, protocol: PROTOCOL })); + const ownerCandidate = await local.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: 'shared-owner', + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + const ownerCredential = await consumeAccessCredentialDelivery( + root, + ownerCandidate.deliveryId, + ownerCandidate.credentialId, + ); + const url = host.websocketEndpoints[0]!; + const pairing = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: ownerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + ); + assert.deepEqual(await pairing.request('access.credential.finalize', {}), { + reconnectRequired: true, + }); + await pairing.close(); + owner = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: ownerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + ); + const unbound = await local.request('access.credential.issue', { + principalKind: 'remote_owner', + principalId: 'shared-owner', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + const unboundCredential = await consumeAccessCredentialDelivery( + root, + unbound.deliveryId, + unbound.credentialId, + ); + const providerIssue = await local.request('access.credential.issue', { + principalKind: 'capability_provider', + principalId: 'shared-owner-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: ownerCandidate.credentialId, + }); + const providerCredential = await consumeAccessCredentialDelivery( + root, + providerIssue.deliveryId, + providerIssue.credentialId, + ); + provider = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: providerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'provider-a', + protocol: PROTOCOL, + }), + ); + await provider.replaceClientCapabilities({ + offers: () => [ + { + offerId: 'bound-provider', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Bound provider', + tools: [ + { + serverId: 'bound-provider', + name: 'echo', + inputSchema: { type: 'object' }, + }, + ], + }, + ], + call: async () => ({ content: [{ type: 'text', text: 'bound' }] }), + }); + + assert.deepEqual( + await connectRemoteRuntimeHost({ + url, + credential: unboundCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + { kind: 'unavailable', reason: 'handshake_failed' }, + ); + } finally { + await Promise.allSettled([provider?.close(), owner?.close(), local?.close()]); + await host.close().catch(() => undefined); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await rm(base, { recursive: true, force: true }); + } +}); + +test('writes the capability-owner schema only after the association commits', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-owner-schema-')); + let rejectAssociation = false; + let authority: Awaited> | undefined; + try { + authority = await openRuntimeHostAccessAuthority(directory, { + writeFile: async (path, file) => { + if (rejectAssociation && file.schemaVersion === 4) { + throw new Error('association write rejected'); + } + await writeAccessCredentialFile(path, file); + }, + }); + const owner = await authority.prepare({ + principalKind: 'remote_owner', + principalId: 'schema-owner', + operationGrants: ['access.credential.finalize'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + await authority.finalize(owner.credentialId, 'schema-client'); + const path = join(directory, 'runtime-host-access.json'); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 3); + + const providerInput = { + principalKind: 'capability_provider' as const, + principalId: 'schema-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'] as const, + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: owner.credentialId, + }; + rejectAssociation = true; + await assert.rejects(authority.issue(providerInput), /association write rejected/u); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 3); + + rejectAssociation = false; + await authority.issue(providerInput); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 4); + } finally { + await authority?.close(); + 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); @@ -1213,6 +1498,13 @@ function requireConnection( return result.connection; } +function requireRemoteConnection( + result: Awaited>, +): RuntimeHostConnection { + if (result.kind !== 'connected') throw new Error(`Remote Client did not connect: ${result.kind}`); + return result.connection; +} + async function waitForCondition(condition: () => Promise): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (await condition()) return; 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..ddb155ab6d 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,158 @@ 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('does not match a companion from a hello-only Client identity', async () => { + const coordinator = createCoordinator(); + const provider = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'associated-provider', + 'provider-client', + 'provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'associated-provider', + 'associated-registration', + 'inspect', + ); + const unboundOwner = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'unbound-owner', + 'owner-client', + 'owner-principal', + 'remote_owner', + undefined, + false, + ), + { send: async () => {} }, + ); + + assert.deepEqual(await coordinator.bindSession('unbound-owner', 'unbound-owner'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('unbound-owner'), undefined); + + await Promise.all([provider.close(), unboundOwner.close()]); + await coordinator.close(); + }); + test('previews the initiating provider without persisting a Session binding', async () => { const coordinator = createCoordinator(); const connection = coordinator.attachConnection( @@ -1190,6 +1342,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..6bdd854295 100644 --- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts +++ b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts @@ -24,6 +24,15 @@ export function clientCapabilityConnectionIdentity( clientInstanceId = connectionId, principalId = 'test-principal', principalKind: ClientCapabilityConnectionIdentity['principalKind'] = 'local_owner', + capabilityOwner?: ClientCapabilityConnectionIdentity['capabilityOwner'], + credentialBound = principalKind === 'remote_owner', ): ClientCapabilityConnectionIdentity { - return { connectionId, principalId, clientInstanceId, principalKind }; + return { + connectionId, + principalId, + clientInstanceId, + ...(credentialBound ? { credentialBoundClientInstanceId: clientInstanceId } : {}), + principalKind, + ...(capabilityOwner ? { capabilityOwner } : {}), + }; } 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 223b7fa668..7c07e49552 100644 --- a/packages/runtime-host/src/protocol/access-authority.ts +++ b/packages/runtime-host/src/protocol/access-authority.ts @@ -39,6 +39,11 @@ export type ManagedAccessCredentialPrincipalKind = Exclude< 'session_guest' >; +export interface ClientCapabilityOwnerIdentity { + readonly principalId: string; + readonly clientInstanceId: string; +} + const ACCESS_ERRORS = [ 'host_not_ready', 'host_draining', @@ -55,6 +60,7 @@ export interface AccessCredentialIssueInput { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; } export interface AccessCredentialIssueResult { @@ -65,11 +71,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; @@ -202,15 +210,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( @@ -218,6 +239,7 @@ export function decodeAccessCredentialIssueInput(value: unknown): AccessCredenti 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}), }; } @@ -250,19 +272,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( @@ -270,6 +305,7 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwner ? { capabilityOwner } : {}), }; } @@ -384,6 +420,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 8b03815a81..62fb76a362 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,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 = 73 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 74 as const; +// 74: 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. // 73: Transcript pages carry a Host-owned Turn range boundary. Older peers // cannot preserve both the complete edge Turn and the bounded projection. // 71: Session Guests can submit durable exact Turn access requests and Owners diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index 83e5f13d8c..0d12781101 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, @@ -150,6 +151,7 @@ export interface RuntimeHostAccessAuthority { principalId: string, kind: SessionCollaborationGrantKind, ): SessionCollaborationGrant | undefined; + hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean; subscribeRevocations(listener: (credentialId: string) => void): () => void; subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void; subscribeApprovedTurnAccessRequests( @@ -229,6 +231,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; } @@ -579,6 +582,16 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { ); } + hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean { + return this.#file.credentials.some( + (credential) => + credential.status === 'active' && + credential.principalKind === 'remote_owner' && + credential.principalId === principalId && + credential.clientInstanceId === clientInstanceId, + ); + } + prepareRotation( input: AccessCredentialRotationPrepareInput, ): Promise { @@ -601,7 +614,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants: current.operationGrants, canPublishClientCapabilities: current.canPublishClientCapabilities, canUseHostPaths: current.canUseHostPaths, - bindClientInstance: current.bindClientInstanceOnFinalize === true, + bindClientInstance: current.clientInstanceId !== undefined, }, 'prepare', current.operationGrants, @@ -622,6 +635,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' || @@ -639,6 +655,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(); @@ -651,6 +668,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: input.canPublishClientCapabilities, canUseHostPaths: input.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt: createdAt.toISOString(), ...(mode === 'prepare' ? { @@ -700,9 +718,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); @@ -1056,7 +1098,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 292cede830..77da3af44a 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -22,6 +22,7 @@ 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 SessionCollaborationGrant, @@ -30,7 +31,11 @@ import { type OperationKey, } from '../protocol/index.js'; -const ACCESS_FILE_SCHEMA_VERSION = 3; +// Schema 4 makes provider ownership downgrade-safe once an association exists. +// Ordinary access files remain schema 3 so this feature does not fence a +// downgrade before there is an owner association to preserve. +const ACCESS_FILE_SCHEMA_VERSION = 4; +const PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION = 3; const ACCESS_FILE_MAX_BYTES = 512 * 1024; const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query'; const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ @@ -80,6 +85,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; @@ -88,7 +94,9 @@ export interface StoredAccessCredential { } export interface AccessCredentialFile { - readonly schemaVersion: typeof ACCESS_FILE_SCHEMA_VERSION; + readonly schemaVersion: + | typeof PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION + | typeof ACCESS_FILE_SCHEMA_VERSION; readonly credentials: readonly StoredAccessCredential[]; readonly sessionGrants: readonly SessionCollaborationGrant[]; readonly turnAccessRequests: readonly SessionTurnAccessRequest[]; @@ -121,7 +129,9 @@ export function createAccessCredentialFile( turnAccessRequests: readonly SessionTurnAccessRequest[] = [], ): AccessCredentialFile { return { - schemaVersion: ACCESS_FILE_SCHEMA_VERSION, + schemaVersion: credentials.some((credential) => credential.capabilityOwner !== undefined) + ? ACCESS_FILE_SCHEMA_VERSION + : PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION, credentials, sessionGrants, turnAccessRequests, @@ -225,12 +235,21 @@ function serializeAccessCredentialFile(file: AccessCredentialFile): string { function decodeAccessFile(value: unknown): AccessCredentialFile { if ( !isRecord(value) || - (value.schemaVersion !== 1 && value.schemaVersion !== 2 && value.schemaVersion !== 3) + (value.schemaVersion !== 1 && + value.schemaVersion !== 2 && + value.schemaVersion !== 3 && + value.schemaVersion !== 4) ) { 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 < ACCESS_FILE_SCHEMA_VERSION && + credentials.some((credential) => credential.capabilityOwner !== undefined) + ) { + throw new Error('Pre-association Runtime Host access files cannot declare capability owners'); + } if ( new Set(credentials.map((credential) => credential.credentialId)).size !== credentials.length ) { @@ -349,6 +368,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))) { @@ -370,6 +393,7 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { operationGrants, canPublishClientCapabilities: value.canPublishClientCapabilities, canUseHostPaths: value.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt, ...(bindClientInstanceOnFinalize === true ? { bindClientInstanceOnFinalize } : {}), ...(typeof clientInstanceId === 'string' ? { clientInstanceId } : {}), @@ -399,6 +423,23 @@ function decodeStoredSessionGrant(value: unknown): SessionCollaborationGrant { }); } +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-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 1e54236d92..f95b8d8c64 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,10 @@ interface ClientProviderState { readonly providerId: string; readonly principalId: string; readonly clientInstanceId: string; + readonly credentialBoundClientInstanceId?: string; + readonly principalKind: ClientCapabilityConnectionIdentity['principalKind']; readonly trustedProvider: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; activeConnectionId?: string; current?: CapabilityRegistration; readonly registrations: Map; @@ -333,14 +337,43 @@ 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 && + initiatingProvider.credentialBoundClientInstanceId === initiatingProvider.clientInstanceId + ? [...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 +421,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 +467,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 +539,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 +1022,36 @@ 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, + ...(identity.credentialBoundClientInstanceId + ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } + : {}), + 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 || + provider.credentialBoundClientInstanceId !== identity.credentialBoundClientInstanceId + ) { 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 +1086,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 +1423,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); diff --git a/packages/runtime-host/src/server/client-capability-service.ts b/packages/runtime-host/src/server/client-capability-service.ts index 58ea30ec09..2c8ca26568 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 { @@ -31,7 +32,10 @@ export interface ClientCapabilityConnectionIdentity { readonly connectionId: string; readonly principalId: string; readonly clientInstanceId: string; + /** Present only when the access credential, rather than hello, authenticated this Client ID. */ + readonly credentialBoundClientInstanceId?: 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 8e7f83a1bf..359a81908e 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 7e800e1755..d571557670 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -296,7 +296,16 @@ export class RuntimeHostConnectionSession { connectionId: this.#options.connection.connectionId, principalId: this.#options.connection.authority.principalId, clientInstanceId: this.#options.connection.clientInstanceId, + ...(this.#options.connection.authority.clientInstanceId + ? { + credentialBoundClientInstanceId: + this.#options.connection.authority.clientInstanceId, + } + : {}), principalKind: this.#options.connection.authority.principalKind, + ...(this.#options.connection.authority.capabilityOwner + ? { capabilityOwner: this.#options.connection.authority.capabilityOwner } + : {}), }, { send: (frame) => { diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 4ab9603da7..ae628e18eb 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -480,6 +480,16 @@ export class RuntimeHostKernel { if (authority.clientInstanceId && authority.clientInstanceId !== hello.clientInstanceId) { throw new Error('Runtime Host access credential belongs to another Client'); } + if ( + authority.principalKind === 'remote_owner' && + authority.clientInstanceId === undefined && + this.#options.accessAuthority?.hasActiveBoundClientIdentity( + authority.principalId, + hello.clientInstanceId, + ) + ) { + throw new Error('Runtime Host Client identity is bound to another access credential'); + } const selectedProtocol = negotiateProtocol( { min: hello.protocolMin, max: hello.protocolMax }, HOST_PROTOCOL,