aquaman-proxy 0.14.1 → 0.16.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 (68) hide show
  1. package/README.md +31 -6
  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 +544 -53
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/credentials/backends/keeper.d.ts +69 -0
  9. package/dist/core/credentials/backends/keeper.d.ts.map +1 -0
  10. package/dist/core/credentials/backends/keeper.js +189 -0
  11. package/dist/core/credentials/backends/keeper.js.map +1 -0
  12. package/dist/core/credentials/store.d.ts +2 -0
  13. package/dist/core/credentials/store.d.ts.map +1 -1
  14. package/dist/core/credentials/store.js +7 -0
  15. package/dist/core/credentials/store.js.map +1 -1
  16. package/dist/core/index.d.ts +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +1 -1
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/redactor/index.d.ts.map +1 -1
  21. package/dist/core/redactor/index.js +10 -0
  22. package/dist/core/redactor/index.js.map +1 -1
  23. package/dist/core/types.d.ts +19 -3
  24. package/dist/core/types.d.ts.map +1 -1
  25. package/dist/core/utils/config.d.ts +11 -1
  26. package/dist/core/utils/config.d.ts.map +1 -1
  27. package/dist/core/utils/config.js +39 -4
  28. package/dist/core/utils/config.js.map +1 -1
  29. package/dist/core/utils/index.d.ts +1 -1
  30. package/dist/core/utils/index.d.ts.map +1 -1
  31. package/dist/core/utils/index.js +1 -1
  32. package/dist/core/utils/index.js.map +1 -1
  33. package/dist/daemon.d.ts +36 -0
  34. package/dist/daemon.d.ts.map +1 -1
  35. package/dist/daemon.js +136 -15
  36. package/dist/daemon.js.map +1 -1
  37. package/dist/hermes/config-writer.d.ts +19 -0
  38. package/dist/hermes/config-writer.d.ts.map +1 -1
  39. package/dist/hermes/config-writer.js +51 -1
  40. package/dist/hermes/config-writer.js.map +1 -1
  41. package/dist/index.d.ts +7 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +9 -5
  44. package/dist/index.js.map +1 -1
  45. package/dist/migration/openclaw-migrator.d.ts.map +1 -1
  46. package/dist/migration/openclaw-migrator.js +7 -6
  47. package/dist/migration/openclaw-migrator.js.map +1 -1
  48. package/dist/openclaw/channel-routing.d.ts +81 -0
  49. package/dist/openclaw/channel-routing.d.ts.map +1 -0
  50. package/dist/openclaw/channel-routing.js +152 -0
  51. package/dist/openclaw/channel-routing.js.map +1 -0
  52. package/dist/openclaw/integration.d.ts +18 -0
  53. package/dist/openclaw/integration.d.ts.map +1 -1
  54. package/dist/openclaw/integration.js +32 -0
  55. package/dist/openclaw/integration.js.map +1 -1
  56. package/dist/openclaw/secretref.d.ts +35 -6
  57. package/dist/openclaw/secretref.d.ts.map +1 -1
  58. package/dist/openclaw/secretref.js +73 -8
  59. package/dist/openclaw/secretref.js.map +1 -1
  60. package/dist/request-policy.d.ts +7 -0
  61. package/dist/request-policy.d.ts.map +1 -1
  62. package/dist/request-policy.js +31 -2
  63. package/dist/request-policy.js.map +1 -1
  64. package/dist/service-registry.d.ts +8 -0
  65. package/dist/service-registry.d.ts.map +1 -1
  66. package/dist/service-registry.js +11 -4
  67. package/dist/service-registry.js.map +1 -1
  68. package/package.json +3 -3
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);
@@ -80,7 +82,9 @@ function storeOptionsFromConfig(config) {
80
82
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
81
83
  bitwardenFolder: config.credentials.bitwardenFolder,
82
84
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
83
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
85
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
86
+ keeperFolderUid: config.credentials.keeperFolderUid,
87
+ keeperConfigPath: config.credentials.keeperConfigPath
84
88
  };
85
89
  }
