@seekrit/openclaw-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 +80 -0
- package/openclaw.plugin.json +49 -0
- package/package.json +39 -0
- package/seekrit-secret-ref-resolver.js +183 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @seekrit/openclaw-plugin
|
|
2
|
+
|
|
3
|
+
seekrit as an [OpenClaw](https://docs.openclaw.ai) **SecretRef provider**. Your
|
|
4
|
+
gateway config names credentials instead of holding them; the values are
|
|
5
|
+
decrypted on your machine, by your credential, when OpenClaw starts.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install npm:@seekrit/openclaw-plugin
|
|
9
|
+
openclaw plugins enable seekrit
|
|
10
|
+
seekrit openclaw init --write
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then any credential OpenClaw accepts a SecretRef for becomes a reference:
|
|
14
|
+
|
|
15
|
+
```json5
|
|
16
|
+
{ source: "exec", provider: "seekrit", id: "OPENAI_API_KEY" }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Full guide: **<https://seekrit.dev/docs/guides/ai-agents/openclaw>**
|
|
20
|
+
|
|
21
|
+
## What this package is
|
|
22
|
+
|
|
23
|
+
A manifest and a resolver — no runtime code, no tools, no channels, and no
|
|
24
|
+
capability prompts at install time. OpenClaw reads
|
|
25
|
+
`secretProviderIntegrations` out of `openclaw.plugin.json` without executing
|
|
26
|
+
anything, and spawns `seekrit-secret-ref-resolver.js` when it needs values.
|
|
27
|
+
|
|
28
|
+
The resolver is a stdio bridge onto `seekrit openclaw resolve`, so the protocol
|
|
29
|
+
has exactly one implementation. Decryption happens in a short-lived child
|
|
30
|
+
process that exits when the batch is answered: the gateway never holds a token,
|
|
31
|
+
a data key, or a plaintext, and the seekrit API never sees one either.
|
|
32
|
+
|
|
33
|
+
## Ids
|
|
34
|
+
|
|
35
|
+
Two shapes, mirroring `op://vault/item/field`:
|
|
36
|
+
|
|
37
|
+
| id | resolves against |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `OPENAI_API_KEY` | the environment your credential already points at |
|
|
40
|
+
| `billing-api/production/STRIPE_SECRET_KEY` | that application and environment |
|
|
41
|
+
|
|
42
|
+
A bare name is the common case, and the only one a service token needs — the
|
|
43
|
+
token is already bound to an environment.
|
|
44
|
+
|
|
45
|
+
## Credentials
|
|
46
|
+
|
|
47
|
+
The resolver runs with the environment allowlist the manifest declares, and
|
|
48
|
+
finds its credential there. Any of these work:
|
|
49
|
+
|
|
50
|
+
- `SEEKRIT_TOKEN` — a service token (`skt_`), scoped to one environment.
|
|
51
|
+
- `SEEKRIT_CLIENT_ID` + `SEEKRIT_CLIENT_SECRET` — machine credentials, from
|
|
52
|
+
which the CLI mints and caches an admin token.
|
|
53
|
+
- a `seekrit login` session in `$HOME`, for a workstation gateway.
|
|
54
|
+
|
|
55
|
+
`SEEKRIT_CLI` overrides which CLI the resolver runs, and must be an absolute
|
|
56
|
+
path.
|
|
57
|
+
|
|
58
|
+
## Two things that will surprise you
|
|
59
|
+
|
|
60
|
+
**Resolution is eager.** OpenClaw reads every active ref once at startup and
|
|
61
|
+
config reload, into one in-memory snapshot — it does not re-resolve per request.
|
|
62
|
+
After you rotate a secret, run `openclaw secrets reload`, or the gateway keeps
|
|
63
|
+
serving the old value until it restarts.
|
|
64
|
+
|
|
65
|
+
**A `--link`ed install will not work.** OpenClaw only honours
|
|
66
|
+
`secretProviderIntegrations` from plugins whose origin is `bundled` or `global`,
|
|
67
|
+
so a locally linked development copy is installed but inert. Install from npm.
|
|
68
|
+
|
|
69
|
+
## Not a process boundary
|
|
70
|
+
|
|
71
|
+
A SecretRef keeps plaintext out of `openclaw.json`, `models.json`, and the
|
|
72
|
+
gateway's SQLite. It does not stop the agent from reading a resolved value —
|
|
73
|
+
OpenClaw says as much about its own egress sentinels. If the point is that the
|
|
74
|
+
agent must *never* hold the key, put it behind the
|
|
75
|
+
[egress proxy](https://seekrit.dev/docs/guides/agent-proxy) and give the agent a
|
|
76
|
+
`{{seekrit:NAME}}` placeholder instead.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "seekrit",
|
|
3
|
+
"name": "seekrit",
|
|
4
|
+
"description": "Resolve OpenClaw exec SecretRefs from seekrit — end-to-end encrypted secrets, decrypted locally so the gateway config holds no plaintext and the seekrit API never sees one.",
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": false
|
|
7
|
+
},
|
|
8
|
+
"secretProviderIntegrations": {
|
|
9
|
+
"seekrit": {
|
|
10
|
+
"providerAlias": "seekrit",
|
|
11
|
+
"displayName": "seekrit",
|
|
12
|
+
"description": "Resolve OpenClaw exec SecretRefs from a seekrit environment using the seekrit CLI.",
|
|
13
|
+
"source": "exec",
|
|
14
|
+
"command": "${node}",
|
|
15
|
+
"args": ["./seekrit-secret-ref-resolver.js"],
|
|
16
|
+
"timeoutMs": 30000,
|
|
17
|
+
"noOutputTimeoutMs": 30000,
|
|
18
|
+
"maxOutputBytes": 1048576,
|
|
19
|
+
"passEnv": [
|
|
20
|
+
"PATH",
|
|
21
|
+
"HOME",
|
|
22
|
+
"USERPROFILE",
|
|
23
|
+
"APPDATA",
|
|
24
|
+
"LOCALAPPDATA",
|
|
25
|
+
"TEMP",
|
|
26
|
+
"TMP",
|
|
27
|
+
"SYSTEMROOT",
|
|
28
|
+
"WINDIR",
|
|
29
|
+
"XDG_CONFIG_HOME",
|
|
30
|
+
"XDG_CACHE_HOME",
|
|
31
|
+
"NODE_EXTRA_CA_CERTS",
|
|
32
|
+
"SEEKRIT_CLI",
|
|
33
|
+
"SEEKRIT_TOKEN",
|
|
34
|
+
"SEEKRIT_CLIENT_ID",
|
|
35
|
+
"SEEKRIT_CLIENT_SECRET",
|
|
36
|
+
"SEEKRIT_API_URL",
|
|
37
|
+
"SEEKRIT_ORG",
|
|
38
|
+
"SEEKRIT_APP",
|
|
39
|
+
"SEEKRIT_ENV",
|
|
40
|
+
"SEEKRIT_BRANCH"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"configSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"properties": {}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seekrit/openclaw-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "seekrit for OpenClaw — resolve exec SecretRefs from end-to-end encrypted secrets, so the gateway config holds no plaintext and the seekrit API never sees one.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"seekrit-secret-ref-resolver.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"openclaw",
|
|
19
|
+
"openclaw-plugin",
|
|
20
|
+
"secretref",
|
|
21
|
+
"secrets",
|
|
22
|
+
"api-keys",
|
|
23
|
+
"encryption",
|
|
24
|
+
"zero-knowledge"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://seekrit.dev/docs/guides/ai-agents/openclaw",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@seekrit/cli": "workspace:^"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^26.1.0",
|
|
37
|
+
"vitest": "^4.1.9"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenClaw exec SecretRef resolver for seekrit.
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw runs this file — `${node} ./seekrit-secret-ref-resolver.js`, declared
|
|
6
|
+
* in `openclaw.plugin.json` — with the request on stdin and a narrow environment
|
|
7
|
+
* allowlist, and reads the response from stdout. All this file does is hand that
|
|
8
|
+
* request to `seekrit openclaw resolve` and pass the answer back.
|
|
9
|
+
*
|
|
10
|
+
* It is a bridge on purpose, for three reasons.
|
|
11
|
+
*
|
|
12
|
+
* **One implementation of the protocol.** `seekrit openclaw resolve` is also the
|
|
13
|
+
* command a plain CLI install points an exec provider straight at. If the
|
|
14
|
+
* protocol lived here as well, a fix would land in one of the two shapes only.
|
|
15
|
+
*
|
|
16
|
+
* **The gateway process never holds key material.** The decryption happens in a
|
|
17
|
+
* child that exits when the batch is answered — the same reason
|
|
18
|
+
* `@seekrit/paperclip-plugin` shells out rather than importing the crypto into a
|
|
19
|
+
* long-lived worker.
|
|
20
|
+
*
|
|
21
|
+
* **Zero dependencies, and a real path on disk.** OpenClaw resolves the
|
|
22
|
+
* entrypoint OpenClaw declared, then refuses it if it is a symlink, a hardlink,
|
|
23
|
+
* or inside a world-writable directory. A plain committed `.js` file at the
|
|
24
|
+
* plugin root satisfies that; a bundler output behind a symlinked `dist` does
|
|
25
|
+
* not.
|
|
26
|
+
*
|
|
27
|
+
* Failure is fail-closed and quiet. A resolver that cannot reach seekrit answers
|
|
28
|
+
* with a per-id error code and exits 0, so OpenClaw refuses to start with a
|
|
29
|
+
* clear "unresolved ref" rather than a resolver crash — and never with a message,
|
|
30
|
+
* because resolver output may contain credentials and OpenClaw declines to
|
|
31
|
+
* display it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawn } from "node:child_process";
|
|
35
|
+
import { createRequire } from "node:module";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
|
|
38
|
+
const PROTOCOL_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
/** Under the manifest's 30s, so a hung child still produces a clean response. */
|
|
41
|
+
const CHILD_TIMEOUT_MS = 25_000;
|
|
42
|
+
|
|
43
|
+
function readStdin() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let input = "";
|
|
46
|
+
process.stdin.setEncoding("utf8");
|
|
47
|
+
process.stdin.on("data", (chunk) => {
|
|
48
|
+
input += String(chunk);
|
|
49
|
+
});
|
|
50
|
+
process.stdin.on("error", reject);
|
|
51
|
+
process.stdin.on("end", () => resolve(input));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeResponse(response) {
|
|
56
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The ids we owe an answer for, so a failure can be reported against each. */
|
|
60
|
+
function requestedIds(input) {
|
|
61
|
+
const parsed = JSON.parse(input);
|
|
62
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) {
|
|
63
|
+
throw new Error("invalid exec SecretRef request");
|
|
64
|
+
}
|
|
65
|
+
// `trim()`, not just `length`, so this list matches the one
|
|
66
|
+
// `seekrit openclaw resolve` builds — a blank id must not become an error key
|
|
67
|
+
// here that the CLI never reports.
|
|
68
|
+
return parsed.ids.filter((id) => typeof id === "string" && id.trim().length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorResponse(ids, code) {
|
|
72
|
+
const errors = {};
|
|
73
|
+
for (const id of ids) errors[id] = { code };
|
|
74
|
+
return { protocolVersion: PROTOCOL_VERSION, values: {}, errors };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Locate the seekrit CLI's entry file.
|
|
79
|
+
*
|
|
80
|
+
* `SEEKRIT_CLI` first, for an install this package cannot see — but only as an
|
|
81
|
+
* absolute path: a bare name would be looked up on `PATH`, and `PATH` is
|
|
82
|
+
* attacker-influenced in a way an explicit absolute path is not.
|
|
83
|
+
*
|
|
84
|
+
* Otherwise resolve `@seekrit/cli` out of this package's own dependency tree.
|
|
85
|
+
* That is deliberately not a `PATH` lookup either: the `seekrit` on `PATH` is a
|
|
86
|
+
* package-manager shim that symlinks into `node_modules`, and its version is
|
|
87
|
+
* whatever the machine happens to have rather than the one this plugin was
|
|
88
|
+
* published against.
|
|
89
|
+
*/
|
|
90
|
+
function resolveCliEntry() {
|
|
91
|
+
const override = process.env.SEEKRIT_CLI?.trim();
|
|
92
|
+
if (override) {
|
|
93
|
+
if (!path.isAbsolute(override)) {
|
|
94
|
+
throw new Error("SEEKRIT_CLI must be an absolute path");
|
|
95
|
+
}
|
|
96
|
+
return override;
|
|
97
|
+
}
|
|
98
|
+
const require = createRequire(import.meta.url);
|
|
99
|
+
return require.resolve("@seekrit/cli/cli-entry");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run the CLI on the request, resolving to its stdout. */
|
|
103
|
+
function runCli(entry, input) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const child = spawn(process.execPath, [entry, "openclaw", "resolve"], {
|
|
106
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
107
|
+
env: process.env,
|
|
108
|
+
});
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let settled = false;
|
|
111
|
+
const finish = (fn, arg) => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
settled = true;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
fn(arg);
|
|
116
|
+
};
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
child.kill("SIGKILL");
|
|
119
|
+
finish(reject, new Error("seekrit CLI timed out"));
|
|
120
|
+
}, CHILD_TIMEOUT_MS);
|
|
121
|
+
|
|
122
|
+
child.stdout.setEncoding("utf8");
|
|
123
|
+
child.stdout.on("data", (chunk) => {
|
|
124
|
+
stdout += String(chunk);
|
|
125
|
+
});
|
|
126
|
+
// Drained and dropped. The CLI writes diagnostics here, and a full pipe
|
|
127
|
+
// would deadlock a child that is otherwise about to answer — but nothing
|
|
128
|
+
// from it may reach our own stderr, which OpenClaw captures.
|
|
129
|
+
child.stderr.resume();
|
|
130
|
+
child.on("error", (err) => finish(reject, err));
|
|
131
|
+
child.on("close", (code) => {
|
|
132
|
+
if (code === 0) finish(resolve, stdout);
|
|
133
|
+
else finish(reject, new Error(`seekrit CLI exited ${code}`));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
child.stdin.on("error", (err) => finish(reject, err));
|
|
137
|
+
child.stdin.end(input);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Accept only a well-formed protocol response, so junk cannot look like values. */
|
|
142
|
+
function parseCliResponse(stdout) {
|
|
143
|
+
const parsed = JSON.parse(stdout);
|
|
144
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.values !== "object") {
|
|
145
|
+
throw new Error("seekrit CLI returned an unexpected shape");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
149
|
+
values: parsed.values ?? {},
|
|
150
|
+
...(parsed.errors ? { errors: parsed.errors } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function main() {
|
|
155
|
+
const input = await readStdin();
|
|
156
|
+
|
|
157
|
+
let ids;
|
|
158
|
+
try {
|
|
159
|
+
ids = requestedIds(input);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Nothing to answer: no ids to attach an error to. Exit non-zero so OpenClaw
|
|
162
|
+
// reports a resolver fault rather than a silently empty batch.
|
|
163
|
+
process.stderr.write(`seekrit: ${err.message}\n`);
|
|
164
|
+
process.exitCode = 2;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let entry;
|
|
169
|
+
try {
|
|
170
|
+
entry = resolveCliEntry();
|
|
171
|
+
} catch {
|
|
172
|
+
writeResponse(errorResponse(ids, "UNAVAILABLE"));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
writeResponse(parseCliResponse(await runCli(entry, input)));
|
|
178
|
+
} catch {
|
|
179
|
+
writeResponse(errorResponse(ids, "UNAVAILABLE"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await main();
|