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