86
90
  /**
@@ -101,6 +105,112 @@ function loadLoopbackOptions(config) {
101
105
  }
102
106
  return { port: lb.port || DEFAULT_LOOPBACK_PORT, token: lb.token, host: lb.host || '127.0.0.1' };
103
107
  }
108
+ /** Broker scope for `aquaman daemon`, or undefined when `broker.enabled: false`. */
109
+ function daemonBrokerScope(config) {
110
+ if (config.broker?.enabled === false)
111
+ return undefined;
112
+ return createBrokerScope({
113
+ configPath: getConfigPath(),
114
+ loopbackDeniedServices: HERMES_SUPPORTED_SERVICES,
115
+ });
116
+ }
117
+ /**
118
+ * Add or remove refs in config.yaml `broker.allowedRefs`. Edits the raw file
119
+ * (not loadConfig + saveConfig) so env overrides such as the loopback token
120
+ * are never persisted as a side effect. The daemon re-reads the list on change.
121
+ */
122
+ function updateBrokerAllowedRefs(refs, mode) {
123
+ const invalid = refs.filter(r => !parseAquamanRef(r));
124
+ if (invalid.length > 0) {
125
+ console.error(`Invalid ref${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`);
126
+ console.error('Refs look like aquaman://<service>/<key>, e.g. aquaman://github/token');
127
+ process.exit(1);
128
+ }
129
+ ensureConfigDir();
130
+ const configPath = getConfigPath();
131
+ let raw = {};
132
+ if (fs.existsSync(configPath)) {
133
+ const parsed = yamlParse(fs.readFileSync(configPath, 'utf-8'));
134
+ if (parsed && typeof parsed === 'object')
135
+ raw = parsed;
136
+ }
137
+ const current = new Set(Array.isArray(raw.broker?.allowedRefs) ? raw.broker.allowedRefs : []);
138
+ for (const ref of refs) {
139
+ if (mode === 'allow')
140
+ current.add(ref);
141
+ else
142
+ current.delete(ref);
143
+ }
144
+ raw.broker = { ...(raw.broker && typeof raw.broker === 'object' ? raw.broker : {}), allowedRefs: [...current].sort() };
145
+ fs.writeFileSync(configPath, yamlStringify(raw), { encoding: 'utf-8', mode: 0o600 });
146
+ const verb = mode === 'allow' ? 'Allowed' : 'Revoked';
147
+ for (const ref of refs)
148
+ console.log(`${verb}: ${ref}`);
149
+ if (mode === 'allow') {
150
+ console.log('\nAny process running as you can now fetch these values from `aquaman daemon`. Only allow refs you intend to materialize.');
151
+ }
152
+ if (raw.broker.enabled === false) {
153
+ console.log('Note: broker.enabled is false in config.yaml, so the broker is off regardless.');
154
+ }
155
+ }
156
+ /**
157
+ * `POST /broker/resolve` against the running daemon over UDS. Always goes
158
+ * through the daemon (never the vault directly), so `aquaman get` is
159
+ * scope-checked and audited exactly like a coding-agent or Hermes resolve.
160
+ */
161
+ function brokerResolve(socketPath, service, key, timeoutMs) {
162
+ const body = JSON.stringify({ service, key });
163
+ return new Promise((resolve) => {
164
+ const req = http.request({
165
+ socketPath,
166
+ path: '/broker/resolve',
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
169
+ timeout: timeoutMs,
170
+ }, (res) => {
171
+ let payload = '';
172
+ res.on('data', (chunk) => { payload += chunk; });
173
+ res.on('end', () => {
174
+ let json;
175
+ try {
176
+ json = JSON.parse(payload);
177
+ }
178
+ catch {
179
+ resolve({ error: `Proxy returned a non-JSON response (HTTP ${res.statusCode})` });
180
+ return;
181
+ }
182
+ if (res.statusCode === 200 && typeof json.value === 'string') {
183
+ resolve({ value: json.value });
184
+ }
185
+ else {
186
+ resolve({ error: json.error || `Broker error (HTTP ${res.statusCode})`, fix: json.fix, code: json.code });
187
+ }
188
+ });
189
+ });
190
+ req.on('error', (err) => {
191
+ if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
192
+ resolve({ error: `Cannot reach the aquaman proxy at ${socketPath}`, fix: 'Start it with: aquaman daemon', code: 'proxy_unreachable' });
193
+ }
194
+ else if (err.code === 'EPERM' || err.code === 'EACCES') {
195
+ resolve({ error: `Connecting to ${socketPath} was denied (${err.code})`, fix: 'If this runs inside a sandbox, allowlist the socket; otherwise check its owner and 0600 mode', code: 'socket_blocked' });
196
+ }
197
+ else {
198
+ resolve({ error: err.message });
199
+ }
200
+ });
201
+ req.on('timeout', () => {
202
+ req.destroy();
203
+ resolve({ error: `Broker request timed out after ${timeoutMs}ms`, fix: 'A vault that prompts (e.g. 1Password biometrics) may need longer: pass --timeout <ms>' });
204
+ });
205
+ req.end(body);
206
+ });
207
+ }
208
+ function describeBroker(scope) {
209
+ if (!scope)
210
+ return 'disabled (broker.enabled: false)';
211
+ const n = scope.declared().length;
212
+ return `${n} declared ref${n === 1 ? '' : 's'} (see: aquaman broker list)`;
213
+ }
104
214
  /**
105
215
  * Probe the loopback listener's /_health endpoint (no token required). Returns
106
216
  * true if it responds 200 within a short timeout, false otherwise.
@@ -183,6 +293,23 @@ async function promptSecretInput(prompt) {
183
293
  });
184
294
  });
185
295
  }
296
+ /** One-line explanation of why `openclaw setup` left a channel alone. */
297
+ function describeChannelSkip(reason, channel) {
298
+ switch (reason) {
299
+ case 'no-vault-credential':
300
+ return `no credential in the vault (add it: aquaman credentials add ${channel} bot_token)`;
301
+ case 'user-endpoint':
302
+ return 'it already points at your own endpoint (self-hosted Bot API or regional proxy)';
303
+ case 'token-file':
304
+ return 'its token comes from tokenFile, whose precedence over a config token we will not guess at';
305
+ case 'multi-account':
306
+ return 'multi-account channels need one vault entry per account, which the service model has no room for yet';
307
+ case 'not-wired':
308
+ return 'routable but not wired yet (re-run: aquaman openclaw setup)';
309
+ case 'unroutable':
310
+ return 'this OpenClaw exposes no endpoint override for it, so its traffic cannot be routed';
311
+ }
312
+ }
186
313
  const program = new Command();
187
314
  program
188
315
  .name('aquaman')
@@ -239,7 +366,7 @@ program
239
366
  }
240
367
  lines.push('');
241
368
  lines.push(aqua('Vault management'));
