You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/29 04:55:43 UTC

(maka) branch feat/runtime-host-peer-relay-discovery updated (5d170ee2c -> 61c56bd4a)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a change to branch feat/runtime-host-peer-relay-discovery
in repository https://gitbox.apache.org/repos/asf/maka.git


    omit 5d170ee2c feat(runtime-host): discover coordination relays automatically
     add d144511e0 feat(inspector): collapse loaded history (#4120)
     add da7a3a82b docs(security): align sandbox boundary claims (#4065)
     add 17aae7c57 fix(desktop): keep session workspace action identities fixed (#4110)
     add ed615013e fix(runtime-host): align enabled model limit (#4124)
     new 61c56bd4a feat(runtime-host): discover coordination relays automatically

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (5d170ee2c)
            \
             N -- N -- N   refs/heads/feat/runtime-host-peer-relay-discovery (61c56bd4a)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 SECURITY.md                                        |  54 +++-
 apps/desktop/e2e/fixtures.ts                       |  37 +++
 .../e2e/session-rail-render-contract.spec.ts       | 180 ++++++++++++
 .../main/__tests__/runtime-host-management.test.ts |  17 +-
 .../__tests__/runtime-host-ssh-terminal.test.ts    |  40 +++
 .../session-workspace-action-identity.test.ts      |  90 ++++++
 .../src/main/__tests__/use-session-trace.test.ts   |  39 +++
 apps/desktop/src/main/runtime-host-management.ts   |  10 +-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |   1 +
 .../desktop/src/renderer/app-shell-chat-actions.ts |   2 +-
 .../src/renderer/app-shell-revision-actions.ts     |   4 +-
 .../desktop/src/renderer/app-shell-turn-actions.ts |   2 +-
 .../tools/inspector/session-inspector-panel.tsx    |  12 +-
 .../workbar/tools/inspector/use-session-trace.ts   |  31 ++-
 .../src/renderer/locales/conversation-copy.ts      |   3 +
 .../src/renderer/locales/settings-projects-copy.ts |   9 +-
 ...n-workspace.ts => session-workspace-actions.ts} | 175 ++++++------
 .../settings/runtime-host-management-dialog.tsx    |  41 ++-
 .../src/renderer/styles/settings/runtime-host.css  |  19 +-
 .../src/renderer/use-app-shell-session-list.ts     |  37 ++-
 .../renderer/use-app-shell-session-workspace.ts    | 191 ++-----------
 docs/astryx-surface-file-inventory.md              |   2 +-
 native/runtime-host-peer/src/engine.rs             | 304 ++++++++++++++++-----
 .../src/engine/relay_discovery.rs                  | 271 ++++++++++++++----
 .../__tests__/runtime-host-service-manager.test.ts |  11 +
 packages/cli/src/cli-core.ts                       |   4 +
 packages/cli/src/runtime-host-cli.ts               |  10 +
 .../src/runtime-host-peer-management-command.ts    |  11 +-
 .../src/runtime-host-service-management-command.ts |   2 +
 packages/core/src/__tests__/relative-time.test.ts  |  39 +++
 packages/core/src/relative-time.ts                 |  46 ++--
 .../__tests__/execution-model-composition.test.ts  | 148 +++++++++-
 .../runtime-host/src/__tests__/protocol.test.ts    |  30 ++
 packages/runtime-host/src/operator/index.ts        |   1 +
 .../src/operator/service-management-frame.ts       |   2 +
 packages/runtime-host/src/protocol/index.ts        |   4 +-
 .../runtime-host/src/protocol/runtime-policy.ts    |   3 +-
 packages/runtime/src/sandbox/README.md             |  22 +-
 packages/ui/src/chat-display-helpers.ts            |  20 +-
 packages/ui/src/chat-turn.tsx                      |   7 +-
 packages/ui/src/relative-time.tsx                  |   2 +-
 packages/ui/src/session-history-list.tsx           |   2 +-
 42 files changed, 1442 insertions(+), 493 deletions(-)
 create mode 100644 apps/desktop/e2e/session-rail-render-contract.spec.ts
 create mode 100644 apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts
 copy apps/desktop/src/renderer/{use-app-shell-session-workspace.ts => session-workspace-actions.ts} (58%)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 01/01: feat(runtime-host): discover coordination relays automatically

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-peer-relay-discovery
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 61c56bd4a52b2c29c74e0347d3bfd368b99c2a86
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Aug 29 11:02:17 2026 +0800

    feat(runtime-host): discover coordination relays automatically
    
    Use a bounded IPFS Amino discovery client to supply public Circuit Relay v2 candidates while the main peer endpoint retains reservation authority. Preserve manual relay precedence and publish only accepted reservations.
    
    Generated-by: OpenAI Codex
---
 .../main/__tests__/runtime-host-management.test.ts |  17 +-
 .../__tests__/runtime-host-ssh-terminal.test.ts    |  40 ++
 apps/desktop/src/main/runtime-host-management.ts   |  27 +-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |   7 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |   2 +
 .../src/renderer/locales/settings-projects-copy.ts |  19 +-
 .../settings/runtime-host-management-dialog.tsx    |  58 +-
 .../src/renderer/styles/settings/runtime-host.css  |  30 +
 docs/astryx-surface-file-inventory.md              |   2 +-
 docs/runtime-host-remote-access.md                 |  11 +-
 docs/runtime-host-remote-access.zh-CN.md           |   8 +-
 native/runtime-host-peer/Cargo.lock                |  61 ++
 native/runtime-host-peer/Cargo.toml                |   2 +
 native/runtime-host-peer/src/bindings.rs           |  14 +-
 native/runtime-host-peer/src/engine.rs             | 652 ++++++++++++++++++++-
 .../src/engine/relay_discovery.rs                  | 504 ++++++++++++++++
 .../cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv    |   5 +
 .../cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt  | 118 +++-
 .../__tests__/runtime-host-service-manager.test.ts |  13 +
 packages/cli/src/cli-core.ts                       |  12 +
 packages/cli/src/runtime-host-cli.ts               |  34 +-
 .../src/runtime-host-peer-management-command.ts    |  16 +-
 packages/cli/src/runtime-host-service-command.ts   |   1 +
 .../src/runtime-host-service-management-command.ts |   2 +
 packages/cli/src/runtime-host-setup-command.ts     |   1 +
 .../src/__tests__/peer-management-frame.test.ts    |   1 +
 .../runtime-host/src/__tests__/peer-native.test.ts |   2 +
 packages/runtime-host/src/client/peer-client.ts    |   8 +-
 packages/runtime-host/src/operator/index.ts        |   1 +
 .../src/operator/managed-deployment.ts             |   1 +
 .../src/operator/peer-management-frame.ts          |   1 +
 .../src/operator/service-management-frame.ts       |   2 +
 packages/runtime-host/src/peer-mesh/owner.ts       |   4 +
 packages/runtime-host/src/server/peer-listener.ts  |   1 +
 packages/runtime-host/src/transport/peer-native.ts |   9 +
 scripts/generate-runtime-host-peer-notices.mjs     |   6 +-
 37 files changed, 1635 insertions(+), 59 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index e7d234745..5e3ee971d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -20,7 +20,7 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
-  RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   runtimeHostAccessCredentialFingerprint,
   type RuntimeHostServiceManagementFrame,
 } from '@maka/runtime-host/operator';
@@ -937,11 +937,11 @@ test('keeps the SSH profile while adding and removing its managed Direct peer',
       assert.equal(input.action, 'status');
       assert.equal(
         input.capabilityRequest,
-        RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+        RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
       );
       return {
         ...serviceResult('status'),
-        operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY],
+        operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY],
       };
     },
     runAccessManagement: async () => assert.fail('access management is not expected'),
@@ -971,9 +971,9 @@ test('keeps the SSH profile while adding and removing its managed Direct peer',
 
   const configure = handlers.get('runtime-host-management:configure-direct-peer');
   assert.ok(configure);
-  const enabled = await configure({}, profile.id, true, []);
+  const enabled = await configure({}, profile.id, true, [], true);
   assert.equal((enabled as { profilePresent: boolean }).profilePresent, true);
-  const disabled = await configure({}, profile.id, false, []);
+  const disabled = await configure({}, profile.id, false, [], true);
   assert.equal((disabled as { profilePresent: boolean }).profilePresent, false);
   assert.deepEqual(actions, ['enable', 'disable']);
 });
@@ -1005,7 +1005,7 @@ test('disables a newly enabled listener when its Desktop profile cannot be commi
       directPeerClientAvailable: true,
       runServiceManagement: async () => ({
         ...serviceResult('status'),
-        operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY],
+        operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY],
       }),
       runAccessManagement: async () => assert.fail('access management is not expected'),
       runPeerManagement: async (input) => {
@@ -1037,7 +1037,7 @@ test('disables a newly enabled listener when its Desktop profile cannot be commi
     const configure = handlers.get('runtime-host-management:configure-direct-peer');
     assert.ok(configure);
     await assert.rejects(
-      configure({}, 'office', true, []) as Promise<unknown>,
+      configure({}, 'office', true, [], true) as Promise<unknown>,
       failure === 'descriptor' ? /usable direct-peer descriptor/u : /profile store failed/u,
     );
     assert.deepEqual(actions, ['enable', 'disable']);
@@ -1077,13 +1077,14 @@ test('does not invoke peer management when the remote operator lacks its capabil
     state: 'unsupported',
     routeHints: [],
     coordinationRelays: [],
+    automaticRelayDiscovery: false,
     profilePresent: false,
     profileEnabled: false,
     clientAvailable: true,
     managementAvailable: false,
   });
   await assert.rejects(
-    configure({}, 'office', true, []) as Promise<unknown>,
+    configure({}, 'office', true, [], true) as Promise<unknown>,
     /Update this Runtime Host/u,
   );
 });
diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
index a08f663a0..614c6db12 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
@@ -27,6 +27,7 @@ import { type RuntimeHostSshProcessFactory } from '@maka/runtime-host/client';
 import {
   encodeRuntimeHostActivationFrame,
   encodeRuntimeHostAccessManagementFrame,
+  encodeRuntimeHostPeerManagementFrame,
   encodeRuntimeHostServiceManagementFrame,
   encodeRuntimeHostSetupFrame,
   encodeRuntimeHostPeerMeshManagementFrame,
@@ -638,6 +639,45 @@ test('keeps a prepared access credential out of the SSH terminal projection', as
   await harness.terminal.close();
 });
 
