aquaman-proxy 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +28 -5
  2. package/dist/broker-scope.d.ts +89 -0
  3. package/dist/broker-scope.d.ts.map +1 -0
  4. package/dist/broker-scope.js +186 -0
  5. package/dist/broker-scope.js.map +1 -0
  6. package/dist/cli/index.js +374 -35
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +1 -1
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/types.d.ts +14 -0
  13. package/dist/core/types.d.ts.map +1 -1
  14. package/dist/core/utils/config.d.ts +9 -0
  15. package/dist/core/utils/config.d.ts.map +1 -1
  16. package/dist/core/utils/config.js +29 -1
  17. package/dist/core/utils/config.js.map +1 -1
  18. package/dist/core/utils/index.d.ts +1 -1
  19. package/dist/core/utils/index.d.ts.map +1 -1
  20. package/dist/core/utils/index.js +1 -1
  21. package/dist/core/utils/index.js.map +1 -1
  22. package/dist/daemon.d.ts +36 -0
  23. package/dist/daemon.d.ts.map +1 -1
  24. package/dist/daemon.js +136 -15
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/hermes/config-writer.d.ts +19 -0
  27. package/dist/hermes/config-writer.d.ts.map +1 -1
  28. package/dist/hermes/config-writer.js +51 -1
  29. package/dist/hermes/config-writer.js.map +1 -1
  30. package/dist/index.d.ts +7 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +9 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/migration/openclaw-migrator.d.ts.map +1 -1
  35. package/dist/migration/openclaw-migrator.js +7 -6
  36. package/dist/migration/openclaw-migrator.js.map +1 -1
  37. package/dist/openclaw/channel-routing.d.ts +81 -0
  38. package/dist/openclaw/channel-routing.d.ts.map +1 -0
  39. package/dist/openclaw/channel-routing.js +152 -0
  40. package/dist/openclaw/channel-routing.js.map +1 -0
  41. package/dist/openclaw/integration.d.ts +18 -0
  42. package/dist/openclaw/integration.d.ts.map +1 -1
  43. package/dist/openclaw/integration.js +32 -0
  44. package/dist/openclaw/integration.js.map +1 -1
  45. package/dist/openclaw/secretref.d.ts +35 -6
  46. package/dist/openclaw/secretref.d.ts.map +1 -1
  47. package/dist/openclaw/secretref.js +73 -8
  48. package/dist/openclaw/secretref.js.map +1 -1
  49. package/dist/request-policy.d.ts +7 -0
  50. package/dist/request-policy.d.ts.map +1 -1
  51. package/dist/request-policy.js +31 -2
  52. package/dist/request-policy.js.map +1 -1
  53. package/dist/service-registry.d.ts +8 -0
  54. package/dist/service-registry.d.ts.map +1 -1
  55. package/dist/service-registry.js +11 -4
  56. package/dist/service-registry.js.map +1 -1
  57. package/package.json +2 -2
package/dist/cli/index.js CHANGED
@@ -12,15 +12,17 @@ import { Command, Help } from 'commander';
12
12
  import * as path from 'node:path';
13
13
  import * as fs from 'node:fs';
14
14
  import * as http from 'node:http';
15
- import { loadConfig, getConfigDir, ensureConfigDir, getDefaultConfig, expandPath, createAuditLogger, createCredentialStore, saveConfig, generateLoopbackToken, DEFAULT_LOOPBACK_PORT, wrapWithCache, resolveCacheTtl } from '../core/index.js';
15
+ import { loadConfig, getConfigDir, getConfigPath, ensureConfigDir, getDefaultConfig, expandPath, createAuditLogger, createCredentialStore, saveConfig, generateLoopbackToken, DEFAULT_LOOPBACK_PORT, wrapWithCache, resolveCacheTtl } from '../core/index.js';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { createCredentialProxy } from '../daemon.js';
18
18
  import { createServiceRegistry, ServiceRegistry } from '../service-registry.js';
19
- import { createOpenClawIntegration, authProfilesAreSqliteOnly } from '../openclaw/integration.js';
19
+ import { createOpenClawIntegration, authProfilesAreSqliteOnly, legacyAuthProfilesBlockProviders, pluginInstallNeedsCapabilityConsent } from '../openclaw/integration.js';
20
20
  import { supportsSecretRefIntegrations, wireSecretRefProviders, secretRefWiringStatus } from '../openclaw/secretref.js';
21
+ import { CHANNEL_ROUTING_SUPPORTED, wireChannelRouting, channelRoutingStatus } from '../openclaw/channel-routing.js';
21
22
  import { createHermesIntegration, detectHermes } from '../hermes/integration.js';
22
- import { managedScopeShadowedKeys, HERMES_MANAGED_ENV_PATH } from '../hermes/config-writer.js';
23
- import { loadPolicyFromConfig, validatePolicyConfig, getDefaultPolicyPresets, matchPolicy } from '../request-policy.js';
23
+ import { managedScopeShadowedKeys, hermesManagedEnvPath, HERMES_SUPPORTED_SERVICES, hermesSecretSourceRefs } from '../hermes/config-writer.js';
24
+ import { createBrokerScope, parseAquamanRef, defaultProjectsPath } from '../broker-scope.js';
25
+ import { loadPolicyFromConfig, validatePolicyConfig, lintPolicyConfig, getDefaultPolicyPresets, matchPolicy } from '../request-policy.js';
24
26
  import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
25
27
  // Read version from package.json (single source of truth)
26
28
  const __cliFilename = fileURLToPath(import.meta.url);
@@ -101,6 +103,60 @@ function loadLoopbackOptions(config) {
101
103
  }
102
104
  return { port: lb.port || DEFAULT_LOOPBACK_PORT, token: lb.token, host: lb.host || '127.0.0.1' };
103
105
  }
106
+ /** Broker scope for `aquaman daemon`, or undefined when `broker.enabled: false`. */
107
+ function daemonBrokerScope(config) {
108
+ if (config.broker?.enabled === false)
109
+ return undefined;
110
+ return createBrokerScope({
111
+ configPath: getConfigPath(),
112
+ loopbackDeniedServices: HERMES_SUPPORTED_SERVICES,
113
+ });
114
+ }
115
+ /**
116
+ * Add or remove refs in config.yaml `broker.allowedRefs`. Edits the raw file
117
+ * (not loadConfig + saveConfig) so env overrides such as the loopback token
118
+ * are never persisted as a side effect. The daemon re-reads the list on change.
119
+ */
120
+ function updateBrokerAllowedRefs(refs, mode) {
121
+ const invalid = refs.filter(r => !parseAquamanRef(r));
122
+ if (invalid.length > 0) {
123
+ console.error(`Invalid ref${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`);
124
+ console.error('Refs look like aquaman://<service>/<key>, e.g. aquaman://github/token');
125
+ process.exit(1);
126
+ }
127
+ ensureConfigDir();
128
+ const configPath = getConfigPath();
129
+ let raw = {};
130
+ if (fs.existsSync(configPath)) {
131
+ const parsed = yamlParse(fs.readFileSync(configPath, 'utf-8'));
132
+ if (parsed && typeof parsed === 'object')
133
+ raw = parsed;
134
+ }
135
+ const current = new Set(Array.isArray(raw.broker?.allowedRefs) ? raw.broker.allowedRefs : []);
136
+ for (const ref of refs) {
137
+ if (mode === 'allow')
138
+ current.add(ref);
139
+ else
140
+ current.delete(ref);
141
+ }
142
+ raw.broker = { ...(raw.broker && typeof raw.broker === 'object' ? raw.broker : {}), allowedRefs: [...current].sort() };
143
+ fs.writeFileSync(configPath, yamlStringify(raw), { encoding: 'utf-8', mode: 0o600 });
144
+ const verb = mode === 'allow' ? 'Allowed' : 'Revoked';
145
+ for (const ref of refs)
146
+ console.log(`${verb}: ${ref}`);
147
+ if (mode === 'allow') {
148
+ console.log('\nAny process running as you can now fetch these values from `aquaman daemon`. Only allow refs you intend to materialize.');
149
+ }
150
+ if (raw.broker.enabled === false) {
151
+ console.log('Note: broker.enabled is false in config.yaml, so the broker is off regardless.');
152
+ }
153
+ }
154
+ function describeBroker(scope) {
155
+ if (!scope)
156
+ return 'disabled (broker.enabled: false)';
157
+ const n = scope.declared().length;
158
+ return `${n} declared ref${n === 1 ? '' : 's'} (see: aquaman broker list)`;
159
+ }
104
160
  /**
105
161
  * Probe the loopback listener's /_health endpoint (no token required). Returns
106
162
  * true if it responds 200 within a short timeout, false otherwise.
@@ -183,6 +239,23 @@ async function promptSecretInput(prompt) {
183
239
  });
184
240
  });
185
241
  }
242
+ /** One-line explanation of why `openclaw setup` left a channel alone. */
243
+ function describeChannelSkip(reason, channel) {
244
+ switch (reason) {
245
+ case 'no-vault-credential':
246
+ return `no credential in the vault (add it: aquaman credentials add ${channel} bot_token)`;
247
+ case 'user-endpoint':
248
+ return 'it already points at your own endpoint (self-hosted Bot API or regional proxy)';
249
+ case 'token-file':
250
+ return 'its token comes from tokenFile, whose precedence over a config token we will not guess at';
251
+ case 'multi-account':
252
+ return 'multi-account channels need one vault entry per account, which the service model has no room for yet';
253
+ case 'not-wired':
254
+ return 'routable but not wired yet (re-run: aquaman openclaw setup)';
255
+ case 'unroutable':
256
+ return 'this OpenClaw exposes no endpoint override for it, so its traffic cannot be routed';
257
+ }
258
+ }
186
259
  const program = new Command();
187
260
  program
188
261
  .name('aquaman')
@@ -700,6 +773,8 @@ openclaw
700
773
  // Start credential proxy
701
774
  const socketPath = path.join(getConfigDir(), 'proxy.sock');
702
775
  const policyConfig = loadPolicyFromConfig(config);
776
+ // No `broker`: this proxy fronts OpenClaw, and the OpenClaw path never
777
+ // hands credential values out (v0.15.0; see broker-scope.ts).
703
778
  const credentialProxy = createCredentialProxy({
704
779
  socketPath,
705
780
  store: credentialStore,
@@ -841,6 +916,10 @@ program
841
916
  const serviceRegistry = createServiceRegistry({ configPath: config.services.configPath });
842
917
  // Start credential proxy
843
918
  const policyConfig2 = loadPolicyFromConfig(config);
919
+ // The daemon is the only proxy that serves the credential broker, and only
920
+ // for declared refs (projects.yaml + broker.allowedRefs). Over loopback the
921
+ // Hermes LLM-provider keys are never materialized.
922
+ const brokerScope = daemonBrokerScope(config);
844
923
  const credentialProxy = createCredentialProxy({
845
924
  socketPath,
846
925
  store: credentialStore,
@@ -848,6 +927,7 @@ program
848
927
  serviceRegistry,
849
928
  policyConfig: policyConfig2,
850
929
  loopback: loadLoopbackOptions(config),
930
+ broker: brokerScope,
851
931
  onRequest: (info) => {
852
932
  auditLogger.logCredentialAccess('system', 'system', {
853
933
  service: info.service,
@@ -861,6 +941,10 @@ program
861
941
  // Write PID file
862
942
  writePidFile();
863
943
  console.log(`Credential proxy: ${socketPath}`);
944
+ console.log(`Credential broker: ${describeBroker(brokerScope)}`);
945
+ if (brokerScope?.declarationError()) {
946
+ console.error(` ⚠ ${brokerScope.declarationError()} — refs declared there are refused until it parses`);
947
+ }
864
948
  console.log(`Audit logging: ${config.audit.enabled ? 'enabled' : 'disabled'}`);
865
949
  console.log(`Credential backend: ${config.credentials.backend}`);
866
950
  console.log(`PID file: ${getPidFile()}`);
@@ -907,6 +991,9 @@ openclaw
907
991
  const serviceRegistry = createServiceRegistry({ configPath: config.services.configPath });
908
992
  // Start credential proxy
909
993
  const policyConfig3 = loadPolicyFromConfig(config);
994
+ // No `broker`: this proxy runs inside the OpenClaw plugin's lifecycle and
995
+ // must never hand credential values to the gateway or its agents — that
996
+ // was the ClawScan finding against 0.14.x (v0.15.0; see broker-scope.ts).
910
997
  const credentialProxy = createCredentialProxy({
911
998
  socketPath,
912
999
  store: credentialStore,
@@ -1086,7 +1173,7 @@ hermes
1086
1173
  console.log(` ~/.hermes/.env: ${envWired ? aqua('wired') : 'not wired'} (${envPath})`);
1087
1174
  const shadowed = managedScopeShadowedKeys(integration.configureHermes());
1088
1175
  if (shadowed.length > 0) {
1089
- console.log(` Managed scope: ⚠ ${HERMES_MANAGED_ENV_PATH} pins ${shadowed.join(', ')} — proxy bypassed for these keys`);
1176
+ console.log(` Managed scope: ⚠ ${hermesManagedEnvPath()} pins ${shadowed.join(', ')} — proxy bypassed for these keys`);
1090
1177
  }
1091
1178
  const info = await detectHermes(config.hermes?.binaryPath || 'hermes');
1092
1179
  console.log(` Hermes CLI: ${info.installed ? aqua(`v${info.version}`) : 'not found'}`);
@@ -1150,7 +1237,7 @@ hermes
1150
1237
  {
1151
1238
  const shadowed = managedScopeShadowedKeys(integration.configureHermes());
1152
1239
  if (shadowed.length > 0) {
1153
- fail(`Hermes managed scope (${HERMES_MANAGED_ENV_PATH}) pins ${shadowed.join(', ')} — it overrides ~/.hermes/.env, so the proxy is BYPASSED for these keys. Remove them from the managed file or point them at the proxy.`);
1240
+ fail(`Hermes managed scope (${hermesManagedEnvPath()}) pins ${shadowed.join(', ')} — it overrides ~/.hermes/.env, so the proxy is BYPASSED for these keys. Remove them from the managed file or point them at the proxy.`);
1154
1241
  }
1155
1242
  else {
1156
1243
  pass('No managed-scope override of aquaman env vars');
@@ -1177,7 +1264,38 @@ hermes
1177
1264
  fail(`Vault backend "${config.credentials.backend}" failed: ${err instanceof Error ? err.message : err}`);
1178
1265
  }
1179
1266
  }
1180
- // 5. Hermes installed
1267
+ // 5. Secret-source bindings are declared to the broker (v0.15.0+). The
1268
+ // daemon only materializes declared refs, so a binding in Hermes'
1269
+ // config.yaml alone now resolves to a "not declared" warning at startup.
1270
+ {
1271
+ const bound = hermesSecretSourceRefs();
1272
+ if (bound.error) {
1273
+ fail(`Hermes config unreadable (${bound.error}) — can't check secret-source bindings`);
1274
+ }
1275
+ else if (bound.refs.length === 0) {
1276
+ console.log(` • No aquaman secret-source bindings in ${bound.path} (optional)`);
1277
+ }
1278
+ else {
1279
+ const scope = daemonBrokerScope(config);
1280
+ if (!scope) {
1281
+ fail(`${bound.refs.length} secret-source binding(s) in ${bound.path}, but broker.enabled is false — none will resolve`);
1282
+ }
1283
+ else {
1284
+ const undeclared = bound.refs.filter((ref) => {
1285
+ const r = parseAquamanRef(ref);
1286
+ return !scope.check(r.service, r.key, 'loopback').allowed;
1287
+ });
1288
+ if (undeclared.length === 0) {
1289
+ pass(`All ${bound.refs.length} secret-source binding(s) are declared to the broker`);
1290
+ }
1291
+ else {
1292
+ fail(`Secret-source binding(s) not declared to the broker, so the daemon refuses them: ${undeclared.join(', ')}`);
1293
+ console.log(` → aquaman broker allow ${undeclared.join(' ')}`);
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+ // 6. Hermes installed
1181
1299
  const info = await detectHermes(config.hermes?.binaryPath || 'hermes');
1182
1300
  if (info.installed)
1183
1301
  pass(`Hermes CLI installed (v${info.version})`);
@@ -1587,6 +1705,19 @@ openclaw
1587
1705
  shouldInstall = answer.toLowerCase() !== 'n';
1588
1706
  }
1589
1707
  if (shouldInstall) {
1708
+ // Detect the gateway version once — it gates the install command
1709
+ // (capability consent on 2026.8.1+) and the credential surface:
1710
+ // SecretRef wiring (canonical, >= 2026.6.5) vs the legacy
1711
+ // auth-profiles.json placeholder. AQUAMAN_OPENCLAW_VERSION overrides
1712
+ // detection (tests + operators pinning behavior explicitly).
1713
+ let ocVersion = process.env.AQUAMAN_OPENCLAW_VERSION || null;
1714
+ if (!ocVersion) {
1715
+ try {
1716
+ const { execSync } = await import('node:child_process');
1717
+ ocVersion = execSync('openclaw --version', { stdio: 'pipe', encoding: 'utf-8', timeout: 5000 }).trim();
1718
+ }
1719
+ catch { /* CLI not on PATH */ }
1720
+ }
1590
1721
  // a. Copy plugin files
1591
1722
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
1592
1723
  const pluginSrc = path.resolve(currentDir, '../../../plugin');
@@ -1597,14 +1728,26 @@ openclaw
1597
1728
  console.log(' \u2713 Plugin installed to ' + pluginDest);
1598
1729
  }
1599
1730
  else if (cliDetected) {
1600
- // npm install — plugin source not bundled, use openclaw's plugin installer
1731
+ // npm install — plugin source not bundled, use openclaw's plugin
1732
+ // installer. OpenClaw 2026.8.1+ requires capability consent for
1733
+ // third-party plugins: interactively we let OpenClaw show its own
1734
+ // consent screen to the human; --non-interactive means the operator
1735
+ // pre-approved, so we pass --accept-capabilities and say so. The
1736
+ // clawhub: source avoids the extra --force an npm spec needs.
1737
+ const consent = pluginInstallNeedsCapabilityConsent(ocVersion);
1738
+ const args = consent
1739
+ ? ['plugins', 'install', 'clawhub:aquaman-plugin', ...(isNonInteractive ? ['--accept-capabilities'] : [])]
1740
+ : ['plugins', 'install', 'aquaman-plugin'];
1601
1741
  try {
1602
- const { execSync: execSyncFallback } = await import('node:child_process');
1603
- execSyncFallback('openclaw plugins install aquaman-plugin', { stdio: 'pipe' });
1742
+ const { execFileSync: execFileSyncFallback } = await import('node:child_process');
1743
+ execFileSyncFallback('openclaw', args, { stdio: consent && !isNonInteractive ? 'inherit' : 'pipe' });
1604
1744
  console.log(' \u2713 Plugin installed via openclaw');
1745
+ if (consent && isNonInteractive) {
1746
+ console.log(' \u2192 Accepted the plugin\u2019s OpenClaw capabilities on your behalf (--non-interactive)');
1747
+ }
1605
1748
  }
1606
1749
  catch {
1607
- console.log(' \u2717 Could not install plugin. Run: openclaw plugins install aquaman-plugin');
1750
+ console.log(` \u2717 Could not install plugin. Run: openclaw ${args.join(' ')}${consent && isNonInteractive ? '' : consent ? ' (then approve its capabilities)' : ''}`);
1608
1751
  }
1609
1752
  }
1610
1753
  // b. Write/merge openclaw.json
@@ -1620,11 +1763,20 @@ openclaw
1620
1763
  openclawConfig.plugins = {};
1621
1764
  if (!openclawConfig.plugins.entries)
1622
1765
  openclawConfig.plugins.entries = {};
1623
- // Set plugins.allow so OpenClaw trusts the plugin (avoids extensions_no_allowlist audit warning)
1624
- if (!openclawConfig.plugins.allow)
1625
- openclawConfig.plugins.allow = [];
1626
- if (!openclawConfig.plugins.allow.includes('aquaman-plugin')) {
1627
- openclawConfig.plugins.allow.push('aquaman-plugin');
1766
+ // plugins.allow is an allowlist for EVERY plugin, OpenClaw's own
1767
+ // included: on current gateways the anthropic/openai model providers
1768
+ // are stock plugins. Creating a list that holds only aquaman-plugin
1769
+ // (what setup did through v0.14.x) blocked them, so every
1770
+ // anthropic/* model became "Unknown model" (verified 2026-09-18 on
1771
+ // 2026.7.33 and 2026.9.1). Append to an existing list; never create
1772
+ // a restrictive one on the user's behalf.
1773
+ if (Array.isArray(openclawConfig.plugins.allow)) {
1774
+ if (!openclawConfig.plugins.allow.includes('aquaman-plugin')) {
1775
+ openclawConfig.plugins.allow.push('aquaman-plugin');
1776
+ }
1777
+ }
1778
+ else {
1779
+ console.log(' \u2192 No plugins.allow list in openclaw.json; left it unset (a list with only aquaman-plugin would block OpenClaw\u2019s own provider plugins)');
1628
1780
  }
1629
1781
  const configuredServices = storedServices.length > 0 ? storedServices : ['anthropic', 'openai'];
1630
1782
  openclawConfig.plugins.entries['aquaman-plugin'] = {
@@ -1634,33 +1786,91 @@ openclaw
1634
1786
  services: configuredServices,
1635
1787
  }
1636
1788
  };
1637
- // Detect the gateway version once \u2014 it gates the credential surface:
1638
- // SecretRef wiring (canonical, >= 2026.6.5) vs the legacy
1639
- // auth-profiles.json placeholder. AQUAMAN_OPENCLAW_VERSION overrides
1640
- // detection (tests + operators pinning behavior explicitly).
1641
- let ocVersion = process.env.AQUAMAN_OPENCLAW_VERSION || null;
1642
- if (!ocVersion) {
1643
- try {
1644
- const { execSync } = await import('node:child_process');
1645
- ocVersion = execSync('openclaw --version', { stdio: 'pipe', encoding: 'utf-8', timeout: 5000 }).trim();
1646
- }
1647
- catch { /* CLI not on PATH */ }
1648
- }
1649
1789
  // b2. SecretRef provider wiring (v0.14.0+, OpenClaw >= 2026.6.5).
1650
1790
  // Config-level refs in openclaw.json are runtime-read on every
1651
1791
  // version and survive OpenClaw's plaintext scrubs \u2014 no SQLite
1652
1792
  // import step needed, unlike the legacy placeholder.
1653
1793
  const secretRefSupported = supportsSecretRefIntegrations(ocVersion);
1654
- if (secretRefSupported) {
1655
- const wiring = wireSecretRefProviders(openclawConfig, configuredServices);
1794
+ // Which configured channels aquaman can actually take over: the host
1795
+ // must expose an endpoint override and the vault must already hold
1796
+ // the real token. Resolved up front because the wiring is sync, and
1797
+ // only for channels the user actually configured -- a vault probe
1798
+ // can be slow (keepassxc opens and derives a key per call).
1799
+ const channelsConfigured = CHANNEL_ROUTING_SUPPORTED.filter(c => openclawConfig.channels?.[c] && typeof openclawConfig.channels[c] === 'object');
1800
+ const channelsWithVaultCredential = new Set();
1801
+ if (channelsConfigured.length > 0) {
1802
+ const channelRegistry = createServiceRegistry();
1803
+ for (const channel of channelsConfigured) {
1804
+ const def = channelRegistry.get(channel);
1805
+ if (!def)
1806
+ continue;
1807
+ if (await store.exists(channel, def.credentialKey)) {
1808
+ channelsWithVaultCredential.add(channel);
1809
+ }
1810
+ }
1811
+ }
1812
+ // Both routes need the loopback listener: model traffic (provider
1813
+ // baseUrl) and channel egress (apiRoot). Decide once.
1814
+ let loopbackOrigin;
1815
+ let loopbackToken;
1816
+ if (secretRefSupported || channelsConfigured.length > 0) {
1817
+ // OpenClaw's model transport builds its own undici dispatcher: it
1818
+ // ignores the aquaman.local sentinel (its own DNS fails) and never
1819
+ // calls globalThis.fetch, so the plugin's interceptor can't see
1820
+ // provider traffic. Route it at the loopback listener instead —
1821
+ // OpenClaw's documented local-provider pattern (v0.15.0).
1822
+ const lbHost = config.loopback?.host || '127.0.0.1';
1823
+ const lbPort = config.loopback?.port || DEFAULT_LOOPBACK_PORT;
1824
+ const lbToken = config.loopback?.token || generateLoopbackToken();
1825
+ config.loopback = { enabled: true, host: lbHost, port: lbPort, token: lbToken };
1826
+ saveConfig(config);
1827
+ loopbackOrigin = `http://${lbHost}:${lbPort}`;
1828
+ loopbackToken = lbToken;
1829
+ console.log(` \u2713 Loopback listener enabled on ${loopbackOrigin} (token-gated, loopback-bound)`);
1830
+ }
1831
+ if (secretRefSupported && loopbackOrigin) {
1832
+ const wiring = wireSecretRefProviders(openclawConfig, configuredServices, { loopbackOrigin });
1656
1833
  if (wiring.wiredProviders.length > 0) {
1657
1834
  console.log(` \u2713 SecretRef wiring for ${wiring.wiredProviders.join(', ')} (canonical credential surface)`);
1658
1835
  }
1836
+ if (wiring.baseUrlProviders.length > 0) {
1837
+ console.log(` \u2713 Model traffic routed through the proxy for ${wiring.baseUrlProviders.join(', ')}`);
1838
+ }
1839
+ if (wiring.keptUserBaseUrl.length > 0) {
1840
+ console.log(` \u2192 Left existing user-set baseUrl untouched for: ${wiring.keptUserBaseUrl.join(', ')} (proxy NOT in the path for those)`);
1841
+ }
1659
1842
  const userKept = wiring.skippedProviders.filter(s => s === 'anthropic' || s === 'openai');
1660
1843
  if (userKept.length > 0) {
1661
1844
  console.log(` \u2192 Left existing user-set apiKey untouched for: ${userKept.join(', ')}`);
1662
1845
  }
1663
1846
  }
1847
+ // b3. Channel egress routing (v0.15.0+). Channels each build their
1848
+ // own undici dispatcher, so the plugin's fetch interceptor never
1849
+ // sees them. Telegram is the one bundled channel with an endpoint
1850
+ // override, and the Bot API carries its token in the path, so the
1851
+ // placeholder written here is the loopback token itself.
1852
+ if (loopbackOrigin && loopbackToken) {
1853
+ const routing = wireChannelRouting(openclawConfig, {
1854
+ loopbackOrigin,
1855
+ loopbackToken,
1856
+ hasVaultCredential: (svc) => channelsWithVaultCredential.has(svc)
1857
+ });
1858
+ if (routing.routedChannels.length > 0) {
1859
+ // A routed channel that isn't in proxiedServices gets a 404 from
1860
+ // our own proxy, which is exactly the dead bot the all-or-nothing
1861
+ // wiring exists to avoid. Open the route we just pointed at us.
1862
+ const proxied = config.credentials.proxiedServices;
1863
+ const added = routing.routedChannels.filter(c => !proxied.includes(c));
1864
+ if (added.length > 0) {
1865
+ config.credentials.proxiedServices = [...proxied, ...added];
1866
+ saveConfig(config);
1867
+ }
1868
+ console.log(` \u2713 Channel traffic routed through the proxy for ${routing.routedChannels.join(', ')}`);
1869
+ }
1870
+ for (const skip of routing.skipped) {
1871
+ console.log(` \u2192 ${skip.channel} left as-is: ${describeChannelSkip(skip.reason, skip.channel)}`);
1872
+ }
1873
+ }
1664
1874
  fs.writeFileSync(openclawJsonPath, JSON.stringify(openclawConfig, null, 2), 'utf-8');
1665
1875
  console.log(' \u2713 Plugin config written to ' + openclawJsonPath);
1666
1876
  // c. Generate auth-profiles.json (only if missing). With SecretRef
@@ -1669,6 +1879,12 @@ openclaw
1669
1879
  const profilesPath = path.join(openclawStateDir, 'agents', 'main', 'agent', 'auth-profiles.json');
1670
1880
  if (secretRefSupported) {
1671
1881
  console.log(' \u2192 Legacy auth-profiles.json placeholder skipped (SecretRef wiring replaces it)');
1882
+ if (legacyAuthProfilesBlockProviders(ocVersion) && fs.existsSync(profilesPath)) {
1883
+ // Left behind by aquaman-plugin <= 0.14.x. On 2026.8.1+ it locks
1884
+ // anthropic/openai out until OpenClaw archives it.
1885
+ console.log(' \u2717 A legacy auth-profiles.json from an older aquaman-plugin blocks providers on this OpenClaw');
1886
+ console.log(' Run once: openclaw doctor --fix (archives it; aquaman-plugin 0.15.0+ never recreates it)');
1887
+ }
1672
1888
  }
1673
1889
  else if (!fs.existsSync(profilesPath)) {
1674
1890
  const profiles = {};
@@ -1780,6 +1996,9 @@ openclaw
1780
1996
  // 2. Backend accessible
1781
1997
  let config;
1782
1998
  let store = null;
1999
+ // Services the vault holds anything for. Used by the channel-routing
2000
+ // check below, which runs outside the store's try block.
2001
+ const vaultServices = new Set();
1783
2002
  try {
1784
2003
  config = loadConfig();
1785
2004
  if (config.credentials.backend === 'systemd-creds') {
@@ -1812,6 +2031,8 @@ openclaw
1812
2031
  });
1813
2032
  // 3. Count credentials
1814
2033
  const creds = await store.list();
2034
+ for (const c of creds)
2035
+ vaultServices.add(c.service);
1815
2036
  if (creds.length > 0) {
1816
2037
  const names = creds.map(c => `${c.service}/${c.key}`).join(', ');
1817
2038
  console.log(` \u2713 ${aqua('Backend:')} ${config.credentials.backend} (accessible)`);
@@ -1916,6 +2137,12 @@ openclaw
1916
2137
  console.log(` ${svc}: ${summary}`);
1917
2138
  }
1918
2139
  }
2140
+ // Deny rules that can never fire (pre-0.15.0 preset shapes) — a
2141
+ // security issue, since the operator believes the endpoint is blocked.
2142
+ for (const warning of lintPolicyConfig(policyConfigDoc)) {
2143
+ console.log(` \u2717 ${aqua('Policy')} ${warning}`);
2144
+ issues++;
2145
+ }
1919
2146
  // Warn about policies for non-proxied services
1920
2147
  if (config.credentials.proxiedServices) {
1921
2148
  for (const svc of Object.keys(policyConfigDoc)) {
@@ -2020,9 +2247,21 @@ openclaw
2020
2247
  if (fs.existsSync(openclawJsonPath)) {
2021
2248
  try {
2022
2249
  const openclawConfig = JSON.parse(fs.readFileSync(openclawJsonPath, 'utf-8'));
2023
- const allowList = openclawConfig.plugins?.allow || [];
2024
- if (allowList.includes('aquaman-plugin')) {
2250
+ const allowList = Array.isArray(openclawConfig.plugins?.allow) ? openclawConfig.plugins.allow : undefined;
2251
+ if (!allowList) {
2252
+ console.log(` \u2713 ${aqua('Plugin')} allowed (no plugins.allow trust list; \`openclaw security audit\` notes extensions_no_allowlist)`);
2253
+ }
2254
+ else if (allowList.includes('aquaman-plugin')) {
2025
2255
  console.log(` \u2713 ${aqua('Plugin')} in plugins.allow trust list`);
2256
+ // A list that excludes OpenClaw's own provider plugins blocks
2257
+ // them (aquaman setup <= 0.14.x wrote exactly such a list).
2258
+ const svcs = openclawConfig.plugins?.entries?.['aquaman-plugin']?.config?.services ?? ['anthropic', 'openai'];
2259
+ const blocked = ['anthropic', 'openai'].filter(p => svcs.includes(p) && !allowList.includes(p));
2260
+ if (blocked.length > 0 && supportsSecretRefIntegrations(openclawVersion)) {
2261
+ console.log(` \u2717 ${aqua('Plugin')} plugins.allow blocks OpenClaw\u2019s own provider plugin(s): ${blocked.join(', ')} (their models report "Unknown model")`);
2262
+ console.log(` \u2192 Add ${blocked.map(b => `"${b}"`).join(', ')} to plugins.allow in openclaw.json (aquaman setup <= 0.14.x created a list without them)`);
2263
+ issues++;
2264
+ }
2026
2265
  }
2027
2266
  else {
2028
2267
  console.log(` \u2717 ${aqua('Plugin')} not in plugins.allow trust list`);
@@ -2048,6 +2287,17 @@ openclaw
2048
2287
  if (status.providerConfigured && status.missingProviders.length === 0) {
2049
2288
  secretRefFullyWired = true;
2050
2289
  console.log(` ✓ ${aqua('SecretRef')} wiring active (${status.wiredProviders.join(', ') || 'no providers'})`);
2290
+ // The apiKey ref alone doesn't put the proxy in the path: without
2291
+ // a loopback baseUrl OpenClaw's transport calls the upstream
2292
+ // directly and the placeholder fails there (v0.15.0).
2293
+ if (status.missingBaseUrl.length > 0) {
2294
+ console.log(` ✗ ${aqua('Model routing')} ${status.missingBaseUrl.join(', ')} still call the provider directly — the proxy is bypassed`);
2295
+ console.log(' → Run: aquaman openclaw setup (points models.providers.<svc>.baseUrl at the loopback listener)');
2296
+ issues++;
2297
+ }
2298
+ else if (status.baseUrlProviders.length > 0) {
2299
+ console.log(` ✓ ${aqua('Model routing')} through the proxy for ${status.baseUrlProviders.join(', ')}`);
2300
+ }
2051
2301
  }
2052
2302
  else if (status.providerConfigured) {
2053
2303
  // Half-migrated: the provider block exists but some providers still
@@ -2068,6 +2318,42 @@ openclaw
2068
2318
  // openclaw.json invalid — already reported above
2069
2319
  }
2070
2320
  }
2321
+ // 8.6. Channel egress routing (v0.15.0+). Channels build their own
2322
+ // undici dispatcher, so the plugin's fetch interceptor never sees
2323
+ // them. Only channels whose endpoint OpenClaw lets us override can
2324
+ // be routed; the rest are at-rest storage only and say so.
2325
+ if (fs.existsSync(openclawJsonPath)) {
2326
+ try {
2327
+ const openclawConfig = JSON.parse(fs.readFileSync(openclawJsonPath, 'utf-8'));
2328
+ const routing = channelRoutingStatus(openclawConfig, {
2329
+ hasVaultCredential: (svc) => vaultServices.has(svc)
2330
+ });
2331
+ if (routing.routed.length > 0) {
2332
+ console.log(` \u2713 ${aqua('Channel routing')} through the proxy for ${routing.routed.join(', ')}`);
2333
+ // Routed but not proxied means our own proxy 404s the channel.
2334
+ const notProxied = routing.routed.filter(c => !(config?.credentials?.proxiedServices ?? []).includes(c));
2335
+ if (notProxied.length > 0) {
2336
+ console.log(` \u2717 ${aqua('Channel routing')} ${notProxied.join(', ')} routed here but missing from proxiedServices, so the proxy returns 404`);
2337
+ console.log(` \u2192 Add ${notProxied.join(', ')} to credentials.proxiedServices in ~/.aquaman/config.yaml (or re-run: aquaman openclaw setup)`);
2338
+ issues++;
2339
+ }
2340
+ }
2341
+ for (const skip of routing.notRouted) {
2342
+ console.log(` \u2717 ${aqua('Channel routing')} ${skip.channel} sends its token straight to the vendor`);
2343
+ console.log(` \u2192 ${describeChannelSkip(skip.reason, skip.channel)}`);
2344
+ issues++;
2345
+ }
2346
+ if (routing.unroutable.length > 0) {
2347
+ // Not a failure: nothing the user can do, and the credentials can
2348
+ // still live in the vault. Stated so nobody assumes coverage.
2349
+ console.log(` \u2192 ${aqua('Channel routing')} not available for ${routing.unroutable.join(', ')} on this OpenClaw (no endpoint override)`);
2350
+ console.log(' (their tokens can live in the vault, but the proxy is not in their egress path)');
2351
+ }
2352
+ }
2353
+ catch {
2354
+ // openclaw.json invalid \u2014 already reported above
2355
+ }
2356
+ }
2071
2357
  // 9. Auth profiles. With SecretRef wiring active this surface is
2072
2358
  // legacy/inert. Otherwise: OpenClaw >= 2026.6.5 reads provider auth
2073
2359
  // profiles from each agent's openclaw-agent.sqlite and no longer reads
@@ -2077,7 +2363,18 @@ openclaw
2077
2363
  const agentDir = path.join(openclawStateDir, 'agents', 'main', 'agent');
2078
2364
  const profilesPath = path.join(agentDir, 'auth-profiles.json');
2079
2365
  const sqlitePath = path.join(agentDir, 'openclaw-agent.sqlite');
2080
- if (secretRefFullyWired) {
2366
+ if (legacyAuthProfilesBlockProviders(openclawVersion) && fs.existsSync(profilesPath)) {
2367
+ // OpenClaw 2.0 line: the legacy JSON is not inert any more — it blocks
2368
+ // providers with AUTH_PROFILE_MIGRATION_REQUIRED (#114033). Older
2369
+ // aquaman-plugin versions rewrote it on every load; 0.15.0+ doesn't.
2370
+ console.log(` \u2717 ${aqua('Auth profiles')} legacy auth-profiles.json blocks anthropic/openai on ${openclawVersion} (AUTH_PROFILE_MIGRATION_REQUIRED)`);
2371
+ if (!secretRefFullyWired) {
2372
+ console.log(' \u2192 First: aquaman openclaw setup (wires the SecretRef provider that replaces it)');
2373
+ }
2374
+ console.log(' \u2192 Then run once: openclaw doctor --fix (archives the file; aquaman-plugin 0.15.0+ never recreates it)');
2375
+ issues++;
2376
+ }
2377
+ else if (secretRefFullyWired) {
2081
2378
  console.log(` ✓ ${aqua('Auth profiles')} not needed (SecretRef wiring replaces the placeholder flow)`);
2082
2379
  if (fs.existsSync(profilesPath)) {
2083
2380
  console.log(' → Optional: the legacy auth-profiles.json placeholder is inert and may be deleted');
@@ -2095,8 +2392,8 @@ openclaw
2095
2392
  console.log(` \u2713 ${aqua('Auth profiles')} (SQLite store present; OpenClaw \u2265 2026.6.5)`);
2096
2393
  }
2097
2394
  else {
2098
- console.log(` \u2717 ${aqua('Auth profiles')} missing (no SQLite store found)`);
2099
- console.log(' \u2192 Run: aquaman setup, then: openclaw doctor --fix');
2395
+ console.log(` \u2717 ${aqua('Auth profiles')} missing: no credential wiring for OpenClaw ${openclawVersion}`);
2396
+ console.log(' \u2192 Run: aquaman openclaw setup (wires the SecretRef provider; the plugin no longer writes auth-profiles.json here)');
2100
2397
  issues++;
2101
2398
  }
2102
2399
  }
@@ -2584,6 +2881,48 @@ policy
2584
2881
  console.log(` \u2717 DENIED by rule: ${rule.method} ${rule.path} \u2192 ${rule.action}`);
2585
2882
  }
2586
2883
  });
2884
+ // Credential broker scope (v0.15.0+)
2885
+ const broker = program
2886
+ .command('broker')
2887
+ .description('Credential broker scope: which refs `aquaman daemon` may hand out');
2888
+ broker
2889
+ .command('list')
2890
+ .description('List the refs the daemon broker will materialize, and where each is declared')
2891
+ .action(() => {
2892
+ const config = loadConfig();
2893
+ console.log('Credential broker: served by `aquaman daemon` only. OpenClaw-hosted proxies never hand out credential values.\n');
2894
+ if (config.broker?.enabled === false) {
2895
+ console.log(' Disabled (broker.enabled: false in config.yaml) — every broker request is refused.');
2896
+ return;
2897
+ }
2898
+ const scope = daemonBrokerScope(config);
2899
+ const declared = scope.declared();
2900
+ if (declared.length === 0) {
2901
+ console.log(' No declared refs — every broker request is refused.');
2902
+ }
2903
+ for (const d of declared)
2904
+ console.log(` ${d.ref} (${d.source})`);
2905
+ const err = scope.declarationError();
2906
+ if (err)
2907
+ console.log(`\n ⚠ ${err} — refs declared there are refused until it parses`);
2908
+ console.log(`\n Never over the loopback listener: ${HERMES_SUPPORTED_SERVICES.join(', ')} (process-isolated on the proxy path)`);
2909
+ console.log(` Coding-agent refs: ${defaultProjectsPath()} (aquaman coder project add ...)`);
2910
+ console.log(` Other refs: broker.allowedRefs in ${getConfigPath()} (aquaman broker allow ...)`);
2911
+ });
2912
+ broker
2913
+ .command('allow')
2914
+ .description('Allow aquaman://service/key refs to be materialized (e.g. Hermes secret-source bindings)')
2915
+ .argument('<refs...>', 'one or more aquaman://service/key refs')
2916
+ .action((refs) => {
2917
+ updateBrokerAllowedRefs(refs, 'allow');
2918
+ });
2919
+ broker
2920
+ .command('revoke')
2921
+ .description('Stop materializing refs added with `aquaman broker allow`')
2922
+ .argument('<refs...>', 'one or more aquaman://service/key refs')
2923
+ .action((refs) => {
2924
+ updateBrokerAllowedRefs(refs, 'revoke');
2925
+ });
2587
2926
  // Migration commands
2588
2927
  openclaw
2589
2928
  .command('migrate')