@arloraccoon/openclaw-vaultwarden-plugin 0.1.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.
- package/README.md +326 -0
- package/dist/index.mjs +752 -0
- package/dist/secret-resolver.mjs +228 -0
- package/openclaw.plugin.json +89 -0
- package/package.json +57 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/secret-resolver-cli.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
var MAX_ITEM_OUTPUT_BYTES = 1024 * 1024;
|
|
5
|
+
var CLI_TIMEOUT_MS = 1e4;
|
|
6
|
+
function runBitwardenGetItem(itemId, session, options = {}) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const fail = () => reject(new Error("Bitwarden item lookup failed"));
|
|
9
|
+
let child;
|
|
10
|
+
try {
|
|
11
|
+
child = (options.spawnProcess ?? spawn)(
|
|
12
|
+
"bw",
|
|
13
|
+
["get", "item", itemId],
|
|
14
|
+
{
|
|
15
|
+
shell: false,
|
|
16
|
+
env: {
|
|
17
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
18
|
+
HOME: process.env.HOME ?? homedir(),
|
|
19
|
+
...process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {},
|
|
20
|
+
...process.env.BITWARDENCLI_APPDATA_DIR ? { BITWARDENCLI_APPDATA_DIR: process.env.BITWARDENCLI_APPDATA_DIR } : {},
|
|
21
|
+
BW_SESSION: session
|
|
22
|
+
},
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
fail();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let outputBytes = 0;
|
|
32
|
+
let settled = false;
|
|
33
|
+
const finishError = () => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
fail();
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
child.kill("SIGKILL");
|
|
41
|
+
finishError();
|
|
42
|
+
}, options.timeoutMs ?? CLI_TIMEOUT_MS);
|
|
43
|
+
child.stdout?.on("data", (chunk) => {
|
|
44
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
45
|
+
outputBytes += data.length;
|
|
46
|
+
if (outputBytes > (options.maxOutputBytes ?? MAX_ITEM_OUTPUT_BYTES)) {
|
|
47
|
+
child.kill("SIGKILL");
|
|
48
|
+
finishError();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
chunks.push(data);
|
|
52
|
+
});
|
|
53
|
+
child.on("error", finishError);
|
|
54
|
+
child.on("close", (code) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (code !== 0) {
|
|
59
|
+
fail();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const item = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
64
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid item");
|
|
65
|
+
resolve(item);
|
|
66
|
+
} catch {
|
|
67
|
+
fail();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/secret-resolver.ts
|
|
74
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
|
75
|
+
import { homedir as homedir2 } from "node:os";
|
|
76
|
+
import { join } from "node:path";
|
|
77
|
+
var MAX_REQUEST_BYTES = 64 * 1024;
|
|
78
|
+
var MAX_BATCH_SIZE = 20;
|
|
79
|
+
var MAX_SESSION_BYTES = 4096;
|
|
80
|
+
var MAX_VALUE_BYTES = 24 * 1024;
|
|
81
|
+
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
82
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
83
|
+
function errorResponse(ids = ["*"]) {
|
|
84
|
+
return {
|
|
85
|
+
protocolVersion: 1,
|
|
86
|
+
values: {},
|
|
87
|
+
errors: Object.fromEntries(ids.map((id) => [id, "Unable to resolve secret"]))
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function parseSelector(id) {
|
|
91
|
+
const match = /^([^/]+)\/(password|field\/(.+))$/.exec(id);
|
|
92
|
+
if (!match || !UUID.test(match[1])) return void 0;
|
|
93
|
+
if (match[2] === "password") return { itemId: match[1], kind: "password" };
|
|
94
|
+
const name = match[3];
|
|
95
|
+
if (!name || name.length > 256 || /[\u0000-\u001f\u007f/]/.test(name) || name.trim() !== name) return void 0;
|
|
96
|
+
return { itemId: match[1], kind: "field", name };
|
|
97
|
+
}
|
|
98
|
+
function readSession(env, homeDirectory) {
|
|
99
|
+
if (Object.hasOwn(env, "BW_SESSION")) {
|
|
100
|
+
const session = env.BW_SESSION;
|
|
101
|
+
if (!session || session.length > MAX_SESSION_BYTES || /\s/.test(session)) {
|
|
102
|
+
throw new Error("invalid session");
|
|
103
|
+
}
|
|
104
|
+
return session;
|
|
105
|
+
}
|
|
106
|
+
const path = env.VAULTWARDEN_SESSION_FILE || join(homeDirectory, ".openclaw", "secrets", "vaultwarden-session");
|
|
107
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
108
|
+
try {
|
|
109
|
+
const stat = fstatSync(fd);
|
|
110
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_SESSION_BYTES) {
|
|
111
|
+
throw new Error("invalid session file");
|
|
112
|
+
}
|
|
113
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid() || (stat.mode & 63) !== 0) {
|
|
114
|
+
throw new Error("unsafe session file");
|
|
115
|
+
}
|
|
116
|
+
const contents = readFileSync(fd, "utf8");
|
|
117
|
+
const session = contents.endsWith("\n") ? contents.slice(0, -1) : contents;
|
|
118
|
+
if (!session || /\s/.test(session) || Buffer.byteLength(session, "utf8") > MAX_SESSION_BYTES) {
|
|
119
|
+
throw new Error("invalid session file");
|
|
120
|
+
}
|
|
121
|
+
return session;
|
|
122
|
+
} finally {
|
|
123
|
+
closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function selectValue(item, selector) {
|
|
127
|
+
const checked = (value) => {
|
|
128
|
+
if (Buffer.byteLength(value, "utf8") > MAX_VALUE_BYTES) throw new Error("value too large");
|
|
129
|
+
return value;
|
|
130
|
+
};
|
|
131
|
+
if (item.id !== selector.itemId) throw new Error("item mismatch");
|
|
132
|
+
if (selector.kind === "password") {
|
|
133
|
+
if (typeof item.login?.password !== "string" || !item.login.password) throw new Error("password missing");
|
|
134
|
+
return checked(item.login.password);
|
|
135
|
+
}
|
|
136
|
+
const matches = (item.fields ?? []).filter((field2) => field2.name === selector.name);
|
|
137
|
+
if (matches.length !== 1) throw new Error("field missing or ambiguous");
|
|
138
|
+
const [field] = matches;
|
|
139
|
+
if (field?.type !== 0 && field?.type !== 1 && field?.type !== "text" && field?.type !== "hidden" || typeof field.value !== "string" || !field.value) {
|
|
140
|
+
throw new Error("field unsupported");
|
|
141
|
+
}
|
|
142
|
+
return checked(field.value);
|
|
143
|
+
}
|
|
144
|
+
async function resolveSecretRequest(rawInput, options = {}) {
|
|
145
|
+
if (Buffer.byteLength(rawInput, "utf8") > MAX_REQUEST_BYTES) return errorResponse();
|
|
146
|
+
let body;
|
|
147
|
+
try {
|
|
148
|
+
body = JSON.parse(rawInput);
|
|
149
|
+
} catch {
|
|
150
|
+
return errorResponse();
|
|
151
|
+
}
|
|
152
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return errorResponse();
|
|
153
|
+
const request = body;
|
|
154
|
+
if (request.protocolVersion !== 1 || !Array.isArray(request.ids) || request.ids.length < 1 || request.ids.length > MAX_BATCH_SIZE || request.ids.some((id) => typeof id !== "string" || id.length > 512 || !parseSelector(id))) {
|
|
155
|
+
return errorResponse();
|
|
156
|
+
}
|
|
157
|
+
const ids = request.ids;
|
|
158
|
+
const selectors = ids.map((id) => parseSelector(id));
|
|
159
|
+
let session;
|
|
160
|
+
try {
|
|
161
|
+
session = readSession(options.env ?? process.env, options.homeDirectory ?? homedir2());
|
|
162
|
+
} catch {
|
|
163
|
+
return errorResponse(ids);
|
|
164
|
+
}
|
|
165
|
+
const values = {};
|
|
166
|
+
const errors = {};
|
|
167
|
+
const itemCache = /* @__PURE__ */ new Map();
|
|
168
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
169
|
+
const id = ids[index];
|
|
170
|
+
const selector = selectors[index];
|
|
171
|
+
try {
|
|
172
|
+
let itemPromise = itemCache.get(selector.itemId);
|
|
173
|
+
if (!itemPromise) {
|
|
174
|
+
const getItem = options.getItem;
|
|
175
|
+
if (!getItem) throw new Error("resolver unavailable");
|
|
176
|
+
itemPromise = getItem(selector.itemId, session);
|
|
177
|
+
itemCache.set(selector.itemId, itemPromise);
|
|
178
|
+
}
|
|
179
|
+
values[id] = selectValue(await itemPromise, selector);
|
|
180
|
+
} catch {
|
|
181
|
+
errors[id] = "Unable to resolve secret";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const response = { protocolVersion: 1, values, errors };
|
|
185
|
+
if (Buffer.byteLength(JSON.stringify(response), "utf8") > MAX_RESPONSE_BYTES) {
|
|
186
|
+
return errorResponse(ids);
|
|
187
|
+
}
|
|
188
|
+
itemCache.clear();
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/secret-resolver-protocol.ts
|
|
193
|
+
async function runResolverProtocol(rawInput, options = {}) {
|
|
194
|
+
return JSON.stringify(await resolveSecretRequest(rawInput, options));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/secret-resolver-entry.ts
|
|
198
|
+
async function main() {
|
|
199
|
+
let rawInput = "";
|
|
200
|
+
let inputBytes = 0;
|
|
201
|
+
process.stdin.setEncoding("utf8");
|
|
202
|
+
for await (const chunk of process.stdin) {
|
|
203
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
204
|
+
inputBytes += Buffer.byteLength(text, "utf8");
|
|
205
|
+
if (inputBytes > MAX_REQUEST_BYTES) {
|
|
206
|
+
process.stdout.write(JSON.stringify({
|
|
207
|
+
protocolVersion: 1,
|
|
208
|
+
values: {},
|
|
209
|
+
errors: { "*": "Unable to resolve secret" }
|
|
210
|
+
}));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
rawInput += text;
|
|
214
|
+
}
|
|
215
|
+
const result = await runResolverProtocol(rawInput, {
|
|
216
|
+
env: process.env,
|
|
217
|
+
getItem: async (itemId, session) => runBitwardenGetItem(itemId, session)
|
|
218
|
+
});
|
|
219
|
+
process.stdout.write(result);
|
|
220
|
+
}
|
|
221
|
+
void main().catch(() => {
|
|
222
|
+
process.stdout.write(JSON.stringify({
|
|
223
|
+
protocolVersion: 1,
|
|
224
|
+
values: {},
|
|
225
|
+
errors: { "*": "Unable to resolve secret" }
|
|
226
|
+
}));
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "vaultwarden",
|
|
3
|
+
"name": "Vaultwarden",
|
|
4
|
+
"description": "Metadata-safe Bitwarden-compatible Vaultwarden tools for OpenClaw with opt-in item mutations",
|
|
5
|
+
"secretProviderIntegrations": {
|
|
6
|
+
"vaultwarden": {
|
|
7
|
+
"displayName": "Vaultwarden",
|
|
8
|
+
"source": "exec",
|
|
9
|
+
"command": "${node}",
|
|
10
|
+
"args": ["./dist/secret-resolver.mjs"],
|
|
11
|
+
"passEnv": ["BW_SESSION", "VAULTWARDEN_SESSION_FILE", "PATH"],
|
|
12
|
+
"timeoutMs": 15000,
|
|
13
|
+
"maxOutputBytes": 65536,
|
|
14
|
+
"jsonOnly": true
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"activation": {
|
|
18
|
+
"onStartup": false
|
|
19
|
+
},
|
|
20
|
+
"contracts": {
|
|
21
|
+
"tools": [
|
|
22
|
+
"vaultwarden_status",
|
|
23
|
+
"vaultwarden_search",
|
|
24
|
+
"vaultwarden_get_item",
|
|
25
|
+
"vaultwarden_list_folders",
|
|
26
|
+
"vaultwarden_list_collections",
|
|
27
|
+
"vaultwarden_create_item",
|
|
28
|
+
"vaultwarden_update_item",
|
|
29
|
+
"vaultwarden_delete_item"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"configSchema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"adapter": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["cli"]
|
|
39
|
+
},
|
|
40
|
+
"sessionEnv": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1
|
|
43
|
+
},
|
|
44
|
+
"session": {
|
|
45
|
+
"description": "Bitwarden CLI session token; prefer an OpenClaw SecretRef.",
|
|
46
|
+
"oneOf": [
|
|
47
|
+
{
|
|
48
|
+
"type": "string",
|
|
49
|
+
"minLength": 1
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"required": ["source", "provider", "id"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"source": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"enum": ["env", "file", "exec"]
|
|
59
|
+
},
|
|
60
|
+
"provider": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"minLength": 1
|
|
63
|
+
},
|
|
64
|
+
"id": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"minLength": 1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"timeoutSeconds": {
|
|
73
|
+
"type": "number",
|
|
74
|
+
"minimum": 1,
|
|
75
|
+
"maximum": 120
|
|
76
|
+
},
|
|
77
|
+
"maxResults": {
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"minimum": 1,
|
|
80
|
+
"maximum": 100
|
|
81
|
+
},
|
|
82
|
+
"allowMutations": {
|
|
83
|
+
"type": "boolean",
|
|
84
|
+
"default": false,
|
|
85
|
+
"description": "Register login-item create, update, and soft-delete tools. Disabled by default."
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arloraccoon/openclaw-vaultwarden-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Metadata-safe Vaultwarden tools for OpenClaw with opt-in item mutations",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "esbuild index.ts --bundle --platform=node --format=esm --external:openclaw '--external:openclaw/*' --external:typebox --outfile=dist/index.mjs && esbuild src/secret-resolver-entry.ts --bundle --platform=node --format=esm --outfile=dist/secret-resolver.mjs",
|
|
7
|
+
"test": "vitest run src/*.test.ts",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"lint": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/ArloTheRaccoon/openclaw-vaultwarden-plugin"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.mjs",
|
|
18
|
+
"module": "./dist/index.mjs",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist/**",
|
|
21
|
+
"openclaw.plugin.json",
|
|
22
|
+
"README.md",
|
|
23
|
+
"package.json"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"typebox": "1.1.38"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"openclaw": ">=2026.9.5"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^26.6.3",
|
|
33
|
+
"esbuild": "0.28.0",
|
|
34
|
+
"eslint": "^10.11.0",
|
|
35
|
+
"playwright": "^1.63.0",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"typescript-eslint": "^8.70.1",
|
|
38
|
+
"vitest": "4.1.6"
|
|
39
|
+
},
|
|
40
|
+
"openclaw": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./dist/index.mjs"
|
|
43
|
+
],
|
|
44
|
+
"install": {
|
|
45
|
+
"npmSpec": "@arloraccoon/openclaw-vaultwarden-plugin",
|
|
46
|
+
"defaultChoice": "npm",
|
|
47
|
+
"minHostVersion": ">=2026.9.5"
|
|
48
|
+
},
|
|
49
|
+
"compat": {
|
|
50
|
+
"pluginApi": ">=2026.5.19"
|
|
51
|
+
},
|
|
52
|
+
"release": {
|
|
53
|
+
"publishToNpm": true,
|
|
54
|
+
"publishToClawHub": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|