+test('requests relay-discovery status only on the peer-management frame', async () => {
+  const harness = createHarness('pending');
+  const management = harness.terminal.runPeerManagement({
+    destination: 'operator@example.com',
+    operatorPath: '/home/operator/.local/share/maka/operator',
+    action: 'status',
+    expectedTarget: {
+      serviceId: 'b'.repeat(64),
+      rootPath: '/srv/maka',
+      rootId: 'a'.repeat(64),
+      deploymentId: '00000000-0000-4000-8000-000000000001',
+    },
+  });
+  await waitFor(() => harness.pty.hasDataListener());
+  const command = harness.launchArgs.at(-1)?.at(-1) ?? '';
+  assert.match(command, /peer.*status.*--framed.*--relay-discovery-status/u);
+
+  harness.pty.emitData(
+    encodeRuntimeHostPeerManagementFrame({
+      kind: 'result',
+      action: 'status',
+      status: {
+        state: 'enabled',
+        serviceState: 'running',
+        peerId: '12D3KooWpeer',
+        rootId: 'a'.repeat(64),
+        routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+        coordinationRelays: [],
+        automaticRelayDiscovery: true,
+      },
+    }),
+  );
+  harness.pty.exit(0);
+
+  const result = await management;
+  assert.equal(result.kind === 'result' && result.status.automaticRelayDiscovery, true);
+  await harness.terminal.close();
+});
+
 test('sends a Mesh invitation only after the authenticated remote operator requests it', async () => {
   const harness = createHarness('pending');
   const invitation = JSON.stringify({ secret: 'one-time-mesh-secret' });
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index c1e82c737..8b0099619 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -20,7 +20,7 @@
 import type { IpcMain } from 'electron';
 import {
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
-  RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   isProductReleaseVersion,
   runtimeHostAccessCredentialFingerprint,
   type RuntimeHostManagedUpdatePolicy,
@@ -317,6 +317,7 @@ export function createDesktopRuntimeHostManagement(input: {
       ...(status.peerId ? { peerId: status.peerId } : {}),
       routeHints: status.routeHints,
       coordinationRelays: status.coordinationRelays,
+      automaticRelayDiscovery: status.automaticRelayDiscovery ?? false,
       profilePresent: profile.exists,
       profileEnabled: profile.enabled,
       clientAvailable: input.directPeerClientAvailable,
@@ -334,7 +335,7 @@ export function createDesktopRuntimeHostManagement(input: {
       operatorPath: target.managed.control.operatorPath,
       action: 'status',
       expectedTarget: target.expectedTarget,
-      capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+      capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
     });
     if (capability.kind === 'error') throw new Error(capability.error.message);
     if (capability.action !== 'status') {
@@ -343,7 +344,7 @@ export function createDesktopRuntimeHostManagement(input: {
     return {
       ...target,
       available: capability.operatorCapabilities?.includes(
-        RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+        RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
       ) === true,
     };
   };
@@ -356,6 +357,7 @@ export function createDesktopRuntimeHostManagement(input: {
       state: 'unsupported',
       routeHints: [],
       coordinationRelays: [],
+      automaticRelayDiscovery: false,
       profilePresent: profile.exists,
       profileEnabled: profile.enabled,
       clientAvailable: input.directPeerClientAvailable,
@@ -390,11 +392,15 @@ export function createDesktopRuntimeHostManagement(input: {
     profileIdValue: unknown,
     enabledValue: unknown,
     coordinationRelaysValue: unknown,
+    automaticRelayDiscoveryValue: unknown,
   ): Promise<DesktopRuntimeHostDirectPeerSnapshot> => {
     if (typeof enabledValue !== 'boolean') {
       throw new Error('Runtime Host direct-peer state is invalid');
     }
     const coordinationRelays = requireCoordinationRelays(coordinationRelaysValue);
+    if (typeof automaticRelayDiscoveryValue !== 'boolean') {
+      throw new Error('Runtime Host relay discovery state is invalid');
+    }
     const { profileId, managed, transport, expectedTarget, available } =
       await peerManagementTarget(profileIdValue);
     if (!available) {
@@ -410,6 +416,7 @@ export function createDesktopRuntimeHostManagement(input: {
       operatorPath: managed.control.operatorPath,
       action: enabledValue ? 'enable' : 'disable',
       ...(enabledValue ? { coordinationRelays } : {}),
+      ...(enabledValue ? { automaticRelayDiscovery: automaticRelayDiscoveryValue } : {}),
       expectedTarget,
     });
     if (response.kind !== 'result') {
@@ -829,8 +836,18 @@ export function createDesktopRuntimeHostManagement(input: {
     getDirectPeer(profileId));
   input.ipcMain.handle(
     channels.configureDirectPeer,
-    (_event, profileId: unknown, enabled: unknown, coordinationRelays: unknown) =>
-      configureDirectPeer(profileId, enabled, coordinationRelays),
+    (
+      _event,
+      profileId: unknown,
+      enabled: unknown,
+      coordinationRelays: unknown,
+      automaticRelayDiscovery: unknown,
+    ) => configureDirectPeer(
+      profileId,
+      enabled,
+      coordinationRelays,
+      automaticRelayDiscovery,
+    ),
   );
 
   return {
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index daa5b2875..ed1c21686 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -174,6 +174,7 @@ export interface DesktopRuntimeHostSshPeerManagementInput {
   readonly operatorPath: string;
   readonly action: Extract<RuntimeHostPeerManagementAction, 'enable' | 'disable' | 'status'>;
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
   readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget'];
   readonly signal?: AbortSignal;
 }
@@ -1343,11 +1344,17 @@ function runtimeHostPeerManagementRemoteCommand(
     'peer',
     input.action,
     '--framed',
+    '--relay-discovery-status',
     ...(input.action === 'enable' && input.coordinationRelays
       ? input.coordinationRelays.length === 0
         ? ['--clear-coordination-relays']
         : input.coordinationRelays.flatMap((relay) => ['--coordination-relay', relay])
       : []),
+    ...(input.action === 'enable' && input.automaticRelayDiscovery !== undefined
+      ? [input.automaticRelayDiscovery
+          ? '--automatic-relay-discovery'
+          : '--no-automatic-relay-discovery']
+      : []),
     ...managedServiceTargetArgs(input.expectedTarget),
   ].map(quotePosix).join(' ');
   return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`;
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index e5f9f1e5f..85c431907 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -514,6 +514,7 @@ export interface DesktopRuntimeHostDirectPeerSnapshot {
   readonly peerId?: string;
   readonly routeHints: readonly string[];
   readonly coordinationRelays: readonly string[];
+  readonly automaticRelayDiscovery: boolean;
   readonly profilePresent: boolean;
   readonly profileEnabled: boolean;
   readonly clientAvailable: boolean;
@@ -731,6 +732,7 @@ export interface MakaBridge {
       profileId: string,
       enabled: boolean,
       coordinationRelays: readonly string[],
+      automaticRelayDiscovery: boolean,
     ): Promise<DesktopRuntimeHostDirectPeerSnapshot>;
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
     rotateCredential(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 63c734436..c21d120bf 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1349,12 +1349,14 @@ const makaBridge = {
       profileId: string,
       enabled: boolean,
       coordinationRelays: readonly string[],
+      automaticRelayDiscovery: boolean,
     ) {
       return ipcRenderer.invoke(
         'runtime-host-management:configure-direct-peer',
         profileId,
         enabled,
         coordinationRelays,
+        automaticRelayDiscovery,
       );
     },
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot> {
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 84ed7af67..93b2e0043 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -134,6 +134,9 @@ export type SettingsProjectsCopy = {
     directPeerRoutes: string;
     directPeerCoordinationRelays: string;
     directPeerCoordinationRelaysPlaceholder: string;
+    directPeerAdvancedCoordination: string;
+    directPeerAutomaticRelayDiscovery: string;
+    directPeerAutomaticRelayDiscoveryHelp: string;
     directPeerEnable: string;
     directPeerDisable: string;
     directPeerAddProfile: string;
@@ -298,7 +301,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       useConnectionCode: '使用连接码',
       configureManually: '手动配置',
       thisComputerRemoteAccess: '远程访问',
-      thisComputerRemoteAccessHelp: '让其他 Maka Desktop 通过实验性 Direct peer 连接此 Host',
+      thisComputerRemoteAccessHelp: '通过实验性端到端直连访问此 Host;可自动发现公共协调节点来辅助打洞',
       remoteAccessOn: '已开启',
       remoteAccessOff: '未开启',
       enableRemoteAccess: '开启',
@@ -407,7 +410,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
         failed: '启动失败',
       },
       directPeer: 'Direct peer(实验性)',
-      directPeerDescription: '创建独立的实验性 Direct profile。受限 NAT 或被阻止的 UDP 可能使其不可达,且不会自动回退;保留 SSH profile 用于手动恢复。',
+      directPeerDescription: '创建独立的实验性 Direct profile。可自动发现或手动指定协调节点来辅助打洞;受限 NAT 或被阻止的 UDP 仍可能使其不可达,且不会回退到中继传输。保留 SSH profile 用于手动恢复。',
       directPeerState: {
         unsupported: '需要更新',
         not_configured: '未配置',
@@ -423,6 +426,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       directPeerRoutes: '可用路径',
       directPeerCoordinationRelays: '连接协调节点(可选)',
       directPeerCoordinationRelaysPlaceholder: '多个地址用逗号分隔',
+      directPeerAdvancedCoordination: '手动设置协调节点',
+      directPeerAutomaticRelayDiscovery: '自动发现协调节点',
+      directPeerAutomaticRelayDiscoveryHelp:
+        '协调节点使用 Circuit Relay v2 协议,仅帮助建立端到端直连,不承载应用流量。Maka 会通过公共 IPFS 网络尽力发现可用节点;手动设置的节点优先。',
       directPeerEnable: '启用并添加',
       directPeerDisable: '停用',
       directPeerAddProfile: '添加到 Desktop',
@@ -587,7 +594,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       useConnectionCode: 'Use connection code',
       configureManually: 'Configure manually',
       thisComputerRemoteAccess: 'Remote access',
-      thisComputerRemoteAccessHelp: 'Let another Maka Desktop reach this Host through experimental Direct peer',
+      thisComputerRemoteAccessHelp: 'Reach this Host through experimental end-to-end direct connections, with automatic public coordination discovery',
       remoteAccessOn: 'On',
       remoteAccessOff: 'Off',
       enableRemoteAccess: 'Enable',
@@ -696,7 +703,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
         failed: 'Failed',
       },
       directPeer: 'Direct peer (experimental)',
-      directPeerDescription: 'Create an independent experimental Direct profile. Restrictive NAT or blocked UDP may make it unreachable, and it does not fall back automatically; keep the SSH profile for manual recovery.',
+      directPeerDescription: 'Create an independent experimental Direct profile. Discover coordination peers automatically or provide them manually to assist hole punching; restrictive NAT or blocked UDP may still make it unreachable, and traffic does not fall back to a relay. Keep the SSH profile for manual recovery.',
       directPeerState: {
         unsupported: 'Update required',
         not_configured: 'Not configured',
@@ -712,6 +719,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       directPeerRoutes: 'Routes',
       directPeerCoordinationRelays: 'Connection coordination peers (optional)',
       directPeerCoordinationRelaysPlaceholder: 'Separate multiple addresses with commas',
+      directPeerAdvancedCoordination: 'Set coordination peers manually',
+      directPeerAutomaticRelayDiscovery: 'Discover coordination peers automatically',
+      directPeerAutomaticRelayDiscoveryHelp:
+        'Coordination peers use Circuit Relay v2 only to establish an end-to-end direct connection; they never carry application traffic. Maka discovers candidates through the public IPFS network on a best-effort basis, while manually configured peers remain preferred.',
       directPeerEnable: 'Enable and add',
       directPeerDisable: 'Disable',
       directPeerAddProfile: 'Add to Desktop',
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
index 951d391be..ffa18efe5 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -21,10 +21,13 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
 import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
 import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
 import { Text } from '@astryxdesign/core/Text';
+import { Switch } from '@astryxdesign/core/Switch';
+import { Tooltip } from '@astryxdesign/core/Tooltip';
 import {
   Badge,
   Banner,
   Button,
+  IconButton,
   MoreMenu,
   Selector,
   Spinner,
@@ -32,6 +35,7 @@ import {
   useToast,
   useUiLocale,
 } from '@maka/ui';
+import { HelpCircle, ICON_SIZE } from '@maka/ui/icons';
 import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale';
 import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client';
 import type {
@@ -101,6 +105,7 @@ export function RuntimeHostManagementDialog(props: {
   const [directPeer, setDirectPeer] = useState<DesktopRuntimeHostDirectPeerSnapshot>();
   const [directPeerError, setDirectPeerError] = useState<string>();
   const [coordinationRelays, setCoordinationRelays] = useState('');
+  const [automaticRelayDiscovery, setAutomaticRelayDiscovery] = useState(true);
   const nextDirectoryRootId = useRef(1);
   const logsRef = useRef<HTMLPreElement>(null);
 
@@ -213,6 +218,7 @@ export function RuntimeHostManagementDialog(props: {
   function applyDirectPeer(snapshot: DesktopRuntimeHostDirectPeerSnapshot): void {
     setDirectPeer(snapshot);
     setCoordinationRelays(snapshot.coordinationRelays.join(', '));
+    setAutomaticRelayDiscovery(snapshot.automaticRelayDiscovery);
     setDirectPeerError(undefined);
   }
 
@@ -243,6 +249,7 @@ export function RuntimeHostManagementDialog(props: {
           profile.id,
           enabled,
           relays,
+          automaticRelayDiscovery,
         ),
       );
     } catch (failure) {
@@ -730,17 +737,46 @@ export function RuntimeHostManagementDialog(props: {
                               />
                             </dl>
                           ) : null}
-                          <TextInput
-                            label={copy.directPeerCoordinationRelays}
-                            value={coordinationRelays}
-                            placeholder={copy.directPeerCoordinationRelaysPlaceholder}
-                            isDisabled={
-                              loading ||
-                              directPeer.profileEnabled ||
-                              directPeer.state === 'enabled'
-                            }
-                            onChange={setCoordinationRelays}
-                          />
+                          <div className="settingsRuntimeHostDirectPeerDiscovery">
+                            <div className="settingsRuntimeHostDirectPeerDiscoveryLabel">
+                              <Text type="body" weight="semibold">
+                                {copy.directPeerAutomaticRelayDiscovery}
+                              </Text>
+                              <Tooltip content={copy.directPeerAutomaticRelayDiscoveryHelp}>
+                                <IconButton
+                                  label={copy.directPeerAutomaticRelayDiscoveryHelp}
+                                  icon={<HelpCircle size={ICON_SIZE.control} aria-hidden="true" />}
+                                  variant="ghost"
+                                  size="sm"
+                                />
+                              </Tooltip>
+                            </div>
+                            <Switch
+                              label={copy.directPeerAutomaticRelayDiscovery}
+                              isLabelHidden
+                              value={automaticRelayDiscovery}
+                              isDisabled={
+                                loading ||
+                                directPeer.profileEnabled ||
+                                directPeer.state === 'enabled'
+                              }
+                              onChange={setAutomaticRelayDiscovery}
+                            />
+                          </div>
+                          <details className="settingsRuntimeHostDirectPeerAdvanced">
+                            <summary>{copy.directPeerAdvancedCoordination}</summary>
+                            <TextInput
+                              label={copy.directPeerCoordinationRelays}
+                              value={coordinationRelays}
+                              placeholder={copy.directPeerCoordinationRelaysPlaceholder}
+                              isDisabled={
+                                loading ||
+                                directPeer.profileEnabled ||
+                                directPeer.state === 'enabled'
+                              }
+                              onChange={setCoordinationRelays}
+                            />
+                          </details>
                           <div className="settingsRuntimeHostUpdatePolicyActions">
                             <Button
                               variant="secondary"
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index 4425f94df..95b3cebcb 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -177,6 +177,36 @@
   justify-content: flex-end;
 }
 
+.settingsRuntimeHostDirectPeerDiscovery {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+}
+
+.settingsRuntimeHostDirectPeerDiscovery > div {
+  display: flex;
+  align-items: center;
+  gap: var(--space-1);
+  min-width: 0;
+}
+
+.settingsRuntimeHostDirectPeerAdvanced {
+  display: grid;
+  gap: var(--space-2);
+}
+
+.settingsRuntimeHostDirectPeerAdvanced summary {
+  width: fit-content;
+  color: var(--foreground-secondary);
+  cursor: pointer;
+  font: var(--maka-text-supporting);
+}
+
+.settingsRuntimeHostDirectPeerAdvanced[open] summary {
+  margin-bottom: var(--space-2);
+}
+
 .settingsRuntimeHostManagementLogs {
   overflow-wrap: anywhere;
   color: var(--foreground-secondary);
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index d13143bab..8be11cb33 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -102,7 +102,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/settings/request-customization-editor.tsx` | settings-module | Button, HStack, IconButton, Text, VStack | aligned — uses Astryx (Button, HStack, IconButton, Text, VStack) | aligned |
 | `apps/desktop/src/renderer/settings/runtime-host-connection-code-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, Layout, LayoutContent | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent) | aligned |
 | `apps/desktop/src/renderer/settings/runtime-host-interaction-boundary.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Selector) | aligned |
+| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, IconButton, Layout, LayoutContent, Selector, Spinner, Switch, Text, Tooltip | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, IconButton, Layout, LayoutContent) | aligned |
 | `apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, SegmentedControl, SegmentedControlItem, Selector, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, SegmentedControl, SegmentedControlItem) | aligned |
 | `apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent) | aligned |
 | `apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx` | settings-module | Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, Switch | aligned — uses Astryx (Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem) | aligned |
diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index c98fe30ff..a7648a5d8 100644
--- a/docs/runtime-host-remote-access.md
+++ b/docs/runtime-host-remote-access.md
@@ -131,8 +131,15 @@ service uninstall removes its key while retaining the State Root. Pass
 `peer enable --clear-coordination-relays` to remove every configured coordination relay.
 
 This direct-only path is experimental and may fail on restrictive NAT or UDP-blocked networks. It
-does not replace an existing TLS, SSH, or overlay-network fallback and does not use a public relay
-unless one is explicitly configured with `peer enable --coordination-relay`.
+does not replace an existing TLS, SSH, or overlay-network fallback. By default, the Host uses a
+bounded client-only view of the public IPFS DHT to discover Circuit Relay v2 candidates and fills a
+target of two accepted reservations after accounting for manual relays. Manually configured relays
+remain preferred. Disable or restore this
+best-effort discovery with `peer enable --no-automatic-relay-discovery` or
+`peer enable --automatic-relay-discovery`; disabling it leaves manual relays intact. Public peers can
+observe the discovery connection and may refuse or drop reservations. Only accepted reservations
+are advertised to Mesh peers, and Maka still requires the application stream to upgrade to a direct
+connection instead of carrying Session traffic through the relay.
 
 ### Direct TLS
 
diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md
index a90aa2149..075dfd0bf 100644
--- a/docs/runtime-host-remote-access.zh-CN.md
+++ b/docs/runtime-host-remote-access.zh-CN.md
@@ -117,8 +117,12 @@ PeerId 和 listener 配置;`peer rotate` 会明确更换 PeerId;卸载 servi
 Root。执行 `peer enable --clear-coordination-relays` 可以删除所有已配置的 coordination relay。
 
 Direct-only 路径仍是实验能力,在受限 NAT 或禁用 UDP 的网络中可能失败。它不会替代已有的 TLS、SSH
-或 overlay network fallback;除非用户在 `peer enable` 时明确传入 `--coordination-relay`,否则不会使用
-公共 relay。
+或 overlay network fallback。Host 默认通过公共 IPFS DHT 的有界 client-only 视图发现 Circuit Relay v2
+候选;扣除手动 relay 后,自动池会补足两个已接受 reservation 的目标。手动配置的 relay 始终优先。使用
+`peer enable --no-automatic-relay-discovery` 可以关闭这项尽力而为的发现,使用
+`peer enable --automatic-relay-discovery` 可以重新开启;关闭不会删除手动 relay。公网 Peer 能观察到发现
+连接,也可能拒绝或中止 reservation。只有已接受的 reservation 才会向 Mesh Peer 发布,Maka 仍要求
+application stream 升级为直连,不会通过 relay 传输 Session traffic。
 
 ### Direct TLS
 
diff --git a/native/runtime-host-peer/Cargo.lock b/native/runtime-host-peer/Cargo.lock
index 12bcd90ac..ca3fffa36 100644
--- a/native/runtime-host-peer/Cargo.lock
+++ b/native/runtime-host-peer/Cargo.lock
@@ -88,6 +88,12 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "asn1_der"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156"
+
 [[package]]
 name = "async-io"
 version = "2.6.0"
@@ -401,6 +407,12 @@ version = "0.8.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
 
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
 [[package]]
 name = "crypto-common"
 version = "0.1.7"
@@ -886,6 +898,12 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
 
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
 [[package]]
 name = "hickory-proto"
 version = "0.25.2"
@@ -1299,6 +1317,7 @@ dependencies = [
  "libp2p-dns",
  "libp2p-identify",
  "libp2p-identity",
+ "libp2p-kad",
  "libp2p-mdns",
  "libp2p-metrics",
  "libp2p-noise",
@@ -1427,18 +1446,47 @@ version = "0.2.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c"
 dependencies = [
+ "asn1_der",
  "bs58",
  "ed25519-dalek",
  "hkdf",
  "multihash",
  "prost",
  "rand 0.8.8",
+ "ring",
  "sha2",
  "thiserror 2.0.20",
  "tracing",
  "zeroize",
 ]
 
+[[package]]
+name = "libp2p-kad"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "either",
+ "fnv",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "rand 0.8.8",
+ "sha2",
+ "smallvec",
+ "thiserror 2.0.20",
+ "tracing",
+ "uint",
+ "web-time",
+]
+
 [[package]]
 name = "libp2p-mdns"
 version = "0.48.0"
@@ -1469,6 +1517,7 @@ dependencies = [
  "libp2p-dcutr",
  "libp2p-identify",
  "libp2p-identity",
+ "libp2p-kad",
  "libp2p-ping",
  "libp2p-relay",
  "libp2p-swarm",
@@ -2947,6 +2996,18 @@ version = "1.20.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
 
+[[package]]
+name = "uint"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90"
+dependencies = [
+ "byteorder",
+ "crunchy",
+ "hex",
+ "static_assertions",
+]
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml
index 5b086ce9d..01e367934 100644
--- a/native/runtime-host-peer/Cargo.toml
+++ b/native/runtime-host-peer/Cargo.toml
@@ -32,11 +32,13 @@ libp2p = { version = "0.56", features = [
   "dcutr",
   "dns",
   "identify",
+  "kad",
   "macros",
   "noise",
   "ping",
   "quic",
   "relay",
+  "rsa",
   "tcp",
   "tokio",
   "yamux",
diff --git a/native/runtime-host-peer/src/bindings.rs b/native/runtime-host-peer/src/bindings.rs
index ced2d6847..9379a8b35 100644
--- a/native/runtime-host-peer/src/bindings.rs
+++ b/native/runtime-host-peer/src/bindings.rs
@@ -19,7 +19,7 @@
 
 use std::{
     path::PathBuf,
-    sync::{Arc, Mutex},
+    sync::{Arc, Mutex, RwLock},
     time::Duration,
 };
 
@@ -39,6 +39,7 @@ pub struct StartPeerEndpointOptions {
     pub expected_peer_id: Option<String>,
     pub listen_addresses: Option<Vec<String>>,
     pub coordination_relays: Option<Vec<String>>,
+    pub automatic_relay_discovery: Option<bool>,
 }
 
 #[napi(object)]
@@ -60,6 +61,7 @@ pub struct PeerIdentitySignature {
 pub struct PeerEndpoint {
     peer_id: String,
     listen_addresses: Vec<String>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
     commands: mpsc::Sender<EngineCommand>,
     incoming: Arc<AsyncMutex<mpsc::Receiver<engine::PeerStream>>>,
     mesh_incoming: Arc<AsyncMutex<mpsc::Receiver<engine::PeerStream>>>,
@@ -79,6 +81,14 @@ impl PeerEndpoint {
         self.listen_addresses.clone()
     }
 
+    #[napi(getter)]
+    pub fn active_coordination_relays(&self) -> Vec<String> {
+        self.active_coordination_relays
+            .read()
+            .map(|addresses| addresses.iter().map(ToString::to_string).collect())
+            .unwrap_or_default()
+    }
+
     #[napi]
     pub async fn connect(&self, options: ConnectPeerOptions) -> Result<PeerStream> {
         connect_peer(self, options, engine::StreamKind::Application).await
@@ -283,6 +293,7 @@ pub fn start_peer_endpoint(options: StartPeerEndpointOptions) -> Result<PeerEndp
             options.coordination_relays.unwrap_or_default(),
             "coordination relay",
         )?,
+        automatic_relay_discovery: options.automatic_relay_discovery.unwrap_or(false),
     })
     .map_err(peer_error)?;
     Ok(PeerEndpoint {
@@ -292,6 +303,7 @@ pub fn start_peer_endpoint(options: StartPeerEndpointOptions) -> Result<PeerEndp
             .into_iter()
             .map(|address| address.to_string())
             .collect(),
+        active_coordination_relays: started.active_coordination_relays,
         commands: started.commands,
         incoming: Arc::new(AsyncMutex::new(started.incoming)),
         mesh_incoming: Arc::new(AsyncMutex::new(started.mesh_incoming)),
diff --git a/native/runtime-host-peer/src/engine.rs b/native/runtime-host-peer/src/engine.rs
index 7b33d11a9..5adef1751 100644
--- a/native/runtime-host-peer/src/engine.rs
+++ b/native/runtime-host-peer/src/engine.rs
@@ -20,6 +20,7 @@
 use std::{
     collections::{HashMap, HashSet},
     path::PathBuf,
+    sync::{Arc, RwLock},
     thread,
     time::{Duration, Instant},
 };
@@ -43,6 +44,7 @@ mod address;
 mod application_stream;
 mod identity_store;
 mod peer_stream;
+mod relay_discovery;
 
 use address::{
     address_with_expected_peer, address_with_peer, coordination_relay_peer_id, is_relayed_address,
@@ -64,7 +66,11 @@ const MAX_ESTABLISHED_CONNECTIONS: u32 = 1024;
 const MAX_CONNECTIONS_PER_PEER: u32 = 4;
 const LISTENER_ADDRESS_QUIET_PERIOD: Duration = Duration::from_millis(250);
 const COORDINATION_RETRY_INTERVAL: Duration = Duration::from_secs(1);
+const AUTOMATIC_RELAY_COOLDOWN: Duration = Duration::from_secs(30);
 const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
+const TARGET_COORDINATION_RESERVATIONS: usize = 2;
+const MAX_AUTOMATIC_RELAY_CANDIDATES: usize = 8;
+const MAX_RELAY_ADDRESSES_PER_PEER: usize = 4;
 
 #[derive(Clone)]
 pub struct StartOptions {
@@ -72,11 +78,13 @@ pub struct StartOptions {
     pub expected_peer_id: Option<PeerId>,
     pub listen_addresses: Vec<Multiaddr>,
     pub coordination_relays: Vec<Multiaddr>,
+    pub automatic_relay_discovery: bool,
 }
 
 pub struct StartedEndpoint {
     pub peer_id: PeerId,
     pub listen_addresses: Vec<Multiaddr>,
+    pub active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
     pub commands: mpsc::Sender<EngineCommand>,
     pub incoming: mpsc::Receiver<PeerStream>,
     pub mesh_incoming: mpsc::Receiver<PeerStream>,
@@ -178,11 +186,14 @@ struct DirectConnectState {
 
 struct CoordinationRelay {
     addresses: Vec<Multiaddr>,
+    automatic_addresses: Vec<Multiaddr>,
+    reservation_addresses: Vec<Multiaddr>,
     connections: HashSet<ConnectionId>,
     pending_connection: Option<ConnectionId>,
     identify_received: bool,
     identify_sent: bool,
     reserve: bool,
+    reservation_accepted: bool,
     client_references: usize,
     reservation_listener: Option<ListenerId>,
     next_connection_attempt: Instant,
@@ -194,11 +205,14 @@ impl Default for CoordinationRelay {
         let now = Instant::now();
         Self {
             addresses: Vec::new(),
+            automatic_addresses: Vec::new(),
+            reservation_addresses: Vec::new(),
             connections: HashSet::new(),
             pending_connection: None,
             identify_received: false,
             identify_sent: false,
             reserve: false,
+            reservation_accepted: false,
             client_references: 0,
             reservation_listener: None,
             next_connection_attempt: now,
@@ -208,6 +222,10 @@ impl Default for CoordinationRelay {
 }
 
 impl CoordinationRelay {
+    fn is_automatic(&self) -> bool {
+        !self.automatic_addresses.is_empty()
+    }
+
     fn is_active(&self) -> bool {
         self.reserve || self.client_references > 0
     }
@@ -216,7 +234,14 @@ impl CoordinationRelay {
         self.identify_received = false;
         self.identify_sent = false;
         self.next_connection_attempt = now;
-        self.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
+        self.next_reservation_attempt = now
+            + if self.is_automatic() {
+                AUTOMATIC_RELAY_COOLDOWN
+            } else {
+                COORDINATION_RETRY_INTERVAL
+            };
+        self.reservation_accepted = false;
+        self.reservation_addresses.clear();
         self.reservation_listener.take()
     }
 
@@ -225,7 +250,14 @@ impl CoordinationRelay {
             return false;
         }
         self.reservation_listener = None;
-        self.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
+        self.reservation_accepted = false;
+        self.reservation_addresses.clear();
+        self.next_reservation_attempt = now
+            + if self.is_automatic() {
+                AUTOMATIC_RELAY_COOLDOWN
+            } else {
+                COORDINATION_RETRY_INTERVAL
+            };
         true
     }
 }
@@ -291,6 +323,8 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
     let (incoming_tx, incoming_rx) = mpsc::channel(INCOMING_STREAM_CAPACITY);
     let (mesh_incoming_tx, mesh_incoming_rx) = mpsc::channel(MESH_INCOMING_STREAM_CAPACITY);
     let (terminal_tx, terminal_rx) = mpsc::channel(1);
+    let active_coordination_relays = Arc::new(RwLock::new(Vec::new()));
+    let active_coordination_relays_for_thread = Arc::clone(&active_coordination_relays);
     let thread = thread::Builder::new()
         .name("maka-runtime-host-peer".to_owned())
         .spawn(move || {
@@ -300,6 +334,7 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
                 incoming_tx,
                 mesh_incoming_tx,
                 ready_tx.clone(),
+                active_coordination_relays_for_thread,
             );
             if let Err(error) = result {
                 let _ = ready_tx.send(Err(error.clone()));
@@ -313,6 +348,7 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
     Ok(StartedEndpoint {
         peer_id: ready.0,
         listen_addresses: ready.1,
+        active_coordination_relays,
         commands: command_tx,
         incoming: incoming_rx,
         mesh_incoming: mesh_incoming_rx,
@@ -327,6 +363,7 @@ fn run_endpoint(
     incoming_tx: mpsc::Sender<PeerStream>,
     mesh_incoming_tx: mpsc::Sender<PeerStream>,
     ready_tx: std::sync::mpsc::SyncSender<Result<(PeerId, Vec<Multiaddr>), PeerError>>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
 ) -> Result<(), PeerError> {
     let runtime = tokio::runtime::Builder::new_multi_thread()
         .enable_all()
@@ -339,6 +376,7 @@ fn run_endpoint(
         incoming_tx,
         mesh_incoming_tx,
         ready_tx,
+        active_coordination_relays,
     ))
 }
 
@@ -348,6 +386,7 @@ async fn run_endpoint_async(
     incoming_tx: mpsc::Sender<PeerStream>,
     mesh_incoming_tx: mpsc::Sender<PeerStream>,
     ready_tx: std::sync::mpsc::SyncSender<Result<(PeerId, Vec<Multiaddr>), PeerError>>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
 ) -> Result<(), PeerError> {
     let key = match options.expected_peer_id {
         Some(expected) => {
@@ -439,6 +478,9 @@ async fn run_endpoint_async(
     let mut external_candidate_ready = startup_external_candidate_ready;
     let mut deadline_tick = tokio::time::interval(Duration::from_millis(100));
     deadline_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+    let mut discovered_relays = options
+        .automatic_relay_discovery
+        .then(relay_discovery::spawn);
 
     loop {
         tokio::select! {
@@ -549,6 +591,34 @@ async fn run_endpoint_async(
                     // Dropping the stream applies bounded backpressure to Mesh control callers.
                 }
             }
+            Some(candidate) = async {
+                match &mut discovered_relays {
+                    Some(receiver) => receiver.recv().await,
+                    None => std::future::pending().await,
+                }
+            } => {
+                discovery_debug(format_args!("candidate {}", candidate.peer_id));
+                register_automatic_relay_candidate(
+                    &mut coordination_relays,
+                    candidate,
+                    local_peer_id,
+                );
+                rebalance_automatic_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    Instant::now(),
+                );
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
+                );
+                maintain_coordination_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    external_candidate_ready,
+                    Instant::now(),
+                );
+            }
             Some(completed) = stream_completed_rx.recv() => {
                 match completed.kind {
                     StreamCompletion::Application(connection_id) => {
@@ -644,6 +714,16 @@ async fn run_endpoint_async(
                     &mut coordination_relays,
                     &mut direct,
                     &mut external_candidate_ready,
+                    &active_coordination_relays,
+                );
+                rebalance_automatic_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    Instant::now(),
+                );
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
                 );
                 maintain_coordination_relays(
                     &mut swarm,
@@ -665,6 +745,11 @@ async fn run_endpoint_async(
             }
             _ = deadline_tick.tick() => {
                 let now = Instant::now();
+                rebalance_automatic_relays(&mut swarm, &mut coordination_relays, now);
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
+                );
                 maintain_coordination_relays(
                     &mut swarm,
                     &mut coordination_relays,
@@ -883,6 +968,7 @@ fn handle_swarm_event(
     coordination_relays: &mut HashMap<PeerId, CoordinationRelay>,
     direct: &mut DirectConnectState,
     external_candidate_ready: &mut bool,
+    active_coordination_relays: &Arc<RwLock<Vec<Multiaddr>>>,
 ) {
     match event {
         SwarmEvent::ConnectionEstablished {
@@ -926,12 +1012,27 @@ fn handle_swarm_event(
             if let Some(relay) = coordination_relays.get_mut(&peer_id) {
                 relay.connections.remove(&connection_id);
             }
+            let mut reservation_changed = false;
+            let mut discard_automatic = false;
             if !swarm.is_connected(&peer_id)
                 && let Some(relay) = coordination_relays.get_mut(&peer_id)
                 && relay.is_active()
-                && let Some(listener) = relay.connection_lost(Instant::now())
             {
-                swarm.remove_listener(listener);
+                let was_accepted = relay.reservation_accepted;
+                discovery_debug(format_args!(
+                    "connection to {peer_id} closed; reservation accepted={was_accepted}"
+                ));
+                if let Some(listener) = relay.connection_lost(Instant::now()) {
+                    swarm.remove_listener(listener);
+                }
+                reservation_changed = true;
+                discard_automatic = relay.is_automatic() && !was_accepted;
+            }
+            if discard_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
+            if reservation_changed {
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
             }
         }
         SwarmEvent::OutgoingConnectionError { connection_id, .. } => {
@@ -939,12 +1040,19 @@ fn handle_swarm_event(
             for connect in direct.pending.values_mut() {
                 connect.dials.remove(&connection_id);
             }
-            for relay in coordination_relays.values_mut() {
+            let mut failed_automatic = None;
+            for (peer_id, relay) in coordination_relays.iter_mut() {
                 if relay.pending_connection == Some(connection_id) {
                     relay.pending_connection = None;
+                    if relay.is_automatic() && !relay.reservation_accepted {
+                        failed_automatic = Some(*peer_id);
+                    }
                     break;
                 }
             }
+            if let Some(peer_id) = failed_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
         }
         SwarmEvent::Behaviour(BehaviourEvent::Dcutr(dcutr::Event {
             remote_peer_id,
@@ -958,6 +1066,40 @@ fn handle_swarm_event(
             }
         }
         SwarmEvent::Behaviour(BehaviourEvent::Dcutr(_)) => {}
+        SwarmEvent::Behaviour(BehaviourEvent::RelayClient(
+            relay::client::Event::ReservationReqAccepted { relay_peer_id, .. },
+        )) => {
+            discovery_debug(format_args!("reservation accepted by {relay_peer_id}"));
+            if let Some(relay) = coordination_relays.get_mut(&relay_peer_id) {
+                relay.reservation_accepted = true;
+            }
+            publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+        }
+        SwarmEvent::NewListenAddr {
+            listener_id,
+            address,
+        } if is_relayed_address(&address) => {
+            let relay_peer = coordination_relays.iter().find_map(|(peer_id, relay)| {
+                (relay.reservation_listener == Some(listener_id)).then_some(*peer_id)
+            });
+            if let Some(relay_peer) = relay_peer {
+                let automatic = coordination_relays
+                    .get(&relay_peer)
+                    .is_some_and(CoordinationRelay::is_automatic);
+                if let Some(base_address) = reservation_base_address(address, relay_peer, automatic)
+                {
+                    if let Some(relay) = coordination_relays.get_mut(&relay_peer) {
+                        remember_reservation_address(relay, base_address);
+                    }
+                } else if automatic {
+                    if let Some(relay) = coordination_relays.get_mut(&relay_peer) {
+                        relay.reservation_accepted = false;
+                    }
+                    discard_automatic_relay_candidate(swarm, coordination_relays, relay_peer);
+                }
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+            }
+        }
         SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received {
             peer_id,
             ..
@@ -999,12 +1141,32 @@ fn handle_swarm_event(
                 );
             }
         }
-        SwarmEvent::ListenerClosed { listener_id, .. } => {
-            for relay in coordination_relays.values_mut() {
+        SwarmEvent::ListenerClosed {
+            listener_id,
+            reason,
+            ..
+        } => {
+            discovery_debug(format_args!(
+                "reservation listener {listener_id:?} closed: {reason:?}"
+            ));
+            let mut changed = false;
+            let mut rejected_automatic = None;
+            for (peer_id, relay) in coordination_relays.iter_mut() {
+                let was_accepted = relay.reservation_accepted;
                 if relay.listener_closed(listener_id, Instant::now()) {
+                    if relay.is_automatic() && !was_accepted {
+                        rejected_automatic = Some(*peer_id);
+                    }
+                    changed = true;
                     break;
                 }
             }
+            if let Some(peer_id) = rejected_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
+            if changed {
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+            }
         }
         _ => {}
     }
@@ -1023,6 +1185,7 @@ fn handle_startup_event(
         coordination_relays,
         &mut DirectConnectState::default(),
         external_candidate_ready,
+        &Arc::new(RwLock::new(Vec::new())),
     );
 }
 
@@ -1041,16 +1204,256 @@ fn register_coordination_relay(
         ));
     }
     let relay = relays.entry(relay_peer).or_default();
+    if reserve {
+        relay.automatic_addresses.clear();
+    }
     relay.reserve |= reserve;
     if add_client_reference {
         relay.client_references += 1;
     }
-    if !relay.addresses.contains(address) {
-        relay.addresses.push(address.clone());
-    }
+    remember_relay_address(&mut relay.addresses, address.clone());
     Ok(())
 }
 
+fn register_automatic_relay_candidate(
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    candidate: relay_discovery::RelayCandidate,
+    local_peer_id: PeerId,
+) {
+    if candidate.peer_id == local_peer_id {
+        return;
+    }
+    if !relays
+        .get(&candidate.peer_id)
+        .is_some_and(CoordinationRelay::is_automatic)
+        && relays.values().filter(|relay| relay.is_automatic()).count()
+            >= MAX_AUTOMATIC_RELAY_CANDIDATES
+    {
+        return;
+    }
+    let addresses = bounded_relay_addresses(candidate.addresses);
+    if addresses.is_empty() {
+        return;
+    }
+    let relay = relays.entry(candidate.peer_id).or_default();
+    if relay.reserve && !relay.is_automatic() {
+        return;
+    }
+    relay.automatic_addresses = addresses;
+}
+
+fn rebalance_automatic_relays(
+    swarm: &mut Swarm<Behaviour>,
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    now: Instant,
+) {
+    let manual_reservations = relays
+        .values()
+        .filter(|relay| {
+            !relay.is_automatic()
+                && relay.reservation_accepted
+                && !relay.reservation_addresses.is_empty()
+        })
+        .count();
+    let desired = TARGET_COORDINATION_RESERVATIONS.saturating_sub(manual_reservations);
+    let mut automatic = relays
+        .iter()
+        .filter(|(_, relay)| relay.is_automatic())
+        .map(|(peer_id, relay)| (*peer_id, relay.reservation_accepted, relay.reserve))
+        .collect::<Vec<_>>();
+    automatic.sort_unstable_by_key(|(peer_id, accepted, reserved)| {
+        (!*accepted, !*reserved, peer_id.to_string())
+    });
+
+    let mut selected = 0;
+    for (peer_id, accepted, _) in automatic {
+        let relay = relays
+            .get_mut(&peer_id)
+            .expect("automatic relay was collected from the same map");
+        let should_reserve = selected < desired
+            && (accepted
+                || relay.reservation_listener.is_some()
+                || relay.next_reservation_attempt <= now);
+        if should_reserve {
+            relay.reserve = true;
+            selected += 1;
+            continue;
+        }
+        relay.reserve = false;
+        relay.reservation_accepted = false;
+        relay.reservation_addresses.clear();
+        if let Some(listener) = relay.reservation_listener.take() {
+            discovery_debug(format_args!(
+                "removing reservation listener for deselected candidate {peer_id}"
+            ));
+            swarm.remove_listener(listener);
+        }
+        if relay.client_references == 0 {
+            for connection_id in relay.connections.drain() {
+                let _ = swarm.close_connection(connection_id);
+            }
+        }
+    }
+}
+
+fn publish_active_coordination_relays(
+    relays: &HashMap<PeerId, CoordinationRelay>,
+    snapshot: &Arc<RwLock<Vec<Multiaddr>>>,
+) {
+    let mut addresses = relays
+        .values()
+        .filter(|relay| relay.reservation_accepted)
+        .flat_map(|relay| relay.reservation_addresses.iter().cloned())
+        .collect::<Vec<_>>();
+    addresses.sort_unstable_by_key(ToString::to_string);
+    addresses.dedup();
+    if let Ok(mut current) = snapshot.write() {
+        *current = addresses;
+    }
+}
+
+fn bounded_relay_addresses(mut addresses: Vec<Multiaddr>) -> Vec<Multiaddr> {
+    addresses.sort_unstable_by_key(|address| (relay_route_class(address), address.to_string()));
+    let mut classes = HashSet::new();
+    addresses
+        .into_iter()
+        .filter(|address| classes.insert(relay_route_class(address)))
+        .take(MAX_RELAY_ADDRESSES_PER_PEER)
+        .collect()
+}
+
+fn relay_route_class(address: &Multiaddr) -> (u8, u8) {
+    let mut protocols = address.iter();
+    let host = match protocols.next() {
+        Some(Protocol::Ip4(_)) => 0,
+        Some(Protocol::Ip6(_)) => 1,
+        Some(Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_)) => 2,
+        _ => 3,
+    };
+    let transport = match protocols.next() {
+        Some(Protocol::Udp(_)) => 0,
+        Some(Protocol::Tcp(_)) => 1,
+        _ => 2,
+    };
+    (host, transport)
+}
+
+fn reservation_base_address(
+    mut address: Multiaddr,
+    expected_peer: PeerId,
+    require_public: bool,
+) -> Option<Multiaddr> {
+    if !matches!(address.pop(), Some(Protocol::P2p(_)))
+        || !matches!(address.pop(), Some(Protocol::P2pCircuit))
+        || coordination_relay_peer_id(&address).ok()? != expected_peer
+        || !supported_relay_address(&address, require_public)
+    {
+        return None;
+    }
+    Some(address)
+}
+
+fn remember_reservation_address(relay: &mut CoordinationRelay, address: Multiaddr) {
+    remember_relay_address(&mut relay.reservation_addresses, address);
+}
+
+fn remember_relay_address(addresses: &mut Vec<Multiaddr>, address: Multiaddr) {
+    let class = relay_route_class(&address);
+    if let Some(existing) = addresses
+        .iter_mut()
+        .find(|existing| relay_route_class(existing) == class)
+    {
+        *existing = address;
+    } else if addresses.len() < MAX_RELAY_ADDRESSES_PER_PEER {
+        addresses.push(address);
+    }
+}
+
+fn supported_public_relay_address(address: &Multiaddr) -> bool {
+    supported_relay_address(address, true)
+}
+
+fn supported_relay_address(address: &Multiaddr, require_public: bool) -> bool {
+    let mut protocols = address.iter();
+    let host_supported = match protocols.next() {
+        Some(Protocol::Ip4(address)) => !require_public || public_ipv4(address),
+        Some(Protocol::Ip6(address)) => !require_public || public_ipv6(address),
+        Some(Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_)) => !require_public,
+        _ => false,
+    };
+    if !host_supported {
+        return false;
+    }
+    match protocols.next() {
+        Some(Protocol::Tcp(_)) => {
+            matches!(protocols.next(), Some(Protocol::P2p(_))) && protocols.next().is_none()
+        }
+        Some(Protocol::Udp(_)) => {
+            matches!(protocols.next(), Some(Protocol::QuicV1))
+                && matches!(protocols.next(), Some(Protocol::P2p(_)))
+                && protocols.next().is_none()
+        }
+        _ => false,
+    }
+}
+
+fn public_ipv4(address: std::net::Ipv4Addr) -> bool {
+    let [first, second, third, _] = address.octets();
+    !(first == 0
+        || first == 10
+        || first == 127
+        || first >= 224
+        || (first == 100 && (64..=127).contains(&second))
+        || (first == 169 && second == 254)
+        || (first == 172 && (16..=31).contains(&second))
+        || (first == 192 && second == 0 && third == 0)
+        || (first == 192 && second == 0 && third == 2)
+        || (first == 192 && second == 168)
+        || (first == 198 && (second == 18 || second == 19))
+        || (first == 198 && second == 51 && third == 100)
+        || (first == 203 && second == 0 && third == 113))
+}
+
+fn public_ipv6(address: std::net::Ipv6Addr) -> bool {
+    if let Some(address) = address.to_ipv4() {
+        return public_ipv4(address);
+    }
+    let segments = address.segments();
+    segments[0] & 0xe000 == 0x2000
+        && !(segments[0] == 0x2001 && segments[1] < 0x0200)
+        && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
+        && segments[0] != 0x2002
+        && segments[0] & 0xfff0 != 0x3ff0
+}
+
+fn discard_automatic_relay_candidate(
+    swarm: &mut Swarm<Behaviour>,
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    peer_id: PeerId,
+) {
+    let Some(relay) = relays.get_mut(&peer_id) else {
+        return;
+    };
+    if !relay.is_automatic() || relay.reservation_accepted {
+        return;
+    }
+    relay.automatic_addresses.clear();
+    relay.reserve = false;
+    relay.reservation_addresses.clear();
+    if let Some(listener) = relay.reservation_listener.take() {
+        swarm.remove_listener(listener);
+    }
+    if relay.client_references > 0 {
+        return;
+    }
+    let mut relay = relays
+        .remove(&peer_id)
+        .expect("automatic relay candidate was read from the same map");
+    for connection_id in relay.connections.drain() {
+        let _ = swarm.close_connection(connection_id);
+    }
+}
+
 fn release_coordination_relays(
     swarm: &mut Swarm<Behaviour>,
     relays: &mut HashMap<PeerId, CoordinationRelay>,
@@ -1092,7 +1495,7 @@ fn dial_coordination_relay(
     }
     relay.next_connection_attempt = now + COORDINATION_RETRY_INTERVAL;
     let options = DialOpts::peer_id(peer_id)
-        .addresses(relay.addresses.clone())
+        .addresses(relay_dial_addresses(relay))
         .build();
     let connection_id = options.connection_id();
     if swarm.dial(options).is_ok() {
@@ -1140,14 +1543,44 @@ fn request_coordination_reservation(
         return;
     }
     relay.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
-    for address in &relay.addresses {
-        if let Ok(listener) = swarm.listen_on(address.clone().with(Protocol::P2pCircuit)) {
-            relay.reservation_listener = Some(listener);
-            break;
+    for address in relay_reservation_addresses(relay) {
+        match swarm.listen_on(address.with(Protocol::P2pCircuit)) {
+            Ok(listener) => {
+                discovery_debug(format_args!("requesting reservation from {peer_id}"));
+                relay.reservation_listener = Some(listener);
+                break;
+            }
+            Err(error) => {
+                discovery_debug(format_args!(
+                    "reservation request for {peer_id} failed: {error}"
+                ));
+            }
         }
     }
 }
 
+fn relay_dial_addresses(relay: &CoordinationRelay) -> Vec<Multiaddr> {
+    let mut addresses = relay.addresses.clone();
+    addresses.extend(relay.automatic_addresses.iter().cloned());
+    addresses.sort_unstable_by_key(ToString::to_string);
+    addresses.dedup();
+    addresses
+}
+
+fn relay_reservation_addresses(relay: &CoordinationRelay) -> Vec<Multiaddr> {
+    if relay.is_automatic() {
+        relay.automatic_addresses.clone()
+    } else {
+        relay.addresses.clone()
+    }
+}
+
+fn discovery_debug(message: std::fmt::Arguments<'_>) {
+    if std::env::var_os("MAKA_PEER_DISCOVERY_DEBUG").is_some() {
+        eprintln!("[peer-relay-pool] {message}");
+    }
+}
+
 fn retry_connect_routes(
     swarm: &mut Swarm<Behaviour>,
     direct: &mut DirectConnectState,
@@ -1366,6 +1799,7 @@ mod tests {
                     .expect("test listen address"),
             ],
             coordination_relays: Vec::new(),
+            automatic_relay_discovery: false,
         }
     }
 
@@ -1448,6 +1882,12 @@ mod tests {
         let mut relay = CoordinationRelay {
             identify_received: true,
             identify_sent: true,
+            reservation_accepted: true,
+            reservation_addresses: vec![
+                "/ip4/192.0.2.1/tcp/4001"
+                    .parse()
+                    .expect("valid reservation address"),
+            ],
             reservation_listener: Some(listener),
             next_connection_attempt: now + Duration::from_secs(30),
             next_reservation_attempt: now + Duration::from_secs(30),
@@ -1456,6 +1896,8 @@ mod tests {
 
         assert!(relay.listener_closed(listener, now));
         assert!(relay.reservation_listener.is_none());
+        assert!(!relay.reservation_accepted);
+        assert!(relay.reservation_addresses.is_empty());
 
         relay.reservation_listener = Some(listener);
         assert_eq!(relay.connection_lost(now), Some(listener));
@@ -1464,6 +1906,43 @@ mod tests {
         assert_eq!(relay.next_connection_attempt, now);
     }
 
+    #[test]
+    fn active_coordination_routes_only_publish_accepted_reservations() {
+        let accepted_peer = PeerId::random();
+        let pending_peer = PeerId::random();
+        let accepted_address: Multiaddr = format!("/ip4/192.0.2.1/tcp/4001/p2p/{accepted_peer}")
+            .parse()
+            .expect("valid accepted relay address");
+        let pending_address: Multiaddr = format!("/ip4/192.0.2.2/tcp/4001/p2p/{pending_peer}")
+            .parse()
+            .expect("valid pending relay address");
+        let relays = HashMap::from([
+            (
+                accepted_peer,
+                CoordinationRelay {
+                    reservation_accepted: true,
+                    reservation_addresses: vec![accepted_address.clone()],
+                    ..CoordinationRelay::default()
+                },
+            ),
+            (
+                pending_peer,
+                CoordinationRelay {
+                    reservation_addresses: vec![pending_address],
+                    ..CoordinationRelay::default()
+                },
+            ),
+        ]);
+        let snapshot = Arc::new(RwLock::new(Vec::new()));
+
+        publish_active_coordination_relays(&relays, &snapshot);
+
+        assert_eq!(
+            *snapshot.read().expect("read snapshot"),
+            vec![accepted_address]
+        );
+    }
+
     #[test]
     fn coordination_relay_requires_one_terminal_peer_identity() {
         let relay = PeerId::random();
@@ -1481,4 +1960,147 @@ mod tests {
             .expect("valid relayed address");
         assert!(coordination_relay_peer_id(&tunneled).is_err());
     }
+
+    #[test]
+    fn relay_routes_enforce_origin_policy_identity_and_replacement() {
+        let relay = PeerId::random();
+        let local = PeerId::random();
+        let other = PeerId::random();
+        let public_quic: Multiaddr = format!("/ip4/1.1.1.1/udp/4001/quic-v1/p2p/{relay}")
+            .parse()
+            .expect("valid public QUIC address");
+        let private_tcp: Multiaddr = format!("/ip4/192.168.1.2/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid private TCP address");
+        let unsupported_websocket: Multiaddr = format!("/ip4/1.1.1.1/tcp/4001/ws/p2p/{relay}")
+            .parse()
+            .expect("valid WebSocket address");
+        let mapped_private: Multiaddr = format!("/ip6/::ffff:127.0.0.1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid IPv4-mapped private address");
+        let compatible_private: Multiaddr = format!("/ip6/::127.0.0.1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid IPv4-compatible private address");
+        let former_site_local: Multiaddr = format!("/ip6/fec0::1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid former site-local address");
+
+        assert!(supported_public_relay_address(&public_quic));
+        assert!(!supported_public_relay_address(&private_tcp));
+        assert!(!supported_public_relay_address(&unsupported_websocket));
+        assert!(!supported_public_relay_address(&mapped_private));
+        assert!(!supported_public_relay_address(&compatible_private));
+        assert!(!supported_public_relay_address(&former_site_local));
+
+        let manual_route: Multiaddr = format!("{private_tcp}/p2p-circuit/p2p/{local}")
+            .parse()
+            .expect("valid manual reservation route");
+        assert_eq!(
+            reservation_base_address(manual_route.clone(), relay, false),
+            Some(private_tcp),
+        );
+        assert!(reservation_base_address(manual_route, other, false).is_none());
+
+        let dns_base: Multiaddr = format!("/dns4/relay.example/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid DNS relay address");
+        let dns_route: Multiaddr = format!("{dns_base}/p2p-circuit/p2p/{local}")
+            .parse()
+            .expect("valid DNS reservation route");
+        assert!(reservation_base_address(dns_route.clone(), relay, true).is_none());
+        assert_eq!(
+            reservation_base_address(dns_route, relay, false),
+            Some(dns_base),
+        );
+
+        let mut state = CoordinationRelay::default();
+        let first: Multiaddr = format!("/ip4/1.1.1.1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid first relay route");
+        let renewed: Multiaddr = format!("/ip4/1.0.0.1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid renewed relay route");
+        remember_reservation_address(&mut state, first);
+        remember_reservation_address(&mut state, renewed.clone());
+        assert_eq!(state.reservation_addresses, vec![renewed]);
+
+        let automatic_peer = PeerId::random();
+        let automatic_address: Multiaddr = format!("/ip4/1.1.1.1/tcp/4001/p2p/{automatic_peer}")
+            .parse()
+            .expect("valid automatic relay address");
+        let replacement: Multiaddr = format!("/ip4/8.8.8.8/tcp/4001/p2p/{automatic_peer}")
+            .parse()
+            .expect("valid replacement relay address");
+        let mut relays = HashMap::new();
+        register_automatic_relay_candidate(
+            &mut relays,
+            relay_discovery::RelayCandidate {
+                peer_id: automatic_peer,
+                addresses: vec![automatic_address],
+            },
+            local,
+        );
+        register_automatic_relay_candidate(
+            &mut relays,
+            relay_discovery::RelayCandidate {
+                peer_id: automatic_peer,
+                addresses: vec![replacement.clone()],
+            },
+            local,
+        );
+        let automatic = relays
+            .get_mut(&automatic_peer)
+            .expect("automatic relay is registered");
+        let private_client_address: Multiaddr =
+            format!("/ip4/192.168.1.20/tcp/4001/p2p/{automatic_peer}")
+                .parse()
+                .expect("valid private client relay address");
+        automatic.addresses.push(private_client_address);
+        assert_eq!(relay_reservation_addresses(automatic), vec![replacement]);
+
+        let mut bounded = HashMap::new();
+        for _ in 0..MAX_AUTOMATIC_RELAY_CANDIDATES {
+            let peer = PeerId::random();
+            let address = format!("/ip4/1.1.1.1/tcp/4001/p2p/{peer}")
+                .parse()
+                .expect("valid bounded relay address");
+            bounded.insert(
+                peer,
+                CoordinationRelay {
+                    automatic_addresses: vec![address],
+                    ..CoordinationRelay::default()
+                },
+            );
+        }
+        let client_peer = PeerId::random();
+        let client_address = format!("/ip4/1.1.1.1/tcp/4001/p2p/{client_peer}")
+            .parse()
+            .expect("valid client relay address");
+        register_coordination_relay(&mut bounded, &client_address, local, false, true)
+            .expect("client relay is registered");
+        let replacement_client_address = format!("/ip4/8.8.8.8/tcp/4001/p2p/{client_peer}")
+            .parse()
+            .expect("valid replacement client relay address");
+        register_coordination_relay(
+            &mut bounded,
+            &replacement_client_address,
+            local,
+            false,
+            false,
+        )
+        .expect("client relay address is refreshed");
+        assert_eq!(
+            bounded[&client_peer].addresses,
+            vec![replacement_client_address]
+        );
+        register_automatic_relay_candidate(
+            &mut bounded,
+            relay_discovery::RelayCandidate {
+                peer_id: client_peer,
+                addresses: vec![client_address],
+            },
+            local,
+        );
+        assert!(!bounded[&client_peer].is_automatic());
+    }
 }
diff --git a/native/runtime-host-peer/src/engine/relay_discovery.rs b/native/runtime-host-peer/src/engine/relay_discovery.rs
new file mode 100644
index 000000000..e7bec1ffd
--- /dev/null
+++ b/native/runtime-host-peer/src/engine/relay_discovery.rs
@@ -0,0 +1,504 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::{
+    collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
+    ops::{Deref, DerefMut},
+    task::{Context, Poll},
+    time::{Duration, Instant},
+};
+
+use futures::StreamExt;
+use libp2p::{
+    Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder, connection_limits,
+    core::{Endpoint, transport::PortUse},
+    identify, identity,
+    kad::{self, store::MemoryStore},
+    noise, ping,
+    swarm::{
+        ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, SwarmEvent, THandler,
+        THandlerInEvent, THandlerOutEvent, ToSwarm, dial_opts::DialOpts,
+    },
+    tcp, yamux,
+};
+use tokio::sync::mpsc;
+
+use super::{address::address_with_peer, supported_public_relay_address};
+
+const AMINO_PROTOCOL: &str = "/ipfs/kad/1.0.0";
+const IDENTIFY_PROTOCOL: &str = "/maka/runtime-host/relay-discovery/1";
+const RELAY_HOP_PROTOCOL: &str = "/libp2p/circuit/relay/0.2.0/hop";
+const DISCOVERY_INTERVAL: Duration = Duration::from_secs(30);
+const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
+const MAX_PENDING_PROBES: usize = 8;
+const MAX_KNOWN_PEERS: usize = 256;
+const MAX_ADDRESSES_PER_PEER: usize = 8;
+const PROBE_REVISIT_INTERVAL: Duration = Duration::from_secs(10 * 60);
+
+const AMINO_BOOTSTRAPS: &[&str] = &[
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
+    "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
+    "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
+    "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
+];
+
+struct KnownPeer {
+    addresses: Vec<Multiaddr>,
+    first_seen: Instant,
+}
+
+#[derive(Debug)]
+pub(super) struct RelayCandidate {
+    pub peer_id: PeerId,
+    pub addresses: Vec<Multiaddr>,
+}
+
+#[derive(NetworkBehaviour)]
+struct Behaviour {
+    connection_limits: connection_limits::Behaviour,
+    kad: PublicKad,
+    identify: identify::Behaviour,
+    ping: ping::Behaviour,
+}
+
+struct PublicKad(kad::Behaviour<MemoryStore>);
+
+impl Deref for PublicKad {
+    type Target = kad::Behaviour<MemoryStore>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl DerefMut for PublicKad {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl NetworkBehaviour for PublicKad {
+    type ConnectionHandler = <kad::Behaviour<MemoryStore> as NetworkBehaviour>::ConnectionHandler;
+    type ToSwarm = <kad::Behaviour<MemoryStore> as NetworkBehaviour>::ToSwarm;
+
+    fn handle_pending_inbound_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        local_addr: &Multiaddr,
+        remote_addr: &Multiaddr,
+    ) -> Result<(), ConnectionDenied> {
+        self.0
+            .handle_pending_inbound_connection(connection_id, local_addr, remote_addr)
+    }
+
+    fn handle_established_inbound_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        peer: PeerId,
+        local_addr: &Multiaddr,
+        remote_addr: &Multiaddr,
+    ) -> Result<THandler<Self>, ConnectionDenied> {
+        self.0
+            .handle_established_inbound_connection(connection_id, peer, local_addr, remote_addr)
+    }
+
+    fn handle_pending_outbound_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        maybe_peer: Option<PeerId>,
+        addresses: &[Multiaddr],
+        effective_role: Endpoint,
+    ) -> Result<Vec<Multiaddr>, ConnectionDenied> {
+        let addresses = self.0.handle_pending_outbound_connection(
+            connection_id,
+            maybe_peer,
+            addresses,
+            effective_role,
+        )?;
+        Ok(addresses
+            .into_iter()
+            .filter(|address| allowed_kad_dial_address(maybe_peer, address))
+            .collect())
+    }
+
+    fn handle_established_outbound_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        peer: PeerId,
+        address: &Multiaddr,
+        role_override: Endpoint,
+        port_use: PortUse,
+    ) -> Result<THandler<Self>, ConnectionDenied> {
+        self.0.handle_established_outbound_connection(
+            connection_id,
+            peer,
+            address,
+            role_override,
+            port_use,
+        )
+    }
+
+    fn on_swarm_event(&mut self, event: FromSwarm) {
+        self.0.on_swarm_event(event);
+    }
+
+    fn on_connection_handler_event(
+        &mut self,
+        peer_id: PeerId,
+        connection_id: ConnectionId,
+        event: THandlerOutEvent<Self>,
+    ) {
+        self.0
+            .on_connection_handler_event(peer_id, connection_id, event);
+    }
+
+    fn poll(
+        &mut self,
+        cx: &mut Context<'_>,
+    ) -> Poll<ToSwarm<Self::ToSwarm, THandlerInEvent<Self>>> {
+        self.0.poll(cx)
+    }
+}
+
+pub(super) fn spawn() -> mpsc::Receiver<RelayCandidate> {
+    let (sender, receiver) = mpsc::channel(32);
+    tokio::spawn(async move {
+        match build_swarm() {
+            Ok(mut swarm) => run(&mut swarm, sender).await,
+            Err(error) => eprintln!("[peer-relay-discovery] unavailable: {error}"),
+        }
+    });
+    receiver
+}
+
+fn build_swarm() -> Result<Swarm<Behaviour>, Box<dyn std::error::Error + Send + Sync>> {
+    let key = identity::Keypair::generate_ed25519();
+    let local_peer_id = key.public().to_peer_id();
+    let swarm = SwarmBuilder::with_existing_identity(key)
+        .with_tokio()
+        .with_tcp(
+            tcp::Config::default().nodelay(true),
+            noise::Config::new,
+            yamux::Config::default,
+        )?
+        .with_quic()
+        .with_dns()?
+        .with_behaviour(move |key| {
+            let mut config = kad::Config::new(StreamProtocol::new(AMINO_PROTOCOL));
+            config.set_query_timeout(Duration::from_secs(15));
+            let mut kad =
+                kad::Behaviour::with_config(local_peer_id, MemoryStore::new(local_peer_id), config);
+            kad.set_mode(Some(kad::Mode::Client));
+            Behaviour {
+                connection_limits: connection_limits::Behaviour::new(
+                    connection_limits::ConnectionLimits::default()
+                        .with_max_pending_outgoing(Some(MAX_PENDING_PROBES as u32))
+                        .with_max_established_outgoing(Some(32))
+                        .with_max_established(Some(32))
+                        .with_max_established_per_peer(Some(1)),
+                ),
+                kad: PublicKad(kad),
+                identify: identify::Behaviour::new(discovery_identify_config(key.public())),
+                ping: ping::Behaviour::new(ping::Config::new()),
+            }
+        })?
+        .with_swarm_config(|config| config.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
+        .build();
+    Ok(swarm)
+}
+
+fn discovery_identify_config(public_key: identity::PublicKey) -> identify::Config {
+    identify::Config::new(IDENTIFY_PROTOCOL.to_owned(), public_key).with_cache_size(0)
+}
+
+async fn run(swarm: &mut Swarm<Behaviour>, sender: mpsc::Sender<RelayCandidate>) {
+    let mut known = HashMap::<PeerId, KnownPeer>::new();
+    let mut pending = HashSet::<PeerId>::new();
+    let mut queue = VecDeque::<PeerId>::new();
+
+    for value in AMINO_BOOTSTRAPS {
+        let Ok(address) = value.parse::<Multiaddr>() else {
+            continue;
+        };
+        let Some(peer_id) = terminal_peer_id(&address) else {
+            continue;
+        };
+        remember_canonical(
+            peer_id,
+            vec![address_with_peer(address, peer_id)],
+            &mut known,
+            &mut queue,
+            Instant::now(),
+        );
+        for address in known
+            .get(&peer_id)
+            .into_iter()
+            .flat_map(|peer| &peer.addresses)
+        {
+            swarm
+                .behaviour_mut()
+                .kad
+                .add_address(&peer_id, address.clone());
+        }
+    }
+    let _ = swarm.behaviour_mut().kad.bootstrap();
+
+    let mut walk = tokio::time::interval(DISCOVERY_INTERVAL);
+    walk.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+    loop {
+        start_probes(swarm, &known, &mut queue, &mut pending);
+        tokio::select! {
+            _ = walk.tick() => {
+                expire_known_peers(&mut known, &pending, Instant::now());
+                swarm.behaviour_mut().kad.get_closest_peers(PeerId::random().to_bytes());
+            }
+            event = swarm.select_next_some() => match event {
+                SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
+                    result: kad::QueryResult::GetClosestPeers(result),
+                    ..
+                })) => {
+                    let peers = match result {
+                        Ok(result) => result.peers,
+                        Err(kad::GetClosestPeersError::Timeout { peers, .. }) => peers,
+                    };
+                    debug(format_args!("DHT walk returned {} peers", peers.len()));
+                    for peer in peers {
+                        remember(
+                            peer.peer_id,
+                            peer.addrs,
+                            &mut known,
+                            &mut queue,
+                            Instant::now(),
+                        );
+                    }
+                }
+                SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::RoutingUpdated {
+                    peer,
+                    addresses: discovered,
+                    ..
+                })) => {
+                    remember(
+                        peer,
+                        discovered.into_vec(),
+                        &mut known,
+                        &mut queue,
+                        Instant::now(),
+                    );
+                }
+                SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received {
+                    peer_id,
+                    info,
+                    ..
+                })) => {
+                    pending.remove(&peer_id);
+                    let mut candidate_addresses = info.listen_addrs;
+                    if let Some(discovered) = known.get(&peer_id) {
+                        candidate_addresses.extend(discovered.addresses.iter().cloned());
+                    }
+                    candidate_addresses = canonical_addresses(peer_id, candidate_addresses);
+                    debug(format_args!(
+                        "identified {peer_id}; relay={} addresses={}",
+                        info.protocols.iter().any(|protocol| protocol.as_ref() == RELAY_HOP_PROTOCOL),
+                        candidate_addresses.len(),
+                    ));
+                    if info.protocols.iter().any(|protocol| protocol.as_ref() == RELAY_HOP_PROTOCOL)
+                        && !candidate_addresses.is_empty()
+                        && sender.send(RelayCandidate {
+                            peer_id,
+                            addresses: candidate_addresses,
+                        }).await.is_err()
+                    {
+                        return;
+                    }
+                }
+                SwarmEvent::OutgoingConnectionError { peer_id: Some(peer_id), error, .. } => {
+                    debug(format_args!("dial to {peer_id} failed: {error}"));
+                    pending.remove(&peer_id);
+                }
+                SwarmEvent::ConnectionClosed { peer_id, .. } => {
+                    pending.remove(&peer_id);
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+fn debug(message: std::fmt::Arguments<'_>) {
+    if std::env::var_os("MAKA_PEER_DISCOVERY_DEBUG").is_some() {
+        eprintln!("[peer-relay-discovery] {message}");
+    }
+}
+
+fn remember(
+    peer_id: PeerId,
+    discovered: Vec<Multiaddr>,
+    known: &mut HashMap<PeerId, KnownPeer>,
+    queue: &mut VecDeque<PeerId>,
+    now: Instant,
+) {
+    let discovered = canonical_addresses(peer_id, discovered);
+    remember_canonical(peer_id, discovered, known, queue, now);
+}
+
+fn remember_canonical(
+    peer_id: PeerId,
+    mut discovered: Vec<Multiaddr>,
+    known: &mut HashMap<PeerId, KnownPeer>,
+    queue: &mut VecDeque<PeerId>,
+    now: Instant,
+) {
+    discovered.truncate(MAX_ADDRESSES_PER_PEER);
+    if discovered.is_empty() || !known.contains_key(&peer_id) && known.len() >= MAX_KNOWN_PEERS {
+        return;
+    }
+    match known.entry(peer_id) {
+        Entry::Occupied(mut entry) => {
+            for address in discovered {
+                if entry.get().addresses.len() >= MAX_ADDRESSES_PER_PEER {
+                    break;
+                }
+                if !entry.get().addresses.contains(&address) {
+                    entry.get_mut().addresses.push(address);
+                }
+            }
+        }
+        Entry::Vacant(entry) => {
+            entry.insert(KnownPeer {
+                addresses: discovered,
+                first_seen: now,
+            });
+            queue.push_back(peer_id);
+        }
+    }
+}
+
+fn expire_known_peers(
+    known: &mut HashMap<PeerId, KnownPeer>,
+    pending: &HashSet<PeerId>,
+    now: Instant,
+) {
+    known.retain(|peer_id, peer| {
+        pending.contains(peer_id) || now.duration_since(peer.first_seen) < PROBE_REVISIT_INTERVAL
+    });
+}
+
+fn start_probes(
+    swarm: &mut Swarm<Behaviour>,
+    known: &HashMap<PeerId, KnownPeer>,
+    queue: &mut VecDeque<PeerId>,
+    pending: &mut HashSet<PeerId>,
+) {
+    while pending.len() < MAX_PENDING_PROBES {
+        let Some(peer_id) = queue.pop_front() else {
+            break;
+        };
+        let Some(peer) = known.get(&peer_id) else {
+            continue;
+        };
+        let options = DialOpts::peer_id(peer_id)
+            .addresses(peer.addresses.clone())
+            .build();
+        if swarm.dial(options).is_ok() {
+            pending.insert(peer_id);
+        }
+    }
+}
+
+fn canonical_addresses(peer_id: PeerId, addresses: Vec<Multiaddr>) -> Vec<Multiaddr> {
+    let mut result = Vec::new();
+    for address in addresses {
+        if address
+            .iter()
+            .any(|protocol| matches!(protocol, libp2p::multiaddr::Protocol::P2pCircuit))
+        {
+            continue;
+        }
+        let address = address_with_peer(address, peer_id);
+        if has_literal_ip_host(&address)
+            && supported_public_relay_address(&address)
+            && !result.contains(&address)
+        {
+            result.push(address);
+        }
+    }
+    result
+}
+
+fn allowed_kad_dial_address(peer_id: Option<PeerId>, address: &Multiaddr) -> bool {
+    let Some(peer_id) = peer_id else {
+        return false;
+    };
+    let canonical = address_with_peer(address.clone(), peer_id);
+    if supported_public_relay_address(&canonical) {
+        return true;
+    }
+    AMINO_BOOTSTRAPS.iter().any(|value| {
+        value
+            .parse::<Multiaddr>()
+            .is_ok_and(|bootstrap| bootstrap == canonical)
+    })
+}
+
+fn terminal_peer_id(address: &Multiaddr) -> Option<PeerId> {
+    match address.iter().last()? {
+        libp2p::multiaddr::Protocol::P2p(peer_id) => Some(peer_id),
+        _ => None,
+    }
+}
+
+fn has_literal_ip_host(address: &Multiaddr) -> bool {
+    matches!(
+        address.iter().next(),
+        Some(libp2p::multiaddr::Protocol::Ip4(_) | libp2p::multiaddr::Protocol::Ip6(_))
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn kad_dials_only_public_addresses_or_exact_bootstraps() {
+        let peer = PeerId::random();
+        let public: Multiaddr = "/ip4/1.1.1.1/tcp/4001".parse().expect("public address");
+        let private: Multiaddr = "/ip4/127.0.0.1/tcp/4001".parse().expect("private address");
+        let dns: Multiaddr = "/dns4/attacker.example/tcp/4001"
+            .parse()
+            .expect("DNS address");
+
+        assert!(allowed_kad_dial_address(Some(peer), &public));
+        assert!(!allowed_kad_dial_address(Some(peer), &private));
+        assert!(!allowed_kad_dial_address(Some(peer), &dns));
+
+        let bootstrap: Multiaddr = AMINO_BOOTSTRAPS[0]
+            .parse()
+            .expect("constant bootstrap address");
+        let bootstrap_peer = terminal_peer_id(&bootstrap).expect("bootstrap peer identity");
+        assert!(allowed_kad_dial_address(Some(bootstrap_peer), &bootstrap,));
+        assert!(!allowed_kad_dial_address(Some(peer), &bootstrap));
+        assert_eq!(
+            discovery_identify_config(identity::Keypair::generate_ed25519().public()).cache_size(),
+            0,
+        );
+    }
+}
diff --git a/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv b/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
index b5b200eea..16be61345 100644
--- a/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
+++ b/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ arrayref@0.3.9		X
 asn1-rs@0.7.2	X				X			
 asn1-rs-derive@0.6.0	X				X			
 asn1-rs-impl@0.2.0	X				X			
+asn1_der@0.7.7		X			X			
 async-trait@0.1.92	X				X			
 asynchronous-codec@0.7.0					X			
 autocfg@1.5.1	X				X			
@@ -30,6 +31,7 @@ critical-section@1.2.0	X				X
 crossbeam-channel@0.5.16	X				X			
 crossbeam-epoch@0.9.20	X				X			
 crossbeam-utils@0.8.22	X				X			
+crunchy@0.2.4					X			
 crypto-common@0.1.7	X				X			
 ctor@1.0.13	X				X			
 curve25519-dalek@4.1.3			X					
@@ -71,6 +73,7 @@ getrandom@0.4.3	X				X
 hashbrown@0.15.5	X				X			
 hashlink@0.10.0	X				X			
 heck@0.5.0	X				X			
+hex@0.4.3	X				X			
 hickory-proto@0.25.2	X				X			
 hickory-resolver@0.25.2	X				X			
 hkdf@0.12.4	X				X			
@@ -101,6 +104,7 @@ libp2p-dcutr@0.14.1					X
 libp2p-dns@0.44.0					X			
 libp2p-identify@0.47.0					X			
 libp2p-identity@0.2.14					X			
+libp2p-kad@0.48.0					X			
 libp2p-noise@0.46.1					X			
 libp2p-ping@0.47.0					X			
 libp2p-quic@0.13.1					X			
@@ -221,6 +225,7 @@ tracing@0.1.44					X
 tracing-attributes@0.1.31					X			
 tracing-core@0.1.36					X			
 typenum@1.20.1	X				X			
+uint@0.10.1	X				X			
 unicode-ident@1.0.24	X				X	X		
 unicode-segmentation@1.13.3	X				X			
 unsigned-varint@0.7.2					X			
diff --git a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
index b6cab8d19..3af1c6add 100644
--- a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
+++ b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
@@ -5,8 +5,8 @@ Generated by scripts/generate-runtime-host-peer-notices.mjs from the exact
 four-target production dependency inventory. Do not edit this file by hand.
 
 Manifest: native/runtime-host-peer/Cargo.toml
-Cargo.lock SHA-256: 07524c9f4eac723344e6fd8746a2f7cfed692feaed1785ecbfd45e0ecbe20981
-Inventory SHA-256: eb4b2941cf0ed87153aff3ac5d32697dde3ba105e388ff96b9584c65882834f1
+Cargo.lock SHA-256: f475d62519bef1d83c2e33b17f1b1eeffe7a03370b041dabea887b7f57aa43ff
+Inventory SHA-256: 4078d1a287187fbfe21e4937305dabf6e81afac6f2f45388b47a04f311cd8223
 
 Packages
 --------
@@ -44,6 +44,12 @@ SPDX license: MIT/Apache-2.0
 Source: https://github.com/rusticata/asn1-rs.git
 License text: 59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018 (Apache-2.0.txt)
 
+asn1_der@0.7.7
+--------------
+SPDX license: BSD-2-Clause OR MIT
+Source: https://github.com/KizzyCode/asn1_der-rust
+License text: b70b1cc58b6d09afc7bfba35c45cc8613d121563acc5b9cbc519ae3b9a10af8d (MIT.txt)
+
 async-trait@0.1.92
 ------------------
 SPDX license: MIT OR Apache-2.0
@@ -219,6 +225,12 @@ Source: https://github.com/crossbeam-rs/crossbeam
 License text: 769f80b5bcb42ed0af4e4d2fd74e1ac9bf843cb80c5a29219d1ef3544428a6bb (LICENSE-APACHE)
 License text: 5a7d13c6710cdec29e74eb3172b80712049c34099ab7259661ada051fc90777b (LICENSE-MIT)
 
+crunchy@0.2.4
+-------------
+SPDX license: MIT
+Source: https://github.com/eira-fransham/crunchy
+License text: e35e8f8a37b546c01c7eb82d11d7554bb6292a29fc6147c21765d1e9c40b2bae (LICENSE)
+
 crypto-common@0.1.7
 -------------------
 SPDX license: MIT OR Apache-2.0
@@ -497,6 +509,13 @@ Source: https://github.com/withoutboats/heck
 License text: 769f80b5bcb42ed0af4e4d2fd74e1ac9bf843cb80c5a29219d1ef3544428a6bb (LICENSE-APACHE)
 License text: 033a9383ff21d1e4811d98dd2e0081b0ba0d9c9a31d6c7ba9e63e3488479bf29 (LICENSE-MIT)
 
+hex@0.4.3
+---------
+SPDX license: MIT OR Apache-2.0
+Source: https://github.com/KokaKiwi/rust-hex
+License text: c5accbbd8546e94c34aed24afe689a617627d18eed5a6c48277e48db57c23851 (LICENSE-APACHE)
+License text: 9dde4a6ad496606322ae0b61b70dc7123fa231be543caa0896d567e34d20fbf8 (LICENSE-MIT)
+
 hickory-proto@0.25.2
 --------------------
 SPDX license: MIT OR Apache-2.0
@@ -689,6 +708,12 @@ SPDX license: MIT
 Source: https://github.com/libp2p/rust-libp2p
 License text: 80f847ab8b3d1802dcf33aad57b13d3e5cbae4969fb5990a5ff24ac7ae98ddb6 (MIT.txt)
 
+libp2p-kad@0.48.0
+-----------------
+SPDX license: MIT
+Source: https://github.com/libp2p/rust-libp2p
+License text: 4104e268c110e0948f82580e90eeb8a4106c388c4c0f6a1b7ed2e4dd1360872a (MIT.txt)
+
 libp2p-noise@0.46.1
 -------------------
 SPDX license: MIT
@@ -1483,6 +1508,12 @@ License text: db11fec9946737df39ca3898d9cd8c10ec6f6c3a884a6802b0ad0b81b4e8f23a (
 License text: 516b24e051bf5630880ebbd55c40a25ce9552ebaf8970a53e8976eb70e522406 (LICENSE-APACHE)
 License text: 87ebb37988efc3285a41635c6a9fd38147e1e581b5f0556a284d5ab8fa5cdd4c (LICENSE-MIT)
 
+uint@0.10.1
+-----------
+SPDX license: MIT OR Apache-2.0
+Source: https://github.com/paritytech/parity-common
+License text: 59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018 (Apache-2.0.txt)
+
 unicode-ident@1.0.24
 --------------------
 SPDX license: (MIT OR Apache-2.0) AND Unicode-3.0
@@ -3831,6 +3862,7 @@ Used by:
 - libp2p-core@0.43.2 (MIT.txt)
 - libp2p-dns@0.44.0 (MIT.txt)
 - libp2p-identify@0.47.0 (MIT.txt)
+- libp2p-kad@0.48.0 (MIT.txt)
 - libp2p-noise@0.46.1 (MIT.txt)
 - libp2p-ping@0.47.0 (MIT.txt)
 - libp2p-quic@0.13.1 (MIT.txt)
@@ -5215,6 +5247,7 @@ Used by:
 - if-watch@3.2.2 (Apache-2.0.txt)
 - sha2@0.10.9 (LICENSE-APACHE)
 - signature@2.2.0 (LICENSE-APACHE)
+- uint@0.10.1 (Apache-2.0.txt)
 - yamux@0.12.1 (Apache-2.0.txt)
 - yamux@0.13.10 (Apache-2.0.txt)
 - yasna@0.5.2 (Apache-2.0.txt)
@@ -9450,6 +9483,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+9dde4a6ad496606322ae0b61b70dc7123fa231be543caa0896d567e34d20fbf8
+----------------------------------------------------------------
+Used by:
+- hex@0.4.3 (LICENSE-MIT)
+
+Copyright (c) 2013-2014 The Rust Project Developers.
+Copyright (c) 2015-2020 The rust-hex Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 9e0027807b0c548ac1df1bd85fe0d72da6665498b06ebf8a64d5cb841f39fcef
 ----------------------------------------------------------------
 Used by:
@@ -9998,6 +10057,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+b70b1cc58b6d09afc7bfba35c45cc8613d121563acc5b9cbc519ae3b9a10af8d
+----------------------------------------------------------------
+Used by:
+- asn1_der@0.7.7 (MIT.txt)
+
+Copyright notice: not included in the published crate
+Cargo authors: KizzyCode Software Labs./Keziah Biermann <de...@kizzycode.de>
+Source: https://github.com/KizzyCode/asn1_der-rust
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 b8777f2b9ad6f8ef48e077876144281fb9683f77b5944b40d720706e01a18d80
 ----------------------------------------------------------------
 Used by:
@@ -10405,6 +10491,7 @@ c5accbbd8546e94c34aed24afe689a617627d18eed5a6c48277e48db57c23851
 ----------------------------------------------------------------
 Used by:
 - enum-as-inner@0.6.1 (LICENSE-APACHE)
+- hex@0.4.3 (LICENSE-APACHE)
 - resolv-conf@0.7.6 (LICENSE-APACHE)
 
                                  Apache License
@@ -12319,6 +12406,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+e35e8f8a37b546c01c7eb82d11d7554bb6292a29fc6147c21765d1e9c40b2bae
+----------------------------------------------------------------
+Used by:
+- crunchy@0.2.4 (LICENSE)
+
+The MIT License (MIT)
+
+Copyright 2017-2023 Eira Fransham.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 e915669a595b11a200873df8286561881b0e04932f6412a585db6297ba0bc97c
 ----------------------------------------------------------------
 Used by:
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 2db5fee81..2c3945ac2 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -38,6 +38,7 @@ import {
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
   RUNTIME_HOST_SERVICE_LOG_MAX_BYTES,
@@ -160,6 +161,7 @@ describe('managed Runtime Host service', () => {
         '--listen',
         '/ip4/0.0.0.0/udp/44001/quic-v1',
         '--clear-coordination-relays',
+        '--no-automatic-relay-discovery',
         '--allow-interrupt-active-tasks',
         '--expected-service-id',
         'b'.repeat(64),
@@ -179,6 +181,7 @@ describe('managed Runtime Host service', () => {
         framed: true,
         listenAddresses: ['/ip4/0.0.0.0/udp/44001/quic-v1'],
         coordinationRelays: [],
+        automaticRelayDiscovery: false,
         allowInterruptActiveTasks: true,
         managedRootId: 'a'.repeat(64),
         operatorDeploymentId: '00000000-0000-4000-8000-000000000001',
@@ -1795,6 +1798,16 @@ describe('managed Runtime Host service', () => {
       [RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY],
     );
 
+    process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] =
+      RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY;
+    const relayDiscoveryFrame = decodeRuntimeHostServiceManagementFrame(await run());
+    assert.deepEqual(
+      relayDiscoveryFrame?.kind === 'result' && relayDiscoveryFrame.action === 'status'
+        ? relayDiscoveryFrame.operatorCapabilities
+        : undefined,
+      [RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY],
+    );
+
     process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] =
       RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY;
     process.env[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV] = '1';
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index f2d9e286e..7a7f1881f 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -213,6 +213,13 @@ function helpText(cliCommand: string): string {
     '  --websocket-path <path>       Persist the upgrade path (default: /runtime-host)',
     '  --json                        Emit framed machine-readable progress and result records',
     '',
+    'Managed Runtime Host direct-peer options:',
+    '  --listen <multiaddr>          Persist a listener address (repeatable)',
+    '  --coordination-relay <addr>   Prefer a Circuit Relay v2 address (repeatable)',
+    '  --clear-coordination-relays   Remove every manually configured relay',
+    '  --automatic-relay-discovery   Enable best-effort public relay discovery',
+    '  --no-automatic-relay-discovery  Disable public discovery and retain manual relays',
+    '',
     'Runtime Host access issue options:',
     '  --root <path>                 Select the canonical data root',
     '  --kind <kind>                 remote-owner or capability-provider',
@@ -317,6 +324,7 @@ export async function runMakaCli(
                   expectedPeerId: peer.peerId,
                   listenAddresses: peer.listenAddresses,
                   coordinationRelays: peer.coordinationRelays,
+                  automaticRelayDiscovery: peer.automaticRelayDiscovery,
                   meshDataRoot: join(config.deploymentRoot, 'peer-mesh'),
                 },
               }
@@ -475,6 +483,10 @@ export async function runMakaCli(
         operatorDeploymentId: command.operatorDeploymentId,
         listenAddresses: command.listenAddresses,
         ...(command.coordinationRelays ? { coordinationRelays: command.coordinationRelays } : {}),
+        ...(command.automaticRelayDiscovery === undefined
+          ? {}
+          : { automaticRelayDiscovery: command.automaticRelayDiscovery }),
+        ...(command.relayDiscoveryStatus ? { relayDiscoveryStatus: true } : {}),
         ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}),
         ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
       });
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index ca9ea69d1..d2bc06b03 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -165,6 +165,8 @@ export type RuntimeHostCliCommand =
       operatorDeploymentId: string;
       listenAddresses: string[];
       coordinationRelays?: string[];
+      automaticRelayDiscovery?: boolean;
+      relayDiscoveryStatus?: true;
       expectedTarget?: RuntimeHostManagedServiceTarget;
       allowInterruptActiveTasks?: true;
     }
@@ -892,6 +894,8 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
   const listenAddresses: string[] = [];
   const coordinationRelays: string[] = [];
   let clearCoordinationRelays = false;
+  let automaticRelayDiscovery: boolean | undefined;
+  let relayDiscoveryStatus = false;
   let allowInterruptActiveTasks = false;
   const options = parseManagedServiceOptions(argv.slice(1), {
     allowConfiguration: false,
@@ -905,6 +909,22 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
         if (allowInterruptActiveTasks) return error('Duplicate --allow-interrupt-active-tasks');
         allowInterruptActiveTasks = true;
       },
+      '--automatic-relay-discovery': () => {
+        if (automaticRelayDiscovery !== undefined) {
+          return error('Relay discovery mode was specified more than once');
+        }
+        automaticRelayDiscovery = true;
+      },
+      '--no-automatic-relay-discovery': () => {
+        if (automaticRelayDiscovery !== undefined) {
+          return error('Relay discovery mode was specified more than once');
+        }
+        automaticRelayDiscovery = false;
+      },
+      '--relay-discovery-status': () => {
+        if (relayDiscoveryStatus) return error('Duplicate --relay-discovery-status');
+        relayDiscoveryStatus = true;
+      },
     },
     valueOptions: {
       '--client-data-root': (value) => {
@@ -924,6 +944,9 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
   if (options.framed && (action === 'rotate' || action === 'descriptor')) {
     return error(`runtime-host service peer ${action} does not support --framed`);
   }
+  if (relayDiscoveryStatus && !options.framed) {
+    return error('--relay-discovery-status requires --framed');
+  }
   if (listenAddresses.some(hasEphemeralRuntimeHostPeerPort)) {
     return error('--listen requires a stable non-zero transport port');
   }
@@ -932,11 +955,12 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
   }
   if (
     action !== 'enable' &&
-    (listenAddresses.length > 0 || coordinationRelays.length > 0 || clearCoordinationRelays)
+    (listenAddresses.length > 0 ||
+      coordinationRelays.length > 0 ||
+      clearCoordinationRelays ||
+      automaticRelayDiscovery !== undefined)
   ) {
-    return error(
-      '--listen, --coordination-relay, and --clear-coordination-relays are only valid with peer enable',
-    );
+    return error('Peer listener options are only valid with peer enable');
   }
   if (allowInterruptActiveTasks && action !== 'enable' && action !== 'disable') {
     return error('--allow-interrupt-active-tasks is only valid with peer enable or peer disable');
@@ -969,6 +993,8 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
       : coordinationRelays.length > 0
         ? { coordinationRelays }
         : {}),
+    ...(automaticRelayDiscovery === undefined ? {} : { automaticRelayDiscovery }),
+    ...(relayDiscoveryStatus ? { relayDiscoveryStatus: true as const } : {}),
     ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}),
     ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
   };
diff --git a/packages/cli/src/runtime-host-peer-management-command.ts b/packages/cli/src/runtime-host-peer-management-command.ts
index d80b0b748..80a5de2a5 100644
--- a/packages/cli/src/runtime-host-peer-management-command.ts
+++ b/packages/cli/src/runtime-host-peer-management-command.ts
@@ -73,6 +73,8 @@ export interface RuntimeHostPeerManagementCliOptions {
   readonly operatorDeploymentId: string;
   readonly listenAddresses: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
+  readonly relayDiscoveryStatus?: boolean;
   readonly expectedTarget?: RuntimeHostManagedServiceTarget;
   readonly allowInterruptActiveTasks?: boolean;
 }
@@ -251,13 +253,14 @@ async function runCanonicalRuntimeHostPeerManagementLocked(
     if (options.action === 'descriptor') {
       throw new TypeError('Direct-peer descriptor does not support framed output');
     }
+    const framedStatus = options.relayDiscoveryStatus ? status : omitRelayDiscoveryStatus(status);
     writePeerFrame(
       options.action === 'status'
-        ? { kind: 'result', action: options.action, status }
+        ? { kind: 'result', action: options.action, status: framedStatus }
         : {
             kind: 'result',
             action: options.action,
-            status,
+            status: framedStatus,
             restarted: restarted!,
           },
       deps,
@@ -274,6 +277,11 @@ async function runCanonicalRuntimeHostPeerManagementLocked(
   return 0;
 }
 
+function omitRelayDiscoveryStatus(status: RuntimeHostPeerStatus): RuntimeHostPeerStatus {
+  const { automaticRelayDiscovery: _automaticRelayDiscovery, ...legacy } = status;
+  return legacy;
+}
+
 async function prepareCanonicalPeer(
   options: RuntimeHostPeerManagementCliOptions,
   config: Awaited<ReturnType<typeof resolveRuntimeHostManagedDeployment>>['config'],
@@ -312,6 +320,8 @@ async function prepareCanonicalPeer(
     coordinationRelays: [
       ...new Set(options.coordinationRelays ?? current?.coordinationRelays ?? []),
     ],
+    automaticRelayDiscovery:
+      options.automaticRelayDiscovery ?? current?.automaticRelayDiscovery ?? true,
   };
 }
 
@@ -340,6 +350,7 @@ async function readCanonicalPeerStatus(
       serviceState: result.service.state,
       routeHints: [],
       coordinationRelays: [],
+      automaticRelayDiscovery: true,
     };
   }
   return {
@@ -349,6 +360,7 @@ async function readCanonicalPeerStatus(
     rootId: config.root.id,
     routeHints: expandWildcardListenAddresses(peer.listenAddresses),
     coordinationRelays: [...peer.coordinationRelays],
+    automaticRelayDiscovery: peer.automaticRelayDiscovery,
   };
 }
 
diff --git a/packages/cli/src/runtime-host-service-command.ts b/packages/cli/src/runtime-host-service-command.ts
index a84e335b8..53059736a 100644
--- a/packages/cli/src/runtime-host-service-command.ts
+++ b/packages/cli/src/runtime-host-service-command.ts
@@ -49,6 +49,7 @@ export interface RuntimeHostServiceCliOptions {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly meshDataRoot?: string;
   };
 }
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 99dde97f0..42260ca5d 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -25,6 +25,7 @@ import {
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
   RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
@@ -423,6 +424,7 @@ function requestedOperatorCapabilities(): {
   if (
     requested === RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY ||
     requested === RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY ||
+    requested === RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY ||
     requested === RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY
   ) {
     capabilities.push(requested);
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index 55c912172..275213356 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -651,6 +651,7 @@ async function prepareSupervisedDirectPeer(
     coordinationRelays: [
       ...(options.directPeer?.coordinationRelays ?? current?.coordinationRelays ?? []),
     ],
+    automaticRelayDiscovery: current?.automaticRelayDiscovery ?? true,
   };
 }
 
diff --git a/packages/runtime-host/src/__tests__/peer-management-frame.test.ts b/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
index 2bd158ffb..40ea81a05 100644
--- a/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
@@ -36,6 +36,7 @@ test('peer management frames preserve a bounded machine-readable descriptor', ()
       rootId: 'a'.repeat(64),
       routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
       coordinationRelays: [],
+      automaticRelayDiscovery: true,
     },
   };
   assert.deepEqual(
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index 1568f677b..7dad8c138 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -60,6 +60,7 @@ module.exports = {
     return {
       peerId: 'client',
       listenAddresses: [],
+      activeCoordinationRelays: [],
       connect: ({ requestId, peerId, routeHints, coordinationRelays }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays });
         if (peerId === 'ready') return Promise.resolve(stream);
@@ -198,6 +199,7 @@ module.exports = {
   startPeerEndpoint: () => ({
     peerId: 'peer',
     listenAddresses: [],
+    activeCoordinationRelays: [],
     connect: async () => stream,
     connectMeshControl: async () => stream,
     cancelConnect: async () => true,
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index 6464bfdfb..59020d745 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -76,6 +76,7 @@ export function createRuntimeHostPeerClientFromEnvironment(
   options: {
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly routeResolver?: RuntimeHostPeerRouteResolver;
   } = {},
 ): RuntimeHostPeerClient {
@@ -96,6 +97,7 @@ export function createRuntimeHostPeerClient(input: {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
   readonly routeResolver?: RuntimeHostPeerRouteResolver;
 }): RuntimeHostPeerClient {
   return new RuntimeHostPeerClientImpl(input);
@@ -107,6 +109,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
   readonly #expectedPeerId: string | undefined;
   readonly #listenAddresses: readonly string[] | undefined;
   readonly #coordinationRelays: readonly string[] | undefined;
+  readonly #automaticRelayDiscovery: boolean;
   readonly #routeResolver: RuntimeHostPeerRouteResolver | undefined;
   #endpoint: RuntimeHostPeerNativeEndpoint | undefined;
   #draining: Promise<void> | undefined;
@@ -125,6 +128,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly routeResolver?: RuntimeHostPeerRouteResolver;
   }) {
     this.#nativePath = input.nativePath;
@@ -132,6 +136,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     this.#expectedPeerId = input.expectedPeerId;
     this.#listenAddresses = input.listenAddresses;
     this.#coordinationRelays = input.coordinationRelays;
+    this.#automaticRelayDiscovery = input.automaticRelayDiscovery ?? false;
     this.#routeResolver = input.routeResolver;
   }
 
@@ -144,7 +149,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     return Object.freeze({
       peerId: endpoint.peerId,
       listenAddresses: Object.freeze([...endpoint.listenAddresses]),
-      coordinationRelays: Object.freeze([...(this.#coordinationRelays ?? [])]),
+      coordinationRelays: Object.freeze([...endpoint.activeCoordinationRelays]),
     });
   }
 
@@ -328,6 +333,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
       ...(this.#expectedPeerId ? { expectedPeerId: this.#expectedPeerId } : {}),
       ...(this.#listenAddresses ? { listenAddresses: this.#listenAddresses } : {}),
       ...(this.#coordinationRelays ? { coordinationRelays: this.#coordinationRelays } : {}),
+      automaticRelayDiscovery: this.#automaticRelayDiscovery,
     });
     this.#endpoint = endpoint;
     this.#draining = this.#drainInbound(endpoint);
diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index 20e73a858..90f4d4a12 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -59,6 +59,7 @@ export {
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV,
   RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
   RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY,
diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts
index 9aaa6d297..69de37ee7 100644
--- a/packages/runtime-host/src/operator/managed-deployment.ts
+++ b/packages/runtime-host/src/operator/managed-deployment.ts
@@ -176,6 +176,7 @@ const managedDeploymentConfigSchema = z
             peerId: boundedText(256),
             listenAddresses: z.array(boundedText(2_048)).min(1).max(16),
             coordinationRelays: z.array(boundedText(2_048)).max(16),
+            automaticRelayDiscovery: z.boolean().default(true),
           })
           .strict()
           .optional(),
diff --git a/packages/runtime-host/src/operator/peer-management-frame.ts b/packages/runtime-host/src/operator/peer-management-frame.ts
index d216179ec..6c53370da 100644
--- a/packages/runtime-host/src/operator/peer-management-frame.ts
+++ b/packages/runtime-host/src/operator/peer-management-frame.ts
@@ -37,6 +37,7 @@ const STATUS_SCHEMA = z
       .optional(),
     routeHints: z.array(ADDRESS_SCHEMA).max(16),
     coordinationRelays: z.array(ADDRESS_SCHEMA).max(16),
+    automaticRelayDiscovery: z.boolean().optional(),
   })
   .strict();
 
diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts
index f8d431e11..dfbe301a0 100644
--- a/packages/runtime-host/src/operator/service-management-frame.ts
+++ b/packages/runtime-host/src/operator/service-management-frame.ts
@@ -35,6 +35,7 @@ export const RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV =
   'MAKA_RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST';
 export const RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY = 'process-lifetime-lock-v1';
 export const RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY = 'peer-management-v1';
+export const RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY = 'peer-relay-discovery-v1';
 export const RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY = 'update-scheduler-v1';
 export const RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV =
   'MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST';
@@ -91,6 +92,7 @@ const SERVICE_STATES = ['not_installed', ...INSTALLED_SERVICE_STATES] as const;
 const OPERATOR_CAPABILITIES = [
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY,
   RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
   RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY,
 ] as const;
diff --git a/packages/runtime-host/src/peer-mesh/owner.ts b/packages/runtime-host/src/peer-mesh/owner.ts
index 8c62ce623..3f1560154 100644
--- a/packages/runtime-host/src/peer-mesh/owner.ts
+++ b/packages/runtime-host/src/peer-mesh/owner.ts
@@ -41,6 +41,7 @@ export async function openRuntimeHostPeerMeshOwner(input: {
   readonly dataRoot: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }): Promise<RuntimeHostPeerMeshOwner> {
   let mesh: PeerMeshNode | undefined;
   let resolverMesh: PeerMeshNode | undefined;
@@ -55,6 +56,9 @@ export async function openRuntimeHostPeerMeshOwner(input: {
       ...(input.expectedPeerId ? { expectedPeerId: input.expectedPeerId } : {}),
       ...(input.listenAddresses ? { listenAddresses: input.listenAddresses } : {}),
       ...(input.coordinationRelays ? { coordinationRelays: input.coordinationRelays } : {}),
+      ...(input.automaticRelayDiscovery === undefined
+        ? {}
+        : { automaticRelayDiscovery: input.automaticRelayDiscovery }),
       routeResolver: {
         resolveRoutes: (peerId) => resolverMesh?.resolveRoutes(peerId),
       },
diff --git a/packages/runtime-host/src/server/peer-listener.ts b/packages/runtime-host/src/server/peer-listener.ts
index 5e538bbab..11db805ab 100644
--- a/packages/runtime-host/src/server/peer-listener.ts
+++ b/packages/runtime-host/src/server/peer-listener.ts
@@ -42,6 +42,7 @@ export interface RuntimeHostPeerListenerConfiguration {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }
 
 export type RuntimeHostPeerListenerEndpointOptions =
diff --git a/packages/runtime-host/src/transport/peer-native.ts b/packages/runtime-host/src/transport/peer-native.ts
index 0b1106264..c9f362dc9 100644
--- a/packages/runtime-host/src/transport/peer-native.ts
+++ b/packages/runtime-host/src/transport/peer-native.ts
@@ -62,6 +62,7 @@ export interface RuntimeHostPeerIdentityProof {
 export interface RuntimeHostPeerNativeEndpoint {
   readonly peerId: string;
   readonly listenAddresses: readonly string[];
+  readonly activeCoordinationRelays: readonly string[];
   connect(options: {
     readonly requestId: number;
     readonly peerId: string;
@@ -100,6 +101,7 @@ interface RuntimeHostPeerNativeModule {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
   }): unknown;
 }
 
@@ -170,6 +172,7 @@ export function startRuntimeHostPeerEndpoint(input: {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }): RuntimeHostPeerNativeEndpoint {
   try {
     const endpoint = loadNativeModule(input.nativePath).startPeerEndpoint({
@@ -177,6 +180,9 @@ export function startRuntimeHostPeerEndpoint(input: {
       ...(input.expectedPeerId ? { expectedPeerId: input.expectedPeerId } : {}),
       ...(input.listenAddresses ? { listenAddresses: input.listenAddresses } : {}),
       ...(input.coordinationRelays ? { coordinationRelays: input.coordinationRelays } : {}),
+      ...(input.automaticRelayDiscovery === undefined
+        ? {}
+        : { automaticRelayDiscovery: input.automaticRelayDiscovery }),
     });
     if (!isPeerNativeEndpoint(endpoint)) {
       throw new RuntimeHostPeerError(
@@ -442,6 +448,9 @@ function isPeerNativeEndpoint(value: unknown): value is RuntimeHostPeerNativeEnd
     'listenAddresses' in value &&
     Array.isArray(value.listenAddresses) &&
     value.listenAddresses.every((address) => typeof address === 'string') &&
+    'activeCoordinationRelays' in value &&
+    Array.isArray(value.activeCoordinationRelays) &&
+    value.activeCoordinationRelays.every((address) => typeof address === 'string') &&
     'connect' in value &&
     typeof value.connect === 'function' &&
     'connectMeshControl' in value &&
diff --git a/scripts/generate-runtime-host-peer-notices.mjs b/scripts/generate-runtime-host-peer-notices.mjs
index ce9a7bb0f..5cf6b719d 100644
--- a/scripts/generate-runtime-host-peer-notices.mjs
+++ b/scripts/generate-runtime-host-peer-notices.mjs
@@ -104,7 +104,11 @@ function declaredLicenseFallback(pkg, source) {
       text: apache2LicenseText,
     };
   }
-  if (pkg.license === 'MIT') {
+  if (
+    pkg.license
+      .split(/\s+OR\s+/u)
+      .some((alternative) => alternative.replaceAll(/[()]/gu, '').trim() === 'MIT')
+  ) {
     const authors = pkg.authors.length > 0 ? pkg.authors.join(', ') : 'not included';
     return {
       name: 'MIT.txt',


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org