You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/30 11:55:11 UTC
(maka) branch main updated: feat(runtime-host): bind capability providers to Client owners (#4187)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 8f313057bb feat(runtime-host): bind capability providers to Client owners (#4187)
8f313057bb is described below
commit 8f313057bbf4320cd1ad96501040921c17f6dfca
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Sun Aug 30 19:55:03 2026 +0800
feat(runtime-host): bind capability providers to Client owners (#4187)
Bind companion capability providers to Host-authenticated Client identities while preserving a single access-authority association contract.
---
.../runtime-host-operator-command.test.ts | 34 +++
packages/cli/src/cli-core.ts | 4 +
packages/cli/src/runtime-host-access-command.ts | 4 +
packages/cli/src/runtime-host-cli.ts | 22 +-
.../src/__tests__/authenticated-websocket.test.ts | 292 +++++++++++++++++++++
.../client-capability-coordinator.test.ts | 169 ++++++++++++
.../src/__tests__/fixtures/client-capability.ts | 11 +-
.../runtime-host/src/__tests__/protocol.test.ts | 33 +++
.../runtime-host/src/protocol/access-authority.ts | 85 ++++--
packages/runtime-host/src/protocol/index.ts | 5 +-
.../runtime-host/src/server/access-authority.ts | 53 +++-
.../src/server/access-credential-store.ts | 49 +++-
.../src/server/client-capability-coordinator.ts | 115 ++++++--
.../src/server/client-capability-service.ts | 4 +
.../src/server/connection-authority.ts | 25 +-
.../runtime-host/src/server/connection-session.ts | 9 +
packages/runtime-host/src/server/host-kernel.ts | 10 +
17 files changed, 874 insertions(+), 50 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 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 <name> 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 <id> Bind a provider to one Client-bound owner credential',
'',
'Runtime Host capability provider options:',
' --url <ws-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 d86bd62d9e..86e58e624f 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<string, unknown>;
+ 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<ReturnType<typeof openRuntimeHostAccessAuthority>> | 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<ReturnType<typeof connectRemoteRuntimeHost>>,
+): RuntimeHostConnection {
+ if (result.kind !== 'connected') throw new Error(`Remote Client did not connect: ${result.kind}`);
+ return result.connection;
+}
+
async function waitForCondition(condition: () => Promise<boolean>): Promise<void> {
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<void> {
+ 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<AccessCredentialIssueInput, 'capabilityOwnerCredentialId'> {
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<AccessCredentialRotationPrepareResult> {
@@ -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<AccessCredentialIssueResult> {
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<AccessCredentialRevokeResult> {
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<string>();
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<string, CapabilityRegistration>;
@@ -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<stri
}
}
+function selectProviderCandidate(
+ candidates: readonly SelectedOfferBinding[],
+ initiatingProviderId: string | undefined,
+): SelectedOfferBinding | undefined {
+ return initiatingProviderId === undefined
+ ? candidates.length === 1
+ ? candidates[0]
+ : undefined
+ : candidates.find((candidate) => 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,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org