@vaultclaw/vaultclaw-mcp-approval-handoff 0.1.2
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 +119 -0
- package/index.ts +127 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +29 -0
- package/scripts/install.sh +31 -0
- package/src/approval-manager.ts +547 -0
- package/src/approval-payload.ts +305 -0
- package/src/backoff.ts +34 -0
- package/src/config.ts +46 -0
- package/src/dedupe.ts +13 -0
- package/src/logging.ts +21 -0
- package/src/messages.ts +76 -0
- package/src/resume-invoker.ts +157 -0
- package/src/types.ts +93 -0
- package/src/wait-invoker.ts +541 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Vaultclaw MCP Approval Handoff Plugin
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin that auto-handles Vaultclaw MCP approval handoff during agent tool runs.
|
|
4
|
+
|
|
5
|
+
When a tool returns `MCP_APPROVAL_REQUIRED`, the plugin:
|
|
6
|
+
|
|
7
|
+
- extracts `error.details.approval.next_action.arguments.handle`
|
|
8
|
+
- notifies the user to approve/deny in Vaultclaw UI
|
|
9
|
+
- asynchronously invokes `vaultclaw_approval_wait`
|
|
10
|
+
- posts a single terminal update for `ALLOW`, `DENY`, or timeout
|
|
11
|
+
- on `ALLOW`, auto-triggers one follow-up `agent` run for the same `sessionKey` so
|
|
12
|
+
compound flows continue without a manual "approved" message
|
|
13
|
+
|
|
14
|
+
No second manual CLI command is required.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
### Option A: npm package
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
openclaw plugins install @vaultclaw/vaultclaw-mcp-approval-handoff
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Option B: one-shot installer script (local checkout)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
./scripts/install.sh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Optional npm/package override:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
./scripts/install.sh @vaultclaw/vaultclaw-mcp-approval-handoff
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Config
|
|
37
|
+
|
|
38
|
+
Plugin id: `vaultclaw-mcp-approval-handoff`
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"plugins": {
|
|
43
|
+
"entries": {
|
|
44
|
+
"vaultclaw-mcp-approval-handoff": {
|
|
45
|
+
"enabled": true,
|
|
46
|
+
"config": {
|
|
47
|
+
"enabled": true,
|
|
48
|
+
"pollIntervalMs": 1500,
|
|
49
|
+
"maxWaitMs": 600000,
|
|
50
|
+
"commandTimeoutMs": 720000,
|
|
51
|
+
"maxConcurrentWaits": 10
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Validation rules:
|
|
60
|
+
|
|
61
|
+
- `pollIntervalMs`: `250..10000`
|
|
62
|
+
- `maxWaitMs`: `1000..3600000`
|
|
63
|
+
- `commandTimeoutMs > maxWaitMs`
|
|
64
|
+
- `maxConcurrentWaits >= 1`
|
|
65
|
+
|
|
66
|
+
## Behavior
|
|
67
|
+
|
|
68
|
+
- Scope: MCP tool results in OpenClaw agent runs.
|
|
69
|
+
- Non-blocking: wait worker runs async from `after_tool_call`.
|
|
70
|
+
- Supported handles: `JOB` and `PLAN_RUN`.
|
|
71
|
+
- Dedupe key: `(session_id, challenge_id, pending_id, run_id/job_id)`.
|
|
72
|
+
- Session lifecycle: pending waits are canceled on `before_reset` and `session_end`.
|
|
73
|
+
- Retries: transient transport failures retry with backoff (`1s, 2s, 4s, 8s, 16s`, max 5 attempts).
|
|
74
|
+
- No-retry terminal categories: validation/auth/timeouts.
|
|
75
|
+
|
|
76
|
+
## Operational Limits
|
|
77
|
+
|
|
78
|
+
- Wait calls are executed through the local Gateway HTTP route: `POST /tools/invoke`.
|
|
79
|
+
- Gateway auth must allow that route (`token`/`password` via config or env, or `none`/`trusted-proxy` mode).
|
|
80
|
+
- Status updates are posted through system events + heartbeat wake and require a valid `sessionKey`.
|
|
81
|
+
- At most `maxConcurrentWaits` approval workers run in parallel.
|
|
82
|
+
|
|
83
|
+
## Observability
|
|
84
|
+
|
|
85
|
+
Structured plugin logs include:
|
|
86
|
+
|
|
87
|
+
- `approval_detected`
|
|
88
|
+
- `wait_started`
|
|
89
|
+
- `wait_completed`
|
|
90
|
+
- `wait_failed`
|
|
91
|
+
- `wait_canceled`
|
|
92
|
+
- `wait_retry`
|
|
93
|
+
- `terminal_outcome`
|
|
94
|
+
- `resume_started`
|
|
95
|
+
- `resume_completed`
|
|
96
|
+
- `resume_failed`
|
|
97
|
+
- `cleanup`
|
|
98
|
+
|
|
99
|
+
Each log includes correlation keys when available:
|
|
100
|
+
|
|
101
|
+
- `session_id`
|
|
102
|
+
- `challenge_id`
|
|
103
|
+
- `pending_id`
|
|
104
|
+
- `run_id` / `job_id`
|
|
105
|
+
|
|
106
|
+
## Example Transcript
|
|
107
|
+
|
|
108
|
+
1. User: "Trash the newsletter in Gmail from yesterday."
|
|
109
|
+
2. Tool result: approval challenge returned (`MCP_APPROVAL_REQUIRED`).
|
|
110
|
+
3. Plugin post: "Approval required in Vaultclaw UI. Waiting up to 10 minutes... (challenge_id=..., pending_id=..., run_id=...)"
|
|
111
|
+
4. User approves in Vaultclaw UI.
|
|
112
|
+
5. Plugin post: "Approval allowed in Vaultclaw UI. Continuing automatically. (...ids...)"
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install
|
|
118
|
+
npm test
|
|
119
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { ApprovalHandoffManager } from "./src/approval-manager.js";
|
|
3
|
+
import { normalizePluginConfig } from "./src/config.js";
|
|
4
|
+
import { createGatewayAgentResumeInvoker } from "./src/resume-invoker.js";
|
|
5
|
+
import { createGatewayToolsInvokeWaitInvoker } from "./src/wait-invoker.js";
|
|
6
|
+
|
|
7
|
+
const plugin = {
|
|
8
|
+
id: "vaultclaw-mcp-approval-handoff",
|
|
9
|
+
name: "Vaultclaw MCP Approval Handoff",
|
|
10
|
+
description: "Automatically waits MCP approval challenges and posts terminal outcomes.",
|
|
11
|
+
configSchema: {
|
|
12
|
+
parse(value: unknown) {
|
|
13
|
+
return normalizePluginConfig(value);
|
|
14
|
+
},
|
|
15
|
+
uiHints: {
|
|
16
|
+
enabled: {
|
|
17
|
+
label: "Enable Auto Approval Wait",
|
|
18
|
+
},
|
|
19
|
+
pollIntervalMs: {
|
|
20
|
+
label: "Poll Interval (ms)",
|
|
21
|
+
},
|
|
22
|
+
maxWaitMs: {
|
|
23
|
+
label: "Max Wait (ms)",
|
|
24
|
+
},
|
|
25
|
+
commandTimeoutMs: {
|
|
26
|
+
label: "Command Timeout (ms)",
|
|
27
|
+
},
|
|
28
|
+
maxConcurrentWaits: {
|
|
29
|
+
label: "Max Concurrent Waits",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
register(api: OpenClawPluginApi) {
|
|
34
|
+
const resolveSessionKey = (ctx: any): string | undefined => {
|
|
35
|
+
const explicit = typeof ctx?.sessionKey === "string" ? ctx.sessionKey.trim() : "";
|
|
36
|
+
if (explicit) {
|
|
37
|
+
return explicit;
|
|
38
|
+
}
|
|
39
|
+
const sessionId = typeof ctx?.sessionId === "string" ? ctx.sessionId.trim() : "";
|
|
40
|
+
if (sessionId && sessionId.startsWith("agent:")) {
|
|
41
|
+
return sessionId;
|
|
42
|
+
}
|
|
43
|
+
const agentId = typeof ctx?.agentId === "string" ? ctx.agentId.trim() : "";
|
|
44
|
+
if (agentId) {
|
|
45
|
+
return `agent:${agentId}:main`;
|
|
46
|
+
}
|
|
47
|
+
return "agent:main:main";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const config = normalizePluginConfig(api.pluginConfig);
|
|
51
|
+
const waitInvoker = createGatewayToolsInvokeWaitInvoker(api.config);
|
|
52
|
+
const resumeInvoker = typeof api.runtime?.system?.runCommandWithTimeout === "function"
|
|
53
|
+
? createGatewayAgentResumeInvoker({
|
|
54
|
+
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
|
|
55
|
+
})
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
const manager = new ApprovalHandoffManager({
|
|
59
|
+
config,
|
|
60
|
+
waitInvoker,
|
|
61
|
+
resumeInvoker,
|
|
62
|
+
logger: api.logger,
|
|
63
|
+
notifier: {
|
|
64
|
+
post: ({ sessionKey, text, reason, contextKey }) => {
|
|
65
|
+
const scopedSessionKey = sessionKey?.trim();
|
|
66
|
+
if (!scopedSessionKey) {
|
|
67
|
+
api.logger.warn(
|
|
68
|
+
`[vaultclaw-approval-handoff] skipped notification with no sessionKey: ${text}`,
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
api.runtime.system.enqueueSystemEvent(text, {
|
|
74
|
+
sessionKey: scopedSessionKey,
|
|
75
|
+
contextKey,
|
|
76
|
+
});
|
|
77
|
+
const requestHeartbeatNow = api.runtime?.system?.requestHeartbeatNow;
|
|
78
|
+
if (typeof requestHeartbeatNow === "function") {
|
|
79
|
+
requestHeartbeatNow({
|
|
80
|
+
reason: `vaultclaw-approval-handoff:${reason}`,
|
|
81
|
+
sessionKey: scopedSessionKey,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
api.logger.warn(
|
|
86
|
+
`[vaultclaw-approval-handoff] failed to enqueue system event: ${String(error)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
api.on("after_tool_call", (event: any, ctx: any) => {
|
|
94
|
+
const sessionKey = resolveSessionKey(ctx);
|
|
95
|
+
manager.onAfterToolCall(
|
|
96
|
+
{
|
|
97
|
+
toolName: event.toolName,
|
|
98
|
+
result: event.result,
|
|
99
|
+
runId: event.runId,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
sessionId: ctx.sessionId ?? sessionKey,
|
|
103
|
+
sessionKey,
|
|
104
|
+
runId: ctx.runId,
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
api.on("before_reset", (event: any, ctx: any) => {
|
|
110
|
+
const sessionKey = resolveSessionKey(ctx);
|
|
111
|
+
manager.onBeforeReset(event, {
|
|
112
|
+
sessionId: ctx.sessionId ?? sessionKey,
|
|
113
|
+
sessionKey,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
api.on("session_end", (event: any, ctx: any) => {
|
|
118
|
+
const sessionKey = resolveSessionKey(ctx);
|
|
119
|
+
manager.onSessionEnd(event, {
|
|
120
|
+
sessionId: ctx.sessionId ?? sessionKey,
|
|
121
|
+
sessionKey,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default plugin;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "vaultclaw-mcp-approval-handoff",
|
|
3
|
+
"name": "Vaultclaw MCP Approval Handoff",
|
|
4
|
+
"description": "Auto-resume Vaultclaw MCP approval-required tool flows.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"enabled": {
|
|
11
|
+
"type": "boolean",
|
|
12
|
+
"default": true
|
|
13
|
+
},
|
|
14
|
+
"pollIntervalMs": {
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"default": 1500,
|
|
17
|
+
"minimum": 250,
|
|
18
|
+
"maximum": 10000
|
|
19
|
+
},
|
|
20
|
+
"maxWaitMs": {
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"default": 600000,
|
|
23
|
+
"minimum": 1000,
|
|
24
|
+
"maximum": 3600000
|
|
25
|
+
},
|
|
26
|
+
"commandTimeoutMs": {
|
|
27
|
+
"type": "integer",
|
|
28
|
+
"default": 720000,
|
|
29
|
+
"minimum": 1001,
|
|
30
|
+
"maximum": 7200000
|
|
31
|
+
},
|
|
32
|
+
"maxConcurrentWaits": {
|
|
33
|
+
"type": "integer",
|
|
34
|
+
"default": 10,
|
|
35
|
+
"minimum": 1,
|
|
36
|
+
"maximum": 100
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"uiHints": {
|
|
41
|
+
"enabled": {
|
|
42
|
+
"label": "Enable Auto Approval Wait"
|
|
43
|
+
},
|
|
44
|
+
"pollIntervalMs": {
|
|
45
|
+
"label": "Poll Interval (ms)"
|
|
46
|
+
},
|
|
47
|
+
"maxWaitMs": {
|
|
48
|
+
"label": "Max Wait (ms)"
|
|
49
|
+
},
|
|
50
|
+
"commandTimeoutMs": {
|
|
51
|
+
"label": "Command Timeout (ms)"
|
|
52
|
+
},
|
|
53
|
+
"maxConcurrentWaits": {
|
|
54
|
+
"label": "Max Concurrent Waits"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vaultclaw/vaultclaw-mcp-approval-handoff",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenClaw plugin that auto-waits Vaultclaw MCP approvals",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.ts",
|
|
8
|
+
"openclaw.plugin.json",
|
|
9
|
+
"src",
|
|
10
|
+
"scripts/install.sh",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"openclaw": "^2026.3.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.9.2",
|
|
22
|
+
"vitest": "^3.2.4"
|
|
23
|
+
},
|
|
24
|
+
"openclaw": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
PLUGIN_ID="vaultclaw-mcp-approval-handoff"
|
|
5
|
+
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
6
|
+
ROOT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
7
|
+
EXT_DIR="${HOME}/.openclaw/extensions/${PLUGIN_ID}"
|
|
8
|
+
|
|
9
|
+
SPEC="${1:-$ROOT_DIR}"
|
|
10
|
+
|
|
11
|
+
echo "Installing plugin from: $SPEC"
|
|
12
|
+
if openclaw plugins info "$PLUGIN_ID" >/dev/null 2>&1; then
|
|
13
|
+
echo "Existing plugin detected, uninstalling old copy..."
|
|
14
|
+
openclaw plugins uninstall "$PLUGIN_ID" --force || true
|
|
15
|
+
fi
|
|
16
|
+
if [ -d "$EXT_DIR" ]; then
|
|
17
|
+
echo "Removing stale extension directory: $EXT_DIR"
|
|
18
|
+
rm -rf "$EXT_DIR"
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
openclaw plugins install "$SPEC"
|
|
22
|
+
openclaw plugins enable "$PLUGIN_ID" || true
|
|
23
|
+
|
|
24
|
+
openclaw config set "plugins.entries.$PLUGIN_ID.config.enabled" true
|
|
25
|
+
openclaw config set "plugins.entries.$PLUGIN_ID.config.pollIntervalMs" 1500
|
|
26
|
+
openclaw config set "plugins.entries.$PLUGIN_ID.config.maxWaitMs" 600000
|
|
27
|
+
openclaw config set "plugins.entries.$PLUGIN_ID.config.commandTimeoutMs" 720000
|
|
28
|
+
openclaw config set "plugins.entries.$PLUGIN_ID.config.maxConcurrentWaits" 10
|
|
29
|
+
|
|
30
|
+
echo "Plugin installed and configured: $PLUGIN_ID"
|
|
31
|
+
echo "Restart the gateway to apply config if it is already running: openclaw gateway restart"
|