aquaman-plugin 0.11.3 → 0.11.5
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.
- package/README.md +38 -10
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +502 -0
- package/dist/index.js.map +1 -0
- package/dist/src/config-schema.d.ts +37 -0
- package/dist/src/config-schema.d.ts.map +1 -0
- package/dist/src/config-schema.js +58 -0
- package/dist/src/config-schema.js.map +1 -0
- package/dist/src/http-interceptor.d.ts +48 -0
- package/dist/src/http-interceptor.d.ts.map +1 -0
- package/dist/src/http-interceptor.js +143 -0
- package/dist/src/http-interceptor.js.map +1 -0
- package/dist/src/proxy-health.d.ts +23 -0
- package/dist/src/proxy-health.d.ts.map +1 -0
- package/dist/src/proxy-health.js +61 -0
- package/dist/src/proxy-health.js.map +1 -0
- package/dist/src/proxy-manager.d.ts +80 -0
- package/dist/src/proxy-manager.d.ts.map +1 -0
- package/dist/src/proxy-manager.js +279 -0
- package/dist/src/proxy-manager.js.map +1 -0
- package/openclaw.plugin.json +6 -1
- package/package.json +7 -6
- package/index.ts +0 -592
- package/src/commands.ts +0 -306
- package/src/config-schema.ts +0 -68
- package/src/http-interceptor.ts +0 -176
- package/src/index.ts +0 -62
- package/src/plugin.ts +0 -281
- package/src/proxy-health.ts +0 -67
- package/src/proxy-manager.ts +0 -309
package/README.md
CHANGED
|
@@ -51,6 +51,42 @@ The `aquaman` proxy binary is bundled as an npm dependency — no separate downl
|
|
|
51
51
|
> **Using npm?** `npm install -g aquaman-proxy && aquaman setup` does
|
|
52
52
|
> the same thing. Use this if you prefer managing packages with npm.
|
|
53
53
|
|
|
54
|
+
## Security model
|
|
55
|
+
|
|
56
|
+
Aquaman keeps API credentials out of the agent process by running them in a separate proxy process. The agent never sees the secret — only a sentinel base URL that the proxy intercepts, authenticates, and forwards. See the [architecture diagram in the main README](https://github.com/tech4242/aquaman#architecture-decision-isolation-vs-detection).
|
|
57
|
+
|
|
58
|
+
**Proxy process**
|
|
59
|
+
|
|
60
|
+
- The plugin spawns the `aquaman` binary from the `aquaman-proxy` npm package, which is declared as an exact-pinned dependency (no semver range) in the plugin's `package.json` and published by the same author. After spawn the plugin checks the running proxy's reported version against the plugin's own and warns if they disagree.
|
|
61
|
+
- The spawn is what triggers the `dangerous-exec` finding in OpenClaw's static scanner — it's intentional and is the whole point of the plugin.
|
|
62
|
+
|
|
63
|
+
**HTTP interceptor**
|
|
64
|
+
|
|
65
|
+
- Only services listed in the plugin's `services` config get their traffic redirected to the local proxy. As of v0.11.4, the interceptor filters its known-host map by your `services` list — channels you didn't opt into keep talking to the upstream directly.
|
|
66
|
+
- The interceptor uses a Unix Domain Socket (no TCP, no network exposure).
|
|
67
|
+
|
|
68
|
+
**Auth profiles**
|
|
69
|
+
|
|
70
|
+
- On load the plugin writes `~/.openclaw/agents/<id>/agent/auth-profiles.json` with placeholder API-key entries for `anthropic` and `openai` so OpenClaw doesn't reject requests before they reach the proxy. The proxy strips the placeholder and injects the real credential.
|
|
71
|
+
- The plugin never overwrites an existing `auth-profiles.json`. To suppress the generation entirely, set `autoGenerateAuthProfiles: false` in the plugin config (v0.11.4+).
|
|
72
|
+
|
|
73
|
+
**Audit log**
|
|
74
|
+
|
|
75
|
+
- Every credential use is recorded in `~/.aquaman/audit/current.jsonl` with a SHA-256 hash chain so tampering is detectable. The log stays local — no telemetry.
|
|
76
|
+
- `aquaman doctor` surfaces audit log issues; `aquaman audit tail` shows recent entries.
|
|
77
|
+
- Operators can constrain which upstream endpoints get proxied (and therefore credentialed) via the `policy` config in `~/.aquaman/config.yaml`. Denied requests return 403 before any credential is injected.
|
|
78
|
+
|
|
79
|
+
### Scanner findings
|
|
80
|
+
|
|
81
|
+
`openclaw security audit --deep` reports two expected findings:
|
|
82
|
+
|
|
83
|
+
- **`dangerous-exec`** on the proxy-manager module — the plugin spawns the proxy as a separate process. This is how credential isolation works.
|
|
84
|
+
- **`tools_reachable_permissive_policy`** — advisory about your tool policy, not an aquaman vulnerability. Set `"tools": { "profile": "coding" }` in `openclaw.json` if your agents handle untrusted input.
|
|
85
|
+
|
|
86
|
+
ClawHub's ClawScan additionally produces a higher-level review of plugin behavior. The current scan acknowledges credential isolation, proxy spawn, the host map, the auth-profiles generation, and the audit log — see the publisher note on the package page for context on each item.
|
|
87
|
+
|
|
88
|
+
`aquaman setup` adds the plugin to `plugins.allow` automatically.
|
|
89
|
+
|
|
54
90
|
## Available Commands
|
|
55
91
|
|
|
56
92
|
All commands work via OpenClaw CLI or your terminal:
|
|
@@ -77,19 +113,11 @@ Troubleshooting: `openclaw aquaman doctor` or `aquaman doctor`
|
|
|
77
113
|
| Key | Type | Default | Description |
|
|
78
114
|
|-----|------|---------|-------------|
|
|
79
115
|
| `backend` | `"keychain"` \| `"1password"` \| `"vault"` \| `"encrypted-file"` \| `"keepassxc"` \| `"systemd-creds"` \| `"bitwarden"` | `"keychain"` | Credential store |
|
|
80
|
-
| `services` | `string[]` | `["anthropic", "openai"]` | Services to proxy |
|
|
116
|
+
| `services` | `string[]` | `["anthropic", "openai"]` | Services to proxy (also gates which hostnames the interceptor redirects, v0.11.4+) |
|
|
117
|
+
| `autoGenerateAuthProfiles` | `boolean` | `true` | Auto-generate `auth-profiles.json` with placeholder anthropic/openai entries when the file is absent. Set `false` to manage your own (v0.11.4+) |
|
|
81
118
|
|
|
82
119
|
> Advanced settings (audit, vault, request policies) go in `~/.aquaman/config.yaml`. See [request policy docs](https://github.com/tech4242/aquaman#request-policies).
|
|
83
120
|
|
|
84
|
-
## Security Audit
|
|
85
|
-
|
|
86
|
-
`openclaw security audit --deep` reports two expected findings:
|
|
87
|
-
|
|
88
|
-
- **`dangerous-exec`** on `proxy-manager.ts` — the plugin spawns the proxy as a separate process. This is how credential isolation works.
|
|
89
|
-
- **`tools_reachable_permissive_policy`** — advisory about your tool policy, not an aquaman vulnerability. Set `"tools": { "profile": "coding" }` in `openclaw.json` if your agents handle untrusted input.
|
|
90
|
-
|
|
91
|
-
`aquaman setup` adds the plugin to `plugins.allow` automatically.
|
|
92
|
-
|
|
93
121
|
## Documentation
|
|
94
122
|
|
|
95
123
|
See the [main README](https://github.com/tech4242/aquaman#readme) for the full security model, architecture diagrams, request policy config, and manual testing guides.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aquaman OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Credential isolation for OpenClaw.
|
|
5
|
+
* Credentials never enter the agent process - they're managed by a separate proxy.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* 1. Install aquaman: npm install -g aquaman-proxy
|
|
9
|
+
* 2. Store credentials: aquaman credentials add anthropic api_key
|
|
10
|
+
* 3. Enable this plugin in openclaw.json
|
|
11
|
+
*
|
|
12
|
+
* The plugin will:
|
|
13
|
+
* - Start the aquaman proxy on plugin load
|
|
14
|
+
* - Set ANTHROPIC_BASE_URL, OPENAI_BASE_URL etc. to route through proxy via UDS
|
|
15
|
+
* - The proxy injects credentials into requests
|
|
16
|
+
* - Agent never sees the actual API keys
|
|
17
|
+
*/
|
|
18
|
+
interface OpenClawPluginLogger {
|
|
19
|
+
info(msg: string): void;
|
|
20
|
+
warn(msg: string): void;
|
|
21
|
+
error(msg: string): void;
|
|
22
|
+
}
|
|
23
|
+
interface OpenClawPluginApi {
|
|
24
|
+
logger: OpenClawPluginLogger;
|
|
25
|
+
pluginConfig: unknown;
|
|
26
|
+
registerService(def: {
|
|
27
|
+
id: string;
|
|
28
|
+
start(ctx: {
|
|
29
|
+
logger: OpenClawPluginLogger;
|
|
30
|
+
}): void | Promise<void>;
|
|
31
|
+
stop(ctx: {
|
|
32
|
+
logger: OpenClawPluginLogger;
|
|
33
|
+
}): void | Promise<void>;
|
|
34
|
+
}): void;
|
|
35
|
+
registerCommand(def: {
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
acceptsArgs: boolean;
|
|
39
|
+
requireAuth: boolean;
|
|
40
|
+
handler(): Promise<{
|
|
41
|
+
text: string;
|
|
42
|
+
}>;
|
|
43
|
+
}): void;
|
|
44
|
+
registerCli?(fn: (opts: {
|
|
45
|
+
program: any;
|
|
46
|
+
}) => void, opts: {
|
|
47
|
+
commands: string[];
|
|
48
|
+
}): void;
|
|
49
|
+
registerTool(factory: () => {
|
|
50
|
+
name: string;
|
|
51
|
+
label: string;
|
|
52
|
+
description: string;
|
|
53
|
+
parameters: {
|
|
54
|
+
type: "object";
|
|
55
|
+
properties: Record<string, unknown>;
|
|
56
|
+
required: string[];
|
|
57
|
+
};
|
|
58
|
+
execute(toolCallId: string, params: unknown): Promise<{
|
|
59
|
+
content: {
|
|
60
|
+
type: "text";
|
|
61
|
+
text: string;
|
|
62
|
+
}[];
|
|
63
|
+
details: unknown;
|
|
64
|
+
}>;
|
|
65
|
+
}, opts: {
|
|
66
|
+
names: string[];
|
|
67
|
+
}): void;
|
|
68
|
+
}
|
|
69
|
+
type OpenClawPluginDefinition = {
|
|
70
|
+
id?: string;
|
|
71
|
+
name?: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
version?: string;
|
|
74
|
+
register?: (api: OpenClawPluginApi) => void | Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Aquaman OpenClaw Plugin Definition
|
|
78
|
+
*/
|
|
79
|
+
declare const plugin: OpenClawPluginDefinition;
|
|
80
|
+
export default plugin;
|
|
81
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AASH,UAAU,oBAAoB;IAC5B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,UAAU,iBAAiB;IACzB,MAAM,EAAE,oBAAoB,CAAC;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,CAAC,GAAG,EAAE;QACnB,EAAE,EAAE,MAAM,CAAC;QACX,KAAK,CAAC,GAAG,EAAE;YAAE,MAAM,EAAE,oBAAoB,CAAA;SAAE,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACnE,IAAI,CAAC,GAAG,EAAE;YAAE,MAAM,EAAE,oBAAoB,CAAA;SAAE,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACnE,GAAG,IAAI,CAAC;IACT,eAAe,CAAC,GAAG,EAAE;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,WAAW,EAAE,OAAO,CAAC;QACrB,WAAW,EAAE,OAAO,CAAC;QACrB,OAAO,IAAI,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACtC,GAAG,IAAI,CAAC;IACT,WAAW,CAAC,CACV,EAAE,EAAE,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,GAAG,CAAA;KAAE,KAAK,IAAI,EACpC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE,GAC3B,IAAI,CAAC;IACR,YAAY,CACV,OAAO,EAAE,MAAM;QACb,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,EAAE;YAAE,IAAI,EAAE,QAAQ,CAAC;YAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC;QACxF,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;YACpD,OAAO,EAAE;gBAAE,IAAI,EAAE,MAAM,CAAC;gBAAC,IAAI,EAAE,MAAM,CAAA;aAAE,EAAE,CAAC;YAC1C,OAAO,EAAE,OAAO,CAAC;SAClB,CAAC,CAAC;KACJ,EACD,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,GACxB,IAAI,CAAC;CACT;AAED,KAAK,wBAAwB,GAAG;IAC9B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D,CAAC;AAqSF;;GAEG;AACH,QAAA,MAAM,MAAM,EAAE,wBAqPb,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aquaman OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Credential isolation for OpenClaw.
|
|
5
|
+
* Credentials never enter the agent process - they're managed by a separate proxy.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* 1. Install aquaman: npm install -g aquaman-proxy
|
|
9
|
+
* 2. Store credentials: aquaman credentials add anthropic api_key
|
|
10
|
+
* 3. Enable this plugin in openclaw.json
|
|
11
|
+
*
|
|
12
|
+
* The plugin will:
|
|
13
|
+
* - Start the aquaman proxy on plugin load
|
|
14
|
+
* - Set ANTHROPIC_BASE_URL, OPENAI_BASE_URL etc. to route through proxy via UDS
|
|
15
|
+
* - The proxy injects credentials into requests
|
|
16
|
+
* - Agent never sees the actual API keys
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { createHttpInterceptor } from "./src/http-interceptor.js";
|
|
22
|
+
import { createProxyManager, findAquamanProxyBinary, execAquamanProxyCli, execAquamanProxyInteractive } from "./src/proxy-manager.js";
|
|
23
|
+
import { loadHostMap, isProxyRunning, getProxyVersion } from "./src/proxy-health.js";
|
|
24
|
+
/**
|
|
25
|
+
* Find an executable in PATH using filesystem checks (no shell execution).
|
|
26
|
+
* Avoids execSync("which ...") which triggers dangerous-exec security audit flags.
|
|
27
|
+
*/
|
|
28
|
+
function findInPath(name) {
|
|
29
|
+
const pathEnv = process.env.PATH || "";
|
|
30
|
+
const dirs = pathEnv.split(path.delimiter);
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
const candidate = path.join(dir, name);
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Not found or not executable in this dir
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Read plugin version from package.json
|
|
44
|
+
const pluginPkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'package.json');
|
|
45
|
+
let PLUGIN_VERSION = 'unknown';
|
|
46
|
+
try {
|
|
47
|
+
PLUGIN_VERSION = JSON.parse(fs.readFileSync(pluginPkgPath, 'utf-8')).version;
|
|
48
|
+
}
|
|
49
|
+
catch { /* ok */ }
|
|
50
|
+
let proxyManager = null;
|
|
51
|
+
let httpInterceptor = null;
|
|
52
|
+
let socketPath = null;
|
|
53
|
+
let dynamicHostMap = null;
|
|
54
|
+
let configuredServices = ["anthropic", "openai"];
|
|
55
|
+
/** Default socket path */
|
|
56
|
+
function getDefaultSocketPath() {
|
|
57
|
+
const configDir = path.join(os.homedir(), '.aquaman');
|
|
58
|
+
return path.join(configDir, 'proxy.sock');
|
|
59
|
+
}
|
|
60
|
+
/** Fallback host map used when proxy doesn't provide one */
|
|
61
|
+
const FALLBACK_HOST_MAP = new Map([
|
|
62
|
+
['api.anthropic.com', 'anthropic'],
|
|
63
|
+
['api.openai.com', 'openai'],
|
|
64
|
+
['api.github.com', 'github'],
|
|
65
|
+
['api.x.ai', 'xai'],
|
|
66
|
+
['gateway.ai.cloudflare.com', 'cloudflare-ai'],
|
|
67
|
+
['api.mistral.ai', 'mistral'],
|
|
68
|
+
['api-inference.huggingface.co', 'huggingface'],
|
|
69
|
+
['slack.com', 'slack'],
|
|
70
|
+
['*.slack.com', 'slack'],
|
|
71
|
+
['discord.com', 'discord'],
|
|
72
|
+
['*.discord.com', 'discord'],
|
|
73
|
+
['api.telegram.org', 'telegram'],
|
|
74
|
+
['matrix.org', 'matrix'],
|
|
75
|
+
['*.matrix.org', 'matrix'],
|
|
76
|
+
['api.line.me', 'line'],
|
|
77
|
+
['api-data.line.me', 'line'],
|
|
78
|
+
['api.twitch.tv', 'twitch'],
|
|
79
|
+
['id.twitch.tv', 'twitch'],
|
|
80
|
+
['api.twilio.com', 'twilio'],
|
|
81
|
+
['*.twilio.com', 'twilio'],
|
|
82
|
+
['api.telnyx.com', 'telnyx'],
|
|
83
|
+
['api.elevenlabs.io', 'elevenlabs'],
|
|
84
|
+
['openapi.zalo.me', 'zalo'],
|
|
85
|
+
['graph.microsoft.com', 'ms-teams'],
|
|
86
|
+
['open.feishu.cn', 'feishu'],
|
|
87
|
+
['open.larksuite.com', 'feishu'],
|
|
88
|
+
['chat.googleapis.com', 'google-chat'],
|
|
89
|
+
]);
|
|
90
|
+
/**
|
|
91
|
+
* Check if aquaman proxy binary is available (local node_modules or PATH)
|
|
92
|
+
*/
|
|
93
|
+
function isAquamanProxyInstalled() {
|
|
94
|
+
return findAquamanProxyBinary() !== null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start the aquaman proxy daemon using ProxyManager
|
|
98
|
+
*/
|
|
99
|
+
async function startProxy(log) {
|
|
100
|
+
try {
|
|
101
|
+
const mgr = createProxyManager({
|
|
102
|
+
config: {},
|
|
103
|
+
onReady: (info) => {
|
|
104
|
+
socketPath = info.socketPath;
|
|
105
|
+
if (info.hostMap && typeof info.hostMap === "object") {
|
|
106
|
+
dynamicHostMap = new Map(Object.entries(info.hostMap));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
onError: (err) => log.error(`Proxy error: ${err.message}`),
|
|
110
|
+
onExit: (code) => {
|
|
111
|
+
proxyManager = null;
|
|
112
|
+
log.warn(`Proxy exited with code ${code}`);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
await mgr.start();
|
|
116
|
+
proxyManager = mgr;
|
|
117
|
+
socketPath = mgr.getSocketPath();
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
log.error(`Failed to start proxy: ${err}`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Stop the proxy daemon and deactivate the HTTP interceptor
|
|
127
|
+
*/
|
|
128
|
+
function stopProxy() {
|
|
129
|
+
if (httpInterceptor) {
|
|
130
|
+
httpInterceptor.deactivate();
|
|
131
|
+
httpInterceptor = null;
|
|
132
|
+
}
|
|
133
|
+
if (proxyManager) {
|
|
134
|
+
proxyManager.stop();
|
|
135
|
+
proxyManager = null;
|
|
136
|
+
}
|
|
137
|
+
socketPath = null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Activate the HTTP interceptor to redirect channel API traffic through the proxy.
|
|
141
|
+
* This is what provides credential isolation for channels that don't support base URL overrides.
|
|
142
|
+
*/
|
|
143
|
+
function activateHttpInterceptor(log) {
|
|
144
|
+
if (!socketPath) {
|
|
145
|
+
log.error("Cannot activate HTTP interceptor: no socket path");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Use dynamic host map from proxy (includes custom services from services.yaml)
|
|
149
|
+
// Falls back to builtin map for backward compatibility.
|
|
150
|
+
const sourceMap = dynamicHostMap || FALLBACK_HOST_MAP;
|
|
151
|
+
// Restrict interception to services the operator opted into. The interceptor
|
|
152
|
+
// only redirects traffic to hosts whose service value appears in the plugin's
|
|
153
|
+
// `services` config. Channels not in `services` keep their normal direct-to-
|
|
154
|
+
// upstream behavior. (Closes ClawScan ASI02.)
|
|
155
|
+
const allowed = new Set(configuredServices);
|
|
156
|
+
const effectiveHostMap = new Map();
|
|
157
|
+
for (const [host, service] of sourceMap) {
|
|
158
|
+
if (allowed.has(service))
|
|
159
|
+
effectiveHostMap.set(host, service);
|
|
160
|
+
}
|
|
161
|
+
httpInterceptor = createHttpInterceptor({
|
|
162
|
+
socketPath,
|
|
163
|
+
hostMap: effectiveHostMap,
|
|
164
|
+
log: (msg) => log.info(msg),
|
|
165
|
+
});
|
|
166
|
+
httpInterceptor.activate();
|
|
167
|
+
const skipped = sourceMap.size - effectiveHostMap.size;
|
|
168
|
+
log.info(`HTTP interceptor active: ${effectiveHostMap.size} host patterns redirected through proxy` +
|
|
169
|
+
(skipped > 0 ? ` (${skipped} known patterns skipped — not in plugin services config)` : ""));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Set environment variables for SDK clients using sentinel hostname
|
|
173
|
+
*/
|
|
174
|
+
function configureEnvironment(log, services) {
|
|
175
|
+
for (const service of services) {
|
|
176
|
+
const serviceUrl = `http://aquaman.local/${service}`;
|
|
177
|
+
switch (service) {
|
|
178
|
+
case "anthropic":
|
|
179
|
+
process.env["ANTHROPIC_BASE_URL"] = serviceUrl;
|
|
180
|
+
log.info(`Set ANTHROPIC_BASE_URL=${serviceUrl}`);
|
|
181
|
+
break;
|
|
182
|
+
case "openai":
|
|
183
|
+
process.env["OPENAI_BASE_URL"] = serviceUrl;
|
|
184
|
+
log.info(`Set OPENAI_BASE_URL=${serviceUrl}`);
|
|
185
|
+
break;
|
|
186
|
+
case "github":
|
|
187
|
+
process.env["GITHUB_API_URL"] = serviceUrl;
|
|
188
|
+
log.info(`Set GITHUB_API_URL=${serviceUrl}`);
|
|
189
|
+
break;
|
|
190
|
+
default:
|
|
191
|
+
const envKey = `${service.toUpperCase().replace(/-/g, "_")}_BASE_URL`;
|
|
192
|
+
process.env[envKey] = serviceUrl;
|
|
193
|
+
log.info(`Set ${envKey}=${serviceUrl}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build status object for both the tool and slash command
|
|
199
|
+
*/
|
|
200
|
+
function getStatus(services) {
|
|
201
|
+
const cliInstalled = isAquamanProxyInstalled();
|
|
202
|
+
return {
|
|
203
|
+
cliInstalled,
|
|
204
|
+
proxyRunning: proxyManager !== null,
|
|
205
|
+
socketPath: socketPath || getDefaultSocketPath(),
|
|
206
|
+
services,
|
|
207
|
+
httpInterceptorActive: httpInterceptor?.isActive() ?? false,
|
|
208
|
+
...(cliInstalled ? {} : { fix: "Run: npm install -g aquaman-proxy && aquaman setup" }),
|
|
209
|
+
...(!cliInstalled ? {} : proxyManager === null ? { fix: "Run: aquaman setup (or: openclaw aquaman setup)" } : {}),
|
|
210
|
+
environmentVariables: Object.fromEntries(services.map((s) => {
|
|
211
|
+
const key = s === "anthropic"
|
|
212
|
+
? "ANTHROPIC_BASE_URL"
|
|
213
|
+
: s === "openai"
|
|
214
|
+
? "OPENAI_BASE_URL"
|
|
215
|
+
: `${s.toUpperCase()}_BASE_URL`;
|
|
216
|
+
return [key, process.env[key] ?? null];
|
|
217
|
+
})),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Register the aquaman_status tool — always registered (works in degraded mode)
|
|
222
|
+
*/
|
|
223
|
+
function registerStatusTool(api, services) {
|
|
224
|
+
api.registerTool(() => {
|
|
225
|
+
return {
|
|
226
|
+
name: "aquaman_status",
|
|
227
|
+
label: "Aquaman Status",
|
|
228
|
+
description: "Check aquaman credential proxy status and configured services",
|
|
229
|
+
parameters: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {},
|
|
232
|
+
required: [],
|
|
233
|
+
},
|
|
234
|
+
async execute(_toolCallId, _params) {
|
|
235
|
+
const status = getStatus(services);
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
|
238
|
+
details: status,
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}, { names: ["aquaman_status"] });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Auto-generate auth-profiles.json with placeholder keys for proxied services.
|
|
246
|
+
* OpenClaw checks its auth store before making API calls — without a placeholder
|
|
247
|
+
* key, requests are rejected before they ever reach the proxy.
|
|
248
|
+
*/
|
|
249
|
+
function ensureAuthProfiles(log, services) {
|
|
250
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR ||
|
|
251
|
+
path.join(os.homedir(), ".openclaw");
|
|
252
|
+
const profilesPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
253
|
+
if (fs.existsSync(profilesPath))
|
|
254
|
+
return;
|
|
255
|
+
const profiles = {};
|
|
256
|
+
const order = {};
|
|
257
|
+
for (const service of services) {
|
|
258
|
+
if (service === "anthropic" || service === "openai") {
|
|
259
|
+
profiles[`${service}:default`] = {
|
|
260
|
+
type: "api_key",
|
|
261
|
+
provider: service,
|
|
262
|
+
key: "aquaman-proxy-managed",
|
|
263
|
+
};
|
|
264
|
+
order[service] = [`${service}:default`];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const dir = path.dirname(profilesPath);
|
|
268
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
269
|
+
fs.writeFileSync(profilesPath, JSON.stringify({ version: 1, profiles, order }, null, 2), { mode: 0o600 });
|
|
270
|
+
log.info(`Generated auth-profiles.json with placeholder keys at ${profilesPath}`);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Aquaman OpenClaw Plugin Definition
|
|
274
|
+
*/
|
|
275
|
+
const plugin = {
|
|
276
|
+
id: 'aquaman-plugin',
|
|
277
|
+
name: 'Aquaman — API Key Protection',
|
|
278
|
+
version: PLUGIN_VERSION,
|
|
279
|
+
description: 'API key protection for OpenClaw — credentials stay in your vault, never in the agent\'s memory',
|
|
280
|
+
register(api) {
|
|
281
|
+
api.logger.info("Aquaman plugin loaded");
|
|
282
|
+
// Read services from plugin config
|
|
283
|
+
const pluginCfg = api.pluginConfig;
|
|
284
|
+
configuredServices = pluginCfg?.services ?? ["anthropic", "openai"];
|
|
285
|
+
// Auto-generate auth-profiles.json if missing. Opt-out via plugin config
|
|
286
|
+
// `autoGenerateAuthProfiles: false` for operators managing their own auth
|
|
287
|
+
// profiles. (Closes ClawScan ASI03.)
|
|
288
|
+
const autoGenerateAuthProfiles = pluginCfg?.autoGenerateAuthProfiles ?? true;
|
|
289
|
+
if (autoGenerateAuthProfiles) {
|
|
290
|
+
ensureAuthProfiles(api.logger, configuredServices);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
api.logger.info("auto-generation of auth-profiles.json disabled by plugin config");
|
|
294
|
+
}
|
|
295
|
+
// Check if aquaman proxy binary is available
|
|
296
|
+
const proxyAvailable = isAquamanProxyInstalled();
|
|
297
|
+
if (!proxyAvailable) {
|
|
298
|
+
api.logger.warn("aquaman proxy not found. Install with: npm install -g aquaman-proxy");
|
|
299
|
+
api.logger.warn("Then run: aquaman setup");
|
|
300
|
+
// DO NOT call configureEnvironment() — sentinel URLs without a proxy
|
|
301
|
+
// would break all API calls (connection refused to non-existent socket)
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
api.logger.info("aquaman proxy found, will start proxy on gateway start");
|
|
305
|
+
// Configure environment variables immediately (sentinel hostname)
|
|
306
|
+
configureEnvironment(api.logger, configuredServices);
|
|
307
|
+
// Register service for proxy lifecycle management
|
|
308
|
+
api.registerService({
|
|
309
|
+
id: 'aquaman-proxy',
|
|
310
|
+
async start(ctx) {
|
|
311
|
+
ctx.logger.info("Starting aquaman proxy...");
|
|
312
|
+
const started = await startProxy(ctx.logger);
|
|
313
|
+
if (started && socketPath) {
|
|
314
|
+
ctx.logger.info("Aquaman proxy started successfully");
|
|
315
|
+
// Check for version mismatch between plugin and proxy
|
|
316
|
+
const proxyVersion = await getProxyVersion(socketPath);
|
|
317
|
+
if (proxyVersion && proxyVersion !== PLUGIN_VERSION) {
|
|
318
|
+
ctx.logger.warn(`Warning: plugin version ${PLUGIN_VERSION} \u2260 proxy version ${proxyVersion}. ` +
|
|
319
|
+
`Update both: npm install -g aquaman-proxy && openclaw plugins install aquaman-plugin`);
|
|
320
|
+
}
|
|
321
|
+
// Activate HTTP interceptor to redirect channel traffic through proxy
|
|
322
|
+
activateHttpInterceptor(ctx.logger);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
ctx.logger.error("Failed to start aquaman proxy");
|
|
326
|
+
// Check if another instance is already running
|
|
327
|
+
const defaultSock = getDefaultSocketPath();
|
|
328
|
+
const alreadyRunning = await isProxyRunning(defaultSock);
|
|
329
|
+
if (alreadyRunning) {
|
|
330
|
+
socketPath = defaultSock;
|
|
331
|
+
ctx.logger.info("Another aquaman instance is already running — using it");
|
|
332
|
+
// Load host map from existing proxy
|
|
333
|
+
const map = await loadHostMap(defaultSock);
|
|
334
|
+
dynamicHostMap = map.size > 0 ? map : FALLBACK_HOST_MAP;
|
|
335
|
+
activateHttpInterceptor(ctx.logger);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
ctx.logger.error("No running proxy found. Check: openclaw aquaman doctor");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
async stop(ctx) {
|
|
343
|
+
ctx.logger.info("Stopping aquaman proxy...");
|
|
344
|
+
stopProxy();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// --- Commands, tools, and CLI are ALWAYS registered (even without proxy) ---
|
|
349
|
+
// This ensures ClawHub users who installed the plugin but haven't run setup
|
|
350
|
+
// still get actionable commands and status information.
|
|
351
|
+
// Register /aquaman-status slash command for humans
|
|
352
|
+
api.registerCommand({
|
|
353
|
+
name: 'aquaman-status',
|
|
354
|
+
description: 'Show aquaman credential proxy status and configured services',
|
|
355
|
+
acceptsArgs: false,
|
|
356
|
+
requireAuth: true,
|
|
357
|
+
async handler() {
|
|
358
|
+
const status = getStatus(configuredServices);
|
|
359
|
+
return { text: JSON.stringify(status, null, 2) };
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
// Register CLI commands if available
|
|
363
|
+
if (api.registerCli) {
|
|
364
|
+
api.registerCli(({ program }) => {
|
|
365
|
+
const aquamanCmd = program
|
|
366
|
+
.command("aquaman")
|
|
367
|
+
.description("Aquaman — API key protection");
|
|
368
|
+
aquamanCmd
|
|
369
|
+
.command("status")
|
|
370
|
+
.description("Show aquaman proxy status")
|
|
371
|
+
.action(() => {
|
|
372
|
+
const status = getStatus(configuredServices);
|
|
373
|
+
console.log("\nAquaman Status:");
|
|
374
|
+
console.log(` Proxy binary: ${status.cliInstalled ? "found" : "NOT FOUND"}`);
|
|
375
|
+
console.log(` Proxy running: ${status.proxyRunning}`);
|
|
376
|
+
console.log(` Socket path: ${status.socketPath}`);
|
|
377
|
+
console.log(` Services: ${configuredServices.join(", ")}`);
|
|
378
|
+
if (status.fix) {
|
|
379
|
+
console.log(`\n Action needed: ${status.fix}`);
|
|
380
|
+
}
|
|
381
|
+
if (status.proxyRunning) {
|
|
382
|
+
console.log("\nEnvironment Variables:");
|
|
383
|
+
for (const service of configuredServices) {
|
|
384
|
+
const envKey = service === "anthropic"
|
|
385
|
+
? "ANTHROPIC_BASE_URL"
|
|
386
|
+
: service === "openai"
|
|
387
|
+
? "OPENAI_BASE_URL"
|
|
388
|
+
: `${service.toUpperCase()}_BASE_URL`;
|
|
389
|
+
console.log(` ${envKey}=${process.env[envKey] ?? "(not set)"}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
aquamanCmd
|
|
394
|
+
.command("setup")
|
|
395
|
+
.description("Run the setup wizard (stores keys, configures backend)")
|
|
396
|
+
.action(async () => {
|
|
397
|
+
try {
|
|
398
|
+
const exitCode = await execAquamanProxyInteractive(['setup']);
|
|
399
|
+
if (exitCode !== 0)
|
|
400
|
+
process.exitCode = exitCode;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
console.log("\n Run in your terminal:\n aquaman setup\n");
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
aquamanCmd
|
|
407
|
+
.command("doctor")
|
|
408
|
+
.description("Diagnose issues with actionable fixes")
|
|
409
|
+
.action(async () => {
|
|
410
|
+
try {
|
|
411
|
+
const result = await execAquamanProxyCli(['doctor']);
|
|
412
|
+
process.stdout.write(result.stdout);
|
|
413
|
+
if (result.stderr)
|
|
414
|
+
process.stderr.write(result.stderr);
|
|
415
|
+
if (result.exitCode !== 0)
|
|
416
|
+
process.exitCode = result.exitCode;
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
console.error(`Failed to run aquaman doctor: ${err.message}`);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
const credsCmd = aquamanCmd
|
|
424
|
+
.command("credentials")
|
|
425
|
+
.description("Credential management");
|
|
426
|
+
credsCmd
|
|
427
|
+
.command("list")
|
|
428
|
+
.description("List stored credentials")
|
|
429
|
+
.action(async () => {
|
|
430
|
+
try {
|
|
431
|
+
const result = await execAquamanProxyCli(['credentials', 'list']);
|
|
432
|
+
process.stdout.write(result.stdout);
|
|
433
|
+
if (result.stderr)
|
|
434
|
+
process.stderr.write(result.stderr);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
console.error(`Failed: ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
credsCmd
|
|
441
|
+
.command("add <service> [key]")
|
|
442
|
+
.description("Add a credential (secure prompt)")
|
|
443
|
+
.action(async (service, key = "api_key") => {
|
|
444
|
+
try {
|
|
445
|
+
const exitCode = await execAquamanProxyInteractive(['credentials', 'add', service, key]);
|
|
446
|
+
if (exitCode !== 0)
|
|
447
|
+
process.exitCode = exitCode;
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
console.log(`\n Run in your terminal:\n aquaman credentials add ${service} ${key}\n`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
aquamanCmd
|
|
454
|
+
.command("policy-list")
|
|
455
|
+
.description("List configured request policy rules")
|
|
456
|
+
.action(async () => {
|
|
457
|
+
try {
|
|
458
|
+
const result = await execAquamanProxyCli(['policy', 'list']);
|
|
459
|
+
process.stdout.write(result.stdout);
|
|
460
|
+
if (result.stderr)
|
|
461
|
+
process.stderr.write(result.stderr);
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
console.error(`Failed: ${err.message}`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
aquamanCmd
|
|
468
|
+
.command("audit-tail")
|
|
469
|
+
.description("Show recent audit log entries")
|
|
470
|
+
.action(async () => {
|
|
471
|
+
try {
|
|
472
|
+
const result = await execAquamanProxyCli(['audit', 'tail']);
|
|
473
|
+
process.stdout.write(result.stdout);
|
|
474
|
+
if (result.stderr)
|
|
475
|
+
process.stderr.write(result.stderr);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
console.error(`Failed: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
aquamanCmd
|
|
482
|
+
.command("services-list")
|
|
483
|
+
.description("List all configured services")
|
|
484
|
+
.action(async () => {
|
|
485
|
+
try {
|
|
486
|
+
const result = await execAquamanProxyCli(['services', 'list']);
|
|
487
|
+
process.stdout.write(result.stdout);
|
|
488
|
+
if (result.stderr)
|
|
489
|
+
process.stderr.write(result.stderr);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
console.error(`Failed: ${err.message}`);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}, { commands: ["aquaman"] });
|
|
496
|
+
}
|
|
497
|
+
registerStatusTool(api, configuredServices);
|
|
498
|
+
api.logger.info("Aquaman plugin registered successfully");
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
export default plugin;
|
|
502
|
+
//# sourceMappingURL=index.js.map
|