242
- for (const name of ['credentials', 'audit', 'services', 'policy']) {
369
+ for (const name of ['credentials', 'get', 'broker', 'audit', 'services', 'policy']) {
243
370
  const c = byName.get(name);
244
371
  if (c)
245
372
  lines.push(renderCmd(c));
@@ -263,13 +390,12 @@ program
263
390
  }
264
391
  // --- Coder namespace (shim \u2014 list documented subcommands) ---
265
392
  if (byName.has('coder')) {
266
- lines.push(aqua('AI coding-agent integration (Claude Code today)'));
393
+ lines.push(aqua('AI coding-agent integration (Claude Code, Codex)'));
267
394
  const coderSubs = [
268
- ['coder setup <agent>', 'Install hooks for an agent (claude-code today)'],
395
+ ['coder setup <agent>', 'Install hooks for an agent (claude-code, codex)'],
269
396
  ['coder doctor', 'Deep diagnostic \u2014 projects, broker, per-project vault'],
270
397
  ['coder status', 'Configured projects + hook wiring + broker activity'],
271
398
  ['coder project list/add/remove', 'Manage ~/.aquaman/projects.yaml'],
272
- ['coder get <ref>', 'Resolve an aquaman://service/key reference'],
273
399
  ['coder exec <cmd>', 'Run command with project env injected + output redacted'],
274
400
  ];
275
401
  for (const [term, desc] of coderSubs) {
@@ -316,7 +442,7 @@ program
316
442
  program
317
443
  .command('setup')
318
444
  .description('Vault-only setup wizard \u2014 backend + credentials (use `aquaman openclaw setup` or `aquaman coder setup` for full bundles)')
319
- .option('--backend <backend>', 'Credential backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden)')
445
+ .option('--backend <backend>', 'Credential backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden, keeper)')
320
446
  .option('--no-policy', 'Skip request policy preset configuration')
321
447
  .option('--non-interactive', 'Use environment variables instead of prompts (for CI)')
322
448
  .action(async (options) => {
@@ -541,7 +667,7 @@ async function runVaultSetup(opts) {
541
667
  }
542
668
  }
543
669
  }
544
- const validBackends = ['keychain', 'encrypted-file', 'keepassxc', '1password', 'vault', 'systemd-creds', 'bitwarden'];
670
+ const validBackends = ['keychain', 'encrypted-file', 'keepassxc', '1password', 'vault', 'systemd-creds', 'bitwarden', 'keeper'];
545
671
  if (!validBackends.includes(backend)) {
546
672
  console.error(` Invalid backend: ${backend}. Valid: ${validBackends.join(', ')}`);
547
673
  process.exit(1);
@@ -557,6 +683,9 @@ async function runVaultSetup(opts) {
557
683
  config = loadConfig();
558
684
  }
559
685
  config.credentials.backend = backend;
686
+ if (backend === 'keeper' && process.env['AQUAMAN_KEEPER_FOLDER_UID']) {
687
+ config.credentials.keeperFolderUid = process.env['AQUAMAN_KEEPER_FOLDER_UID'];
688
+ }
560
689
  fs.writeFileSync(configPath, yamlStringify(config), { encoding: 'utf-8', mode: 0o600 });
561
690
  const auditDir = path.join(configDir, 'audit');
562
691
  fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
@@ -700,6 +829,8 @@ openclaw
700
829
  // Start credential proxy
701
830
  const socketPath = path.join(getConfigDir(), 'proxy.sock');
702
831
  const policyConfig = loadPolicyFromConfig(config);
832
+ // No `broker`: this proxy fronts OpenClaw, and the OpenClaw path never
833
+ // hands credential values out (v0.15.0; see broker-scope.ts).
703
834
  const credentialProxy = createCredentialProxy({
704
835
  socketPath,
705
836
  store: credentialStore,
@@ -710,7 +841,8 @@ openclaw
710
841
  onRequest: (info) => {
711
842
  auditLogger.logCredentialAccess('system', 'system', {
712
843
  service: info.service,
713
- operation: 'use',
844
+ // A broker resolve hands the value out; everything else injects it.
845
+ operation: info.method === 'BROKER' ? 'read' : 'use',
714
846
  success: !info.error,
715
847
  error: info.error
716
848
  });
@@ -841,6 +973,10 @@ program
841
973
  const serviceRegistry = createServiceRegistry({ configPath: config.services.configPath });
842
974
  // Start credential proxy
843
975
  const policyConfig2 = loadPolicyFromConfig(config);
976
+ // The daemon is the only proxy that serves the credential broker, and only
977
+ // for declared refs (projects.yaml + broker.allowedRefs). Over loopback the
978
+ // Hermes LLM-provider keys are never materialized.
979
+ const brokerScope = daemonBrokerScope(config);
844
980
  const credentialProxy = createCredentialProxy({
845
981
  socketPath,
846
982
  store: credentialStore,
@@ -848,10 +984,12 @@ program
848
984
  serviceRegistry,
849
985
  policyConfig: policyConfig2,
850
986
  loopback: loadLoopbackOptions(config),
987
+ broker: brokerScope,
851
988
  onRequest: (info) => {
852
989
  auditLogger.logCredentialAccess('system', 'system', {
853
990
  service: info.service,
854
- operation: 'use',
991
+ // A broker resolve hands the value out; everything else injects it.
992
+ operation: info.method === 'BROKER' ? 'read' : 'use',
855
993
  success: !info.error,
856
994
  error: info.error
857
995
  });
@@ -861,6 +999,10 @@ program
861
999
  // Write PID file
862
1000
  writePidFile();
863
1001
  console.log(`Credential proxy: ${socketPath}`);
1002
+ console.log(`Credential broker: ${describeBroker(brokerScope)}`);
1003
+ if (brokerScope?.declarationError()) {
1004
+ console.error(` ⚠ ${brokerScope.declarationError()} — refs declared there are refused until it parses`);
1005
+ }
864
1006
  console.log(`Audit logging: ${config.audit.enabled ? 'enabled' : 'disabled'}`);
865
1007
  console.log(`Credential backend: ${config.credentials.backend}`);
866
1008
  console.log(`PID file: ${getPidFile()}`);
@@ -907,6 +1049,9 @@ openclaw
907
1049
  const serviceRegistry = createServiceRegistry({ configPath: config.services.configPath });
908
1050
  // Start credential proxy
909
1051
  const policyConfig3 = loadPolicyFromConfig(config);
1052
+ // No `broker`: this proxy runs inside the OpenClaw plugin's lifecycle and
1053
+ // must never hand credential values to the gateway or its agents — that
1054
+ // was the ClawScan finding against 0.14.x (v0.15.0; see broker-scope.ts).
910
1055
  const credentialProxy = createCredentialProxy({
911
1056
  socketPath,
912
1057
  store: credentialStore,
@@ -917,7 +1062,8 @@ openclaw
917
1062
  onRequest: (info) => {
918
1063
  auditLogger.logCredentialAccess('system', 'system', {
919
1064
  service: info.service,
920
- operation: 'use',
1065
+ // A broker resolve hands the value out; everything else injects it.
1066
+ operation: info.method === 'BROKER' ? 'read' : 'use',
921
1067
  success: !info.error,
922
1068
  error: info.error
923
1069
  });
@@ -1086,7 +1232,7 @@ hermes
1086
1232
  console.log(` ~/.hermes/.env: ${envWired ? aqua('wired') : 'not wired'} (${envPath})`);
1087
1233
  const shadowed = managedScopeShadowedKeys(integration.configureHermes());
1088
1234
  if (shadowed.length > 0) {
1089
- console.log(` Managed scope: ⚠ ${HERMES_MANAGED_ENV_PATH} pins ${shadowed.join(', ')} — proxy bypassed for these keys`);
1235
+ console.log(` Managed scope: ⚠ ${hermesManagedEnvPath()} pins ${shadowed.join(', ')} — proxy bypassed for these keys`);
1090
1236
  }
1091
1237
  const info = await detectHermes(config.hermes?.binaryPath || 'hermes');
1092
1238
  console.log(` Hermes CLI: ${info.installed ? aqua(`v${info.version}`) : 'not found'}`);
@@ -1150,7 +1296,7 @@ hermes
1150
1296
  {
1151
1297
  const shadowed = managedScopeShadowedKeys(integration.configureHermes());
1152
1298
  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.`);
1299
+ 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
1300
  }
1155
1301
  else {
1156
1302
  pass('No managed-scope override of aquaman env vars');
@@ -1177,7 +1323,38 @@ hermes
1177
1323
  fail(`Vault backend "${config.credentials.backend}" failed: ${err instanceof Error ? err.message : err}`);
1178
1324
  }
1179
1325
  }
1180
- // 5. Hermes installed
1326
+ // 5. Secret-source bindings are declared to the broker (v0.15.0+). The
1327
+ // daemon only materializes declared refs, so a binding in Hermes'
1328
+ // config.yaml alone now resolves to a "not declared" warning at startup.
1329
+ {
1330
+ const bound = hermesSecretSourceRefs();
1331
+ if (bound.error) {
1332
+ fail(`Hermes config unreadable (${bound.error}) — can't check secret-source bindings`);
1333
+ }
1334
+ else if (bound.refs.length === 0) {
1335
+ console.log(` • No aquaman secret-source bindings in ${bound.path} (optional)`);
1336
+ }
1337
+ else {
1338
+ const scope = daemonBrokerScope(config);
1339
+ if (!scope) {
1340
+ fail(`${bound.refs.length} secret-source binding(s) in ${bound.path}, but broker.enabled is false — none will resolve`);
1341
+ }
1342
+ else {
1343
+ const undeclared = bound.refs.filter((ref) => {
1344
+ const r = parseAquamanRef(ref);
1345
+ return !scope.check(r.service, r.key, 'loopback').allowed;
1346
+ });
1347
+ if (undeclared.length === 0) {
1348
+ pass(`All ${bound.refs.length} secret-source binding(s) are declared to the broker`);
1349
+ }
1350
+ else {
1351
+ fail(`Secret-source binding(s) not declared to the broker, so the daemon refuses them: ${undeclared.join(', ')}`);
1352
+ console.log(` → aquaman broker allow ${undeclared.join(' ')}`);
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ // 6. Hermes installed
1181
1358
  const info = await detectHermes(config.hermes?.binaryPath || 'hermes');
1182
1359
  if (info.installed)
1183
1360
  pass(`Hermes CLI installed (v${info.version})`);
@@ -1264,7 +1441,7 @@ program
1264
1441
  openclaw
1265
1442
  .command('setup')
1266
1443
  .description('Full setup for OpenClaw — vault + plugin + auth profiles + (optional) auto-migrate')
1267
- .option('--backend <backend>', 'Credential backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden)')
1444
+ .option('--backend <backend>', 'Credential backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden, keeper)')
1268
1445
  .option('--no-openclaw', 'Skip OpenClaw plugin installation step (run vault setup only)')
1269
1446
  .option('--no-policy', 'Skip request policy preset configuration')
1270
1447
  .option('--non-interactive', 'Use environment variables instead of prompts (for CI)')
@@ -1311,7 +1488,7 @@ openclaw
1311
1488
  }
1312
1489
  }
1313
1490
  // Validate backend
1314
- const validBackends = ['keychain', 'encrypted-file', 'keepassxc', '1password', 'vault', 'systemd-creds', 'bitwarden'];
1491
+ const validBackends = ['keychain', 'encrypted-file', 'keepassxc', '1password', 'vault', 'systemd-creds', 'bitwarden', 'keeper'];
1315
1492
  if (!validBackends.includes(backend)) {
1316
1493
  console.error(` Invalid backend: ${backend}`);
1317
1494
  console.error(` Valid options: ${validBackends.join(', ')}`);
@@ -1429,10 +1606,30 @@ openclaw
1429
1606
  process.exit(1);
1430
1607
  }
1431
1608
  }
1609
+ else if (backend === 'keeper') {
1610
+ const { KeeperStore } = await import('../core/credentials/backends/keeper.js');
1611
+ if (!KeeperStore.isAvailable()) {
1612
+ console.error(' Keeper Commander (keeper) not found.');
1613
+ console.error(' Install: pip install keepercommander');
1614
+ console.error(' Then, once: keeper shell -> login -> this-device register -> this-device persistent-login on');
1615
+ process.exit(1);
1616
+ }
1617
+ if (!process.env['AQUAMAN_KEEPER_FOLDER_UID']) {
1618
+ console.error(' Keeper backend needs the folder that will hold aquaman records.');
1619
+ console.error(' Create one in Keeper, then: export AQUAMAN_KEEPER_FOLDER_UID=<folder UID>');
1620
+ console.error(' (find the UID with: keeper get "<folder name>" --format json)');
1621
+ process.exit(1);
1622
+ }
1623
+ }
1432
1624
  // 2. Run init internally (create dirs, config)
1433
1625
  ensureConfigDir();
1434
1626
  const config = getDefaultConfig();
1435
1627
  config.credentials.backend = backend;
1628
+ if (backend === 'keeper') {
1629
+ config.credentials.keeperFolderUid = process.env['AQUAMAN_KEEPER_FOLDER_UID'];
1630
+ if (process.env['AQUAMAN_KEEPER_CONFIG'])
1631
+ config.credentials.keeperConfigPath = process.env['AQUAMAN_KEEPER_CONFIG'];
1632
+ }
1436
1633
  fs.writeFileSync(configPath, yamlStringify(config), { encoding: 'utf-8', mode: 0o600 });
1437
1634
  // Create audit directory
1438
1635
  const auditDir = path.join(configDir, 'audit');
@@ -1587,6 +1784,19 @@ openclaw
1587
1784
  shouldInstall = answer.toLowerCase() !== 'n';
1588
1785
  }
1589
1786
  if (shouldInstall) {
1787
+ // Detect the gateway version once — it gates the install command
1788
+ // (capability consent on 2026.8.1+) and the credential surface:
1789
+ // SecretRef wiring (canonical, >= 2026.6.5) vs the legacy
1790
+ // auth-profiles.json placeholder. AQUAMAN_OPENCLAW_VERSION overrides
1791
+ // detection (tests + operators pinning behavior explicitly).
1792
+ let ocVersion = process.env.AQUAMAN_OPENCLAW_VERSION || null;
1793
+ if (!ocVersion) {
1794
+ try {
1795
+ const { execSync } = await import('node:child_process');
1796
+ ocVersion = execSync('openclaw --version', { stdio: 'pipe', encoding: 'utf-8', timeout: 5000 }).trim();
1797
+ }
1798
+ catch { /* CLI not on PATH */ }
1799
+ }
1590
1800
  // a. Copy plugin files
1591
1801
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
1592
1802
  const pluginSrc = path.resolve(currentDir, '../../../plugin');
@@ -1597,14 +1807,26 @@ openclaw
1597
1807
  console.log(' \u2713 Plugin installed to ' + pluginDest);
1598
1808
  }
1599
1809
  else if (cliDetected) {
1600
- // npm install — plugin source not bundled, use openclaw's plugin installer
1810
+ // npm install — plugin source not bundled, use openclaw's plugin
1811
+ // installer. OpenClaw 2026.8.1+ requires capability consent for
1812
+ // third-party plugins: interactively we let OpenClaw show its own
1813
+ // consent screen to the human; --non-interactive means the operator
1814
+ // pre-approved, so we pass --accept-capabilities and say so. The
1815
+ // clawhub: source avoids the extra --force an npm spec needs.
1816
+ const consent = pluginInstallNeedsCapabilityConsent(ocVersion);
1817
+ const args = consent
1818
+ ? ['plugins', 'install', 'clawhub:aquaman-plugin', ...(isNonInteractive ? ['--accept-capabilities'] : [])]
1819
+ : ['plugins', 'install', 'aquaman-plugin'];
1601
1820
  try {
1602
- const { execSync: execSyncFallback } = await import('node:child_process');
1603
- execSyncFallback('openclaw plugins install aquaman-plugin', { stdio: 'pipe' });
1821
+ const { execFileSync: execFileSyncFallback } = await import('node:child_process');
1822
+ execFileSyncFallback('openclaw', args, { stdio: consent && !isNonInteractive ? 'inherit' : 'pipe' });
1604
1823
  console.log(' \u2713 Plugin installed via openclaw');
1824
+ if (consent && isNonInteractive) {
1825
+ console.log(' \u2192 Accepted the plugin\u2019s OpenClaw capabilities on your behalf (--non-interactive)');
1826
+ }
1605
1827
  }
1606
1828
  catch {
1607
- console.log(' \u2717 Could not install plugin. Run: openclaw plugins install aquaman-plugin');
1829
+ console.log(` \u2717 Could not install plugin. Run: openclaw ${args.join(' ')}${consent && isNonInteractive ? '' : consent ? ' (then approve its capabilities)' : ''}`);
1608
1830
  }
1609
1831
  }
1610
1832
  // b. Write/merge openclaw.json
@@ -1620,11 +1842,20 @@ openclaw
1620
1842
  openclawConfig.plugins = {};
1621
1843
  if (!openclawConfig.plugins.entries)
1622
1844
  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');
1845
+ // plugins.allow is an allowlist for EVERY plugin, OpenClaw's own
1846
+ // included: on current gateways the anthropic/openai model providers
1847
+ // are stock plugins. Creating a list that holds only aquaman-plugin
1848
+ // (what setup did through v0.14.x) blocked them, so every
1849
+ // anthropic/* model became "Unknown model" (verified 2026-09-18 on
1850
+ // 2026.7.33 and 2026.9.1). Append to an existing list; never create
1851
+ // a restrictive one on the user's behalf.
1852
+ if (Array.isArray(openclawConfig.plugins.allow)) {
1853
+ if (!openclawConfig.plugins.allow.includes('aquaman-plugin')) {
1854
+ openclawConfig.plugins.allow.push('aquaman-plugin');
1855
+ }
1856
+ }
1857
+ else {
1858
+ 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
1859
  }
1629
1860
  const configuredServices = storedServices.length > 0 ? storedServices : ['anthropic', 'openai'];
1630
1861
  openclawConfig.plugins.entries['aquaman-plugin'] = {
@@ -1634,33 +1865,91 @@ openclaw
1634
1865
  services: configuredServices,
1635
1866
  }
1636
1867
  };
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
1868
  // b2. SecretRef provider wiring (v0.14.0+, OpenClaw >= 2026.6.5).
1650
1869
  // Config-level refs in openclaw.json are runtime-read on every
1651
1870
  // version and survive OpenClaw's plaintext scrubs \u2014 no SQLite
1652
1871
  // import step needed, unlike the legacy placeholder.
1653
1872
  const secretRefSupported = supportsSecretRefIntegrations(ocVersion);
1654
- if (secretRefSupported) {
1655
- const wiring = wireSecretRefProviders(openclawConfig, configuredServices);
1873
+ // Which configured channels aquaman can actually take over: the host
1874
+ // must expose an endpoint override and the vault must already hold
1875
+ // the real token. Resolved up front because the wiring is sync, and
1876
+ // only for channels the user actually configured -- a vault probe
1877
+ // can be slow (keepassxc opens and derives a key per call).
1878
+ const channelsConfigured = CHANNEL_ROUTING_SUPPORTED.filter(c => openclawConfig.channels?.[c] && typeof openclawConfig.channels[c] === 'object');
1879
+ const channelsWithVaultCredential = new Set();
1880
+ if (channelsConfigured.length > 0) {
1881
+ const channelRegistry = createServiceRegistry();
1882
+ for (const channel of channelsConfigured) {
1883
+ const def = channelRegistry.get(channel);
1884
+ if (!def)
1885
+ continue;
1886
+ if (await store.exists(channel, def.credentialKey)) {
1887
+ channelsWithVaultCredential.add(channel);
1888
+ }
1889
+ }
1890
+ }
1891
+ // Both routes need the loopback listener: model traffic (provider
1892
+ // baseUrl) and channel egress (apiRoot). Decide once.
1893
+ let loopbackOrigin;
1894
+ let loopbackToken;
1895
+ if (secretRefSupported || channelsConfigured.length > 0) {
1896
+ // OpenClaw's model transport builds its own undici dispatcher: it
1897
+ // ignores the aquaman.local sentinel (its own DNS fails) and never
1898
+ // calls globalThis.fetch, so the plugin's interceptor can't see
1899
+ // provider traffic. Route it at the loopback listener instead —
1900
+ // OpenClaw's documented local-provider pattern (v0.15.0).
1901
+ const lbHost = config.loopback?.host || '127.0.0.1';
1902
+ const lbPort = config.loopback?.port || DEFAULT_LOOPBACK_PORT;
1903
+ const lbToken = config.loopback?.token || generateLoopbackToken();
1904
+ config.loopback = { enabled: true, host: lbHost, port: lbPort, token: lbToken };
1905
+ saveConfig(config);
1906
+ loopbackOrigin = `http://${lbHost}:${lbPort}`;
1907
+ loopbackToken = lbToken;
1908
+ console.log(` \u2713 Loopback listener enabled on ${loopbackOrigin} (token-gated, loopback-bound)`);
1909
+ }
1910
+ if (secretRefSupported && loopbackOrigin) {
1911
+ const wiring = wireSecretRefProviders(openclawConfig, configuredServices, { loopbackOrigin });
1656
1912
  if (wiring.wiredProviders.length > 0) {
1657
1913
  console.log(` \u2713 SecretRef wiring for ${wiring.wiredProviders.join(', ')} (canonical credential surface)`);
1658
1914
  }
1915
+ if (wiring.baseUrlProviders.length > 0) {
1916
+ console.log(` \u2713 Model traffic routed through the proxy for ${wiring.baseUrlProviders.join(', ')}`);
1917
+ }
1918
+ if (wiring.keptUserBaseUrl.length > 0) {
1919
+ console.log(` \u2192 Left existing user-set baseUrl untouched for: ${wiring.keptUserBaseUrl.join(', ')} (proxy NOT in the path for those)`);
1920
+ }
1659
1921
  const userKept = wiring.skippedProviders.filter(s => s === 'anthropic' || s === 'openai');
1660
1922
  if (userKept.length > 0) {
1661
1923
  console.log(` \u2192 Left existing user-set apiKey untouched for: ${userKept.join(', ')}`);
1662
1924
  }
1663
1925
  }
1926
+ // b3. Channel egress routing (v0.15.0+). Channels each build their
1927
+ // own undici dispatcher, so the plugin's fetch interceptor never
1928
+ // sees them. Telegram is the one bundled channel with an endpoint
1929
+ // override, and the Bot API carries its token in the path, so the
1930
+ // placeholder written here is the loopback token itself.
1931
+ if (loopbackOrigin && loopbackToken) {
1932
+ const routing = wireChannelRouting(openclawConfig, {
1933
+ loopbackOrigin,
1934
+ loopbackToken,
1935
+ hasVaultCredential: (svc) => channelsWithVaultCredential.has(svc)
1936
+ });
1937
+ if (routing.routedChannels.length > 0) {
1938
+ // A routed channel that isn't in proxiedServices gets a 404 from
1939
+ // our own proxy, which is exactly the dead bot the all-or-nothing
1940
+ // wiring exists to avoid. Open the route we just pointed at us.
1941
+ const proxied = config.credentials.proxiedServices;
1942
+ const added = routing.routedChannels.filter(c => !proxied.includes(c));
1943
+ if (added.length > 0) {
1944
+ config.credentials.proxiedServices = [...proxied, ...added];
1945
+ saveConfig(config);
1946
+ }
1947
+ console.log(` \u2713 Channel traffic routed through the proxy for ${routing.routedChannels.join(', ')}`);
1948
+ }
1949
+ for (const skip of routing.skipped) {
1950
+ console.log(` \u2192 ${skip.channel} left as-is: ${describeChannelSkip(skip.reason, skip.channel)}`);
1951
+ }
1952
+ }
1664
1953
  fs.writeFileSync(openclawJsonPath, JSON.stringify(openclawConfig, null, 2), 'utf-8');
1665
1954
  console.log(' \u2713 Plugin config written to ' + openclawJsonPath);
1666
1955
  // c. Generate auth-profiles.json (only if missing). With SecretRef
@@ -1669,6 +1958,12 @@ openclaw
1669
1958
  const profilesPath = path.join(openclawStateDir, 'agents', 'main', 'agent', 'auth-profiles.json');
1670
1959
  if (secretRefSupported) {
1671
1960
  console.log(' \u2192 Legacy auth-profiles.json placeholder skipped (SecretRef wiring replaces it)');
1961
+ if (legacyAuthProfilesBlockProviders(ocVersion) && fs.existsSync(profilesPath)) {
1962
+ // Left behind by aquaman-plugin <= 0.14.x. On 2026.8.1+ it locks
1963
+ // anthropic/openai out until OpenClaw archives it.
1964
+ console.log(' \u2717 A legacy auth-profiles.json from an older aquaman-plugin blocks providers on this OpenClaw');
1965
+ console.log(' Run once: openclaw doctor --fix (archives it; aquaman-plugin 0.15.0+ never recreates it)');
1966
+ }
1672
1967
  }
1673
1968
  else if (!fs.existsSync(profilesPath)) {
1674
1969
  const profiles = {};
@@ -1780,6 +2075,9 @@ openclaw
1780
2075
  // 2. Backend accessible
1781
2076
  let config;
1782
2077
  let store = null;
2078
+ // Services the vault holds anything for. Used by the channel-routing
2079
+ // check below, which runs outside the store's try block.
2080
+ const vaultServices = new Set();
1783
2081
  try {
1784
2082
  config = loadConfig();
1785
2083
  if (config.credentials.backend === 'systemd-creds') {
@@ -1788,6 +2086,12 @@ openclaw
1788
2086
  throw new Error('systemd-creds backend requires systemd >= 256 with --user support');
1789
2087
  }
1790
2088
  }
2089
+ if (config.credentials.backend === 'keeper') {
2090
+ const { KeeperStore } = await import('../core/credentials/backends/keeper.js');
2091
+ if (!KeeperStore.isAvailable()) {
2092
+ throw new Error('Keeper Commander (keeper) not found. Install: pip install keepercommander');
2093
+ }
2094
+ }
1791
2095
  if (config.credentials.backend === 'bitwarden') {
1792
2096
  const { BitwardenStore } = await import('../core/credentials/backends/bitwarden.js');
1793
2097
  if (!BitwardenStore.isAvailable()) {
@@ -1808,10 +2112,14 @@ openclaw
1808
2112
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
1809
2113
  bitwardenFolder: config.credentials.bitwardenFolder,
1810
2114
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
1811
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
2115
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
2116
+ keeperFolderUid: config.credentials.keeperFolderUid,
2117
+ keeperConfigPath: config.credentials.keeperConfigPath
1812
2118
  });
1813
2119
  // 3. Count credentials
1814
2120
  const creds = await store.list();
2121
+ for (const c of creds)
2122
+ vaultServices.add(c.service);
1815
2123
  if (creds.length > 0) {
1816
2124
  const names = creds.map(c => `${c.service}/${c.key}`).join(', ');
1817
2125
  console.log(` \u2713 ${aqua('Backend:')} ${config.credentials.backend} (accessible)`);
@@ -1916,6 +2224,12 @@ openclaw
1916
2224
  console.log(` ${svc}: ${summary}`);
1917
2225
  }
1918
2226
  }
2227
+ // Deny rules that can never fire (pre-0.15.0 preset shapes) — a
2228
+ // security issue, since the operator believes the endpoint is blocked.
2229
+ for (const warning of lintPolicyConfig(policyConfigDoc)) {
2230
+ console.log(` \u2717 ${aqua('Policy')} ${warning}`);
2231
+ issues++;
2232
+ }
1919
2233
  // Warn about policies for non-proxied services
1920
2234
  if (config.credentials.proxiedServices) {
1921
2235
  for (const svc of Object.keys(policyConfigDoc)) {
@@ -2020,9 +2334,21 @@ openclaw
2020
2334
  if (fs.existsSync(openclawJsonPath)) {
2021
2335
  try {
2022
2336
  const openclawConfig = JSON.parse(fs.readFileSync(openclawJsonPath, 'utf-8'));
2023
- const allowList = openclawConfig.plugins?.allow || [];
2024
- if (allowList.includes('aquaman-plugin')) {
2337
+ const allowList = Array.isArray(openclawConfig.plugins?.allow) ? openclawConfig.plugins.allow : undefined;
2338
+ if (!allowList) {
2339
+ console.log(` \u2713 ${aqua('Plugin')} allowed (no plugins.allow trust list; \`openclaw security audit\` notes extensions_no_allowlist)`);
2340
+ }
2341
+ else if (allowList.includes('aquaman-plugin')) {
2025
2342
  console.log(` \u2713 ${aqua('Plugin')} in plugins.allow trust list`);
2343
+ // A list that excludes OpenClaw's own provider plugins blocks
2344
+ // them (aquaman setup <= 0.14.x wrote exactly such a list).
2345
+ const svcs = openclawConfig.plugins?.entries?.['aquaman-plugin']?.config?.services ?? ['anthropic', 'openai'];
2346
+ const blocked = ['anthropic', 'openai'].filter(p => svcs.includes(p) && !allowList.includes(p));
2347
+ if (blocked.length > 0 && supportsSecretRefIntegrations(openclawVersion)) {
2348
+ console.log(` \u2717 ${aqua('Plugin')} plugins.allow blocks OpenClaw\u2019s own provider plugin(s): ${blocked.join(', ')} (their models report "Unknown model")`);
2349
+ 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)`);
2350
+ issues++;
2351
+ }
2026
2352
  }
2027
2353
  else {
2028
2354
  console.log(` \u2717 ${aqua('Plugin')} not in plugins.allow trust list`);
@@ -2048,6 +2374,17 @@ openclaw
2048
2374
  if (status.providerConfigured && status.missingProviders.length === 0) {
2049
2375
  secretRefFullyWired = true;
2050
2376
  console.log(` ✓ ${aqua('SecretRef')} wiring active (${status.wiredProviders.join(', ') || 'no providers'})`);
2377
+ // The apiKey ref alone doesn't put the proxy in the path: without
2378
+ // a loopback baseUrl OpenClaw's transport calls the upstream
2379
+ // directly and the placeholder fails there (v0.15.0).
2380
+ if (status.missingBaseUrl.length > 0) {
2381
+ console.log(` ✗ ${aqua('Model routing')} ${status.missingBaseUrl.join(', ')} still call the provider directly — the proxy is bypassed`);
2382
+ console.log(' → Run: aquaman openclaw setup (points models.providers.<svc>.baseUrl at the loopback listener)');
2383
+ issues++;
2384
+ }
2385
+ else if (status.baseUrlProviders.length > 0) {
2386
+ console.log(` ✓ ${aqua('Model routing')} through the proxy for ${status.baseUrlProviders.join(', ')}`);
2387
+ }
2051
2388
  }
2052
2389
  else if (status.providerConfigured) {
2053
2390
  // Half-migrated: the provider block exists but some providers still
@@ -2068,6 +2405,42 @@ openclaw
2068
2405
  // openclaw.json invalid — already reported above
2069
2406
  }
2070
2407
  }
2408
+ // 8.6. Channel egress routing (v0.15.0+). Channels build their own
2409
+ // undici dispatcher, so the plugin's fetch interceptor never sees
2410
+ // them. Only channels whose endpoint OpenClaw lets us override can
2411
+ // be routed; the rest are at-rest storage only and say so.
2412
+ if (fs.existsSync(openclawJsonPath)) {
2413
+ try {
2414
+ const openclawConfig = JSON.parse(fs.readFileSync(openclawJsonPath, 'utf-8'));
2415
+ const routing = channelRoutingStatus(openclawConfig, {
2416
+ hasVaultCredential: (svc) => vaultServices.has(svc)
2417
+ });
2418
+ if (routing.routed.length > 0) {
2419
+ console.log(` \u2713 ${aqua('Channel routing')} through the proxy for ${routing.routed.join(', ')}`);
2420
+ // Routed but not proxied means our own proxy 404s the channel.
2421
+ const notProxied = routing.routed.filter(c => !(config?.credentials?.proxiedServices ?? []).includes(c));
2422
+ if (notProxied.length > 0) {
2423
+ console.log(` \u2717 ${aqua('Channel routing')} ${notProxied.join(', ')} routed here but missing from proxiedServices, so the proxy returns 404`);
2424
+ console.log(` \u2192 Add ${notProxied.join(', ')} to credentials.proxiedServices in ~/.aquaman/config.yaml (or re-run: aquaman openclaw setup)`);
2425
+ issues++;
2426
+ }
2427
+ }
2428
+ for (const skip of routing.notRouted) {
2429
+ console.log(` \u2717 ${aqua('Channel routing')} ${skip.channel} sends its token straight to the vendor`);
2430
+ console.log(` \u2192 ${describeChannelSkip(skip.reason, skip.channel)}`);
2431
+ issues++;
2432
+ }
2433
+ if (routing.unroutable.length > 0) {
2434
+ // Not a failure: nothing the user can do, and the credentials can
2435
+ // still live in the vault. Stated so nobody assumes coverage.
2436
+ console.log(` \u2192 ${aqua('Channel routing')} not available for ${routing.unroutable.join(', ')} on this OpenClaw (no endpoint override)`);
2437
+ console.log(' (their tokens can live in the vault, but the proxy is not in their egress path)');
2438
+ }
2439
+ }
2440
+ catch {
2441
+ // openclaw.json invalid \u2014 already reported above
2442
+ }
2443
+ }
2071
2444
  // 9. Auth profiles. With SecretRef wiring active this surface is
2072
2445
  // legacy/inert. Otherwise: OpenClaw >= 2026.6.5 reads provider auth
2073
2446
  // profiles from each agent's openclaw-agent.sqlite and no longer reads
@@ -2077,7 +2450,18 @@ openclaw
2077
2450
  const agentDir = path.join(openclawStateDir, 'agents', 'main', 'agent');
2078
2451
  const profilesPath = path.join(agentDir, 'auth-profiles.json');
2079
2452
  const sqlitePath = path.join(agentDir, 'openclaw-agent.sqlite');
2080
- if (secretRefFullyWired) {
2453
+ if (legacyAuthProfilesBlockProviders(openclawVersion) && fs.existsSync(profilesPath)) {
2454
+ // OpenClaw 2.0 line: the legacy JSON is not inert any more — it blocks
2455
+ // providers with AUTH_PROFILE_MIGRATION_REQUIRED (#114033). Older
2456
+ // aquaman-plugin versions rewrote it on every load; 0.15.0+ doesn't.
2457
+ console.log(` \u2717 ${aqua('Auth profiles')} legacy auth-profiles.json blocks anthropic/openai on ${openclawVersion} (AUTH_PROFILE_MIGRATION_REQUIRED)`);
2458
+ if (!secretRefFullyWired) {
2459
+ console.log(' \u2192 First: aquaman openclaw setup (wires the SecretRef provider that replaces it)');
2460
+ }
2461
+ console.log(' \u2192 Then run once: openclaw doctor --fix (archives the file; aquaman-plugin 0.15.0+ never recreates it)');
2462
+ issues++;
2463
+ }
2464
+ else if (secretRefFullyWired) {
2081
2465
  console.log(` ✓ ${aqua('Auth profiles')} not needed (SecretRef wiring replaces the placeholder flow)`);
2082
2466
  if (fs.existsSync(profilesPath)) {
2083
2467
  console.log(' → Optional: the legacy auth-profiles.json placeholder is inert and may be deleted');
@@ -2095,8 +2479,8 @@ openclaw
2095
2479
  console.log(` \u2713 ${aqua('Auth profiles')} (SQLite store present; OpenClaw \u2265 2026.6.5)`);
2096
2480
  }
2097
2481
  else {
2098
- console.log(` \u2717 ${aqua('Auth profiles')} missing (no SQLite store found)`);
2099
- console.log(' \u2192 Run: aquaman setup, then: openclaw doctor --fix');
2482
+ console.log(` \u2717 ${aqua('Auth profiles')} missing: no credential wiring for OpenClaw ${openclawVersion}`);
2483
+ console.log(' \u2192 Run: aquaman openclaw setup (wires the SecretRef provider; the plugin no longer writes auth-profiles.json here)');
2100
2484
  issues++;
2101
2485
  }
2102
2486
  }
@@ -2285,7 +2669,9 @@ credentials
2285
2669
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
2286
2670
  bitwardenFolder: config.credentials.bitwardenFolder,
2287
2671
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
2288
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
2672
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
2673
+ keeperFolderUid: config.credentials.keeperFolderUid,
2674
+ keeperConfigPath: config.credentials.keeperConfigPath
2289
2675
  });
2290
2676
  }
2291
2677
  catch (error) {
@@ -2368,7 +2754,9 @@ credentials
2368
2754
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
2369
2755
  bitwardenFolder: config.credentials.bitwardenFolder,
2370
2756
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
2371
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
2757
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
2758
+ keeperFolderUid: config.credentials.keeperFolderUid,
2759
+ keeperConfigPath: config.credentials.keeperConfigPath
2372
2760
  });
2373
2761
  }
2374
2762
  catch {
@@ -2405,7 +2793,9 @@ credentials
2405
2793
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
2406
2794
  bitwardenFolder: config.credentials.bitwardenFolder,
2407
2795
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
2408
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
2796
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
2797
+ keeperFolderUid: config.credentials.keeperFolderUid,
2798
+ keeperConfigPath: config.credentials.keeperConfigPath
2409
2799
  });
2410
2800
  }
2411
2801
  catch {
@@ -2423,7 +2813,7 @@ credentials
2423
2813
  credentials
2424
2814
  .command('guide')
2425
2815
  .description('Show setup commands for seeding credentials based on your backend')
2426
- .option('--backend <backend>', 'Override backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden)')
2816
+ .option('--backend <backend>', 'Override backend (keychain, encrypted-file, keepassxc, 1password, vault, systemd-creds, bitwarden, keeper)')
2427
2817
  .option('--service <name>', 'Show commands for a single service only')
2428
2818
  .action(async (options) => {
2429
2819
  const config = loadConfig();
@@ -2584,6 +2974,105 @@ policy
2584
2974
  console.log(` \u2717 DENIED by rule: ${rule.method} ${rule.path} \u2192 ${rule.action}`);
2585
2975
  }
2586
2976
  });
2977
+ // Credential broker scope (v0.15.0+)
2978
+ const broker = program
2979
+ .command('broker')
2980
+ .description('Credential broker scope: which refs `aquaman daemon` may hand out');
2981
+ broker
2982
+ .command('list')
2983
+ .description('List the refs the daemon broker will materialize, and where each is declared')
2984
+ .action(() => {
2985
+ const config = loadConfig();
2986
+ console.log('Credential broker: served by `aquaman daemon` only. OpenClaw-hosted proxies never hand out credential values.\n');
2987
+ if (config.broker?.enabled === false) {
2988
+ console.log(' Disabled (broker.enabled: false in config.yaml) — every broker request is refused.');
2989
+ return;
2990
+ }
2991
+ const scope = daemonBrokerScope(config);
2992
+ const declared = scope.declared();
2993
+ if (declared.length === 0) {
2994
+ console.log(' No declared refs — every broker request is refused.');
2995
+ }
2996
+ for (const d of declared)
2997
+ console.log(` ${d.ref} (${d.source})`);
2998
+ const err = scope.declarationError();
2999
+ if (err)
3000
+ console.log(`\n ⚠ ${err} — refs declared there are refused until it parses`);
3001
+ console.log(`\n Never over the loopback listener: ${HERMES_SUPPORTED_SERVICES.join(', ')} (process-isolated on the proxy path)`);
3002
+ console.log(` Coding-agent refs: ${defaultProjectsPath()} (aquaman coder project add ...)`);
3003
+ console.log(` Other refs: broker.allowedRefs in ${getConfigPath()} (aquaman broker allow ...)`);
3004
+ });
3005
+ broker
3006
+ .command('allow')
3007
+ .description('Allow aquaman://service/key refs to be materialized (e.g. Hermes secret-source bindings)')
3008
+ .argument('<refs...>', 'one or more aquaman://service/key refs')
3009
+ .action((refs) => {
3010
+ updateBrokerAllowedRefs(refs, 'allow');
3011
+ });
3012
+ broker
3013
+ .command('revoke')
3014
+ .description('Stop materializing refs added with `aquaman broker allow`')
3015
+ .argument('<refs...>', 'one or more aquaman://service/key refs')
3016
+ .action((refs) => {
3017
+ updateBrokerAllowedRefs(refs, 'revoke');
3018
+ });
3019
+ // v0.16.0: fills the "run a command, read the secret from stdout" slot every
3020
+ // host now has (sbx secret set --command, Codex model_providers.*.auth.command,
3021
+ // Claude Code apiKeyHelper, OpenClaw exec providers, Hermes command sources).
3022
+ // This materializes the value into the CALLING process, so it is not process
3023
+ // isolation; it is scope-checked and audited because it goes through the daemon.
3024
+ program
3025
+ .command('get')
3026
+ .description('Print a declared credential to stdout, via the running daemon (for host credential-helper commands)')
3027
+ .argument('<ref>', 'aquaman://service/key, declared with `aquaman broker allow` or in projects.yaml')
3028
+ .option('--show', 'Allow printing to an interactive terminal')
3029
+ .option('--timeout <ms>', 'How long to wait for the daemon (vaults that prompt may need longer)', '15000')
3030
+ .addHelpText('after', `
3031
+ Examples:
3032
+ sbx secret set github --command "$(command -v node) $(command -v aquaman) get aquaman://github/token"
3033
+ Codex config.toml: [model_providers.openai.auth] command = "aquaman" args = ["get", "aquaman://openai/api_key"]
3034
+ Claude Code: "apiKeyHelper": "aquaman get aquaman://anthropic/api_key"
3035
+
3036
+ The value lands in whichever process runs this command. Every read is checked
3037
+ against the broker scope and written to the audit log.`)
3038
+ .action(async (ref, opts) => {
3039
+ const parsed = parseAquamanRef(ref);
3040
+ if (!parsed) {
3041
+ console.error(`Invalid ref: ${ref}`);
3042
+ console.error('Refs look like aquaman://<service>/<key>, e.g. aquaman://github/token');
3043
+ process.exit(1);
3044
+ }
3045
+ const timeoutMs = Number(opts.timeout);
3046
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
3047
+ console.error(`Invalid --timeout: ${opts.timeout} (milliseconds, a positive integer)`);
3048
+ process.exit(1);
3049
+ }
3050
+ // Refuse before resolving, so an accidental run at a prompt neither
3051
+ // prints the secret into scrollback nor spends an audited read.
3052
+ if (process.stdout.isTTY && !opts.show) {
3053
+ console.error('Refusing to print a credential to a terminal.');
3054
+ console.error('This command is meant to be run by another program. To print it anyway, pass --show.');
3055
+ process.exit(1);
3056
+ }
3057
+ const result = await brokerResolve(path.join(getConfigDir(), 'proxy.sock'), parsed.service, parsed.key, timeoutMs);
3058
+ if ('value' in result) {
3059
+ // No trailing newline when piped: credential-helper consumers differ on
3060
+ // whether they trim, and a stray "\n" in an auth header breaks requests.
3061
+ process.stdout.write(process.stdout.isTTY ? `${result.value}\n` : result.value);
3062
+ return;
3063
+ }
3064
+ console.error(`Error: ${result.error}`);
3065
+ if (result.code === 'broker_ref_not_declared') {
3066
+ console.error(`Fix: aquaman broker allow ${ref}`);
3067
+ }
3068
+ else if (result.code === 'broker_disabled') {
3069
+ console.error('Fix: the proxy on this socket does not serve the broker. If the OpenClaw plugin started it, run `aquaman daemon` instead (the last proxy started owns the socket).');
3070
+ }
3071
+ else if (result.fix) {
3072
+ console.error(`Fix: ${result.fix}`);
3073
+ }
3074
+ process.exit(1);
3075
+ });
2587
3076
  // Migration commands
2588
3077
  openclaw
2589
3078
  .command('migrate')
@@ -3017,7 +3506,9 @@ openclaw
3017
3506
  keepassxcKeyFilePath: config.credentials.keepassxcKeyFilePath,
3018
3507
  bitwardenFolder: config.credentials.bitwardenFolder,
3019
3508
  bitwardenOrganizationId: config.credentials.bitwardenOrganizationId,
3020
- bitwardenCollectionId: config.credentials.bitwardenCollectionId
3509
+ bitwardenCollectionId: config.credentials.bitwardenCollectionId,
3510
+ keeperFolderUid: config.credentials.keeperFolderUid,
3511
+ keeperConfigPath: config.credentials.keeperConfigPath
3021
3512
  });
3022
3513
  const creds = await store.list();
3023
3514
  console.log(`\nStored credentials: ${creds.length}`);