openclaw-brokerkit 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 +197 -0
- package/dist/index.js +113 -0
- package/dist/src/client.js +152 -0
- package/dist/src/commands.js +101 -0
- package/dist/src/config.js +108 -0
- package/dist/src/errors.js +15 -0
- package/dist/src/generated/operator-v1.js +57 -0
- package/dist/src/http.js +279 -0
- package/dist/src/operator-v1.js +92 -0
- package/dist/src/runtime.js +312 -0
- package/dist/src/store.js +235 -0
- package/dist/src/types.js +1 -0
- package/dist/ui/assets/index-8w0_bcoC.js +143 -0
- package/dist/ui/assets/index-D8Ypbs3j.css +1 -0
- package/dist/ui/index.html +13 -0
- package/openclaw.plugin.json +98 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# OpenClaw BrokerKit
|
|
2
|
+
|
|
3
|
+
`openclaw-brokerkit` adds a provider-neutral Approvals tab and `/brokerkit`
|
|
4
|
+
commands to OpenClaw. It connects to one or more BrokerKit Operator V1 sources.
|
|
5
|
+
The brokers remain authoritative and retain all provider credentials and
|
|
6
|
+
executable plans.
|
|
7
|
+
|
|
8
|
+
The minimum supported host is `openclaw@2026.7.1-beta.5`, the first published
|
|
9
|
+
SDK used by this package that includes public tab descriptors.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
After the package is published, install an exact release from
|
|
14
|
+
[npm](https://www.npmjs.com/package/openclaw-brokerkit) through OpenClaw:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
openclaw plugins install npm:openclaw-brokerkit@<version>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For a local monorepo checkout, build and link the package explicitly:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm --filter openclaw-brokerkit build
|
|
24
|
+
openclaw plugins install --link ./plugins/openclaw
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Choose one explicit trust mode:
|
|
28
|
+
|
|
29
|
+
- `direct` trusts the OpenClaw process with operator SecretRefs and enables the
|
|
30
|
+
tab, background reconciliation, `/brokerkit` commands, and channel delivery.
|
|
31
|
+
- `delegated-web` packages only the tab UI and delegates authenticated browser
|
|
32
|
+
requests to a same-origin trusted backend. It gives OpenClaw no broker
|
|
33
|
+
credential and registers no command or background source service.
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
```json5
|
|
38
|
+
{
|
|
39
|
+
plugins: {
|
|
40
|
+
entries: {
|
|
41
|
+
brokerkit: {
|
|
42
|
+
enabled: true,
|
|
43
|
+
config: {
|
|
44
|
+
mode: "direct",
|
|
45
|
+
brokers: [
|
|
46
|
+
{
|
|
47
|
+
id: "hf-primary",
|
|
48
|
+
label: "Hugging Face",
|
|
49
|
+
endpoint: "http://127.0.0.1:18080",
|
|
50
|
+
operatorCredential: {
|
|
51
|
+
source: "env",
|
|
52
|
+
provider: "default",
|
|
53
|
+
id: "HF_BROKER_OPERATOR_SECRET",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Literal operator credentials are rejected. Non-loopback sources require HTTPS.
|
|
65
|
+
Each `endpoint` is the broker's Operator V1 listener, not its agent listener.
|
|
66
|
+
The plugin stores only cursors, opaque handles, generic channel destinations,
|
|
67
|
+
and delivery bookkeeping under the OpenClaw service state directory.
|
|
68
|
+
|
|
69
|
+
For a deployment that treats OpenClaw as untrusted, configure delegated web:
|
|
70
|
+
|
|
71
|
+
```json5
|
|
72
|
+
{
|
|
73
|
+
plugins: {
|
|
74
|
+
entries: {
|
|
75
|
+
brokerkit: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
config: {
|
|
78
|
+
mode: "delegated-web",
|
|
79
|
+
delegatedWeb: {
|
|
80
|
+
basePath: "/trusted-host/api/brokerkit",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The trusted backend implements `brokerkit.io/delegated-web/v1`, authenticates
|
|
90
|
+
the human operator, and issues an in-memory decision token lasting no more than
|
|
91
|
+
five minutes. The base path must be a normalized same-origin absolute path.
|
|
92
|
+
The OpenClaw gateway must set `gateway.controlUi.embedSandbox` to `scripts` so
|
|
93
|
+
the approval tab can run while retaining its opaque sandbox origin. Do not use
|
|
94
|
+
`trusted`; delegated authentication is designed for the stricter scripts-only
|
|
95
|
+
sandbox.
|
|
96
|
+
|
|
97
|
+
When OpenClaw is outside the credential trust boundary, the trusted backend
|
|
98
|
+
must serve the packaged `dist/ui` files at the registered UI path itself. A
|
|
99
|
+
framed popover may receive a server-enforced `read` or `decide` session. A
|
|
100
|
+
`read` session shows requests without action controls. A `decide` session lets
|
|
101
|
+
the operator act directly in the frame. Hosts that use framed decisions accept
|
|
102
|
+
that the surrounding application controls the iframe's placement and can
|
|
103
|
+
attempt UI redressing; use the navigation-only launcher below when that threat
|
|
104
|
+
must remain out of scope.
|
|
105
|
+
|
|
106
|
+
The host injects the short-lived delegated session into the sandboxed response:
|
|
107
|
+
|
|
108
|
+
```html
|
|
109
|
+
<meta name="brokerkit-delegated-session" content="BASE64URL_SESSION_JSON" />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
With `access: "read"`, each actionable request renders a **Review securely**
|
|
113
|
+
button instead of decision controls. The button posts this navigation-only
|
|
114
|
+
message:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
{ type: "brokerkit.delegated-web.open", version: 1, nonce }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The host verifies the exact opaque-origin frame source and navigates the whole
|
|
121
|
+
browser tab to the trusted UI URL. The top-level response is unframeable and
|
|
122
|
+
contains a server-enforced decision session, so approval, denial, cancellation,
|
|
123
|
+
and revocation require a second explicit action in the trusted document.
|
|
124
|
+
|
|
125
|
+
A host that does not provide a framed inbox can inject this marker so the UI
|
|
126
|
+
renders only the secure-navigation launcher:
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<meta name="brokerkit-delegated-top-level" />
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The content is a base64url-encoded `brokerkit.io/delegated-web/v1` session
|
|
133
|
+
object. The UI removes the element as soon as it reads it. Version 1 uses this
|
|
134
|
+
closed payload:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"api_version": "brokerkit.io/delegated-web/v1",
|
|
139
|
+
"token": "opaque-short-lived-bearer-token",
|
|
140
|
+
"expires_at": "2026-07-12T13:30:00Z",
|
|
141
|
+
"access": "read",
|
|
142
|
+
"renewal_transport": "direct"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`access` is `read` or `decide`; the host enforces it on every endpoint.
|
|
147
|
+
`renewal_transport` is `direct` or `parent`. Direct renewal posts to
|
|
148
|
+
`<basePath>/session` with the current token as a bearer credential and omits
|
|
149
|
+
cookies. Parent renewal uses the nonce-bound bridge below. The host preserves
|
|
150
|
+
the token's access when it returns a replacement session.
|
|
151
|
+
|
|
152
|
+
When the parent application is trusted, a sandboxed tab that cannot call the
|
|
153
|
+
delegated session endpoint directly may instead use this host-neutral bridge:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
request: { type: "brokerkit.delegated-web.session.request", version: 1, nonce }
|
|
157
|
+
response: { type: "brokerkit.delegated-web.session.response", nonce, session }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The trusted parent must answer only requests from the embedded BrokerKit frame
|
|
161
|
+
and must bind each response to the supplied 128-bit nonce. `session` is the
|
|
162
|
+
same `brokerkit.io/delegated-web/v1` object returned by `POST
|
|
163
|
+
<basePath>/session`. The bridge is a BrokerKit interface; it contains no
|
|
164
|
+
host-product namespace or host-specific payload.
|
|
165
|
+
|
|
166
|
+
## Commands
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
/brokerkit subscribe
|
|
170
|
+
/brokerkit unsubscribe
|
|
171
|
+
/brokerkit pending
|
|
172
|
+
/brokerkit show <handle>
|
|
173
|
+
/brokerkit approve <handle>
|
|
174
|
+
/brokerkit deny <handle>
|
|
175
|
+
/brokerkit cancel <handle>
|
|
176
|
+
/brokerkit revoke <handle>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Commands are registered only in direct mode. They require an authorized sender
|
|
180
|
+
and the `operator.approvals` scope.
|
|
181
|
+
Subscriptions use OpenClaw's generic outbound adapter, so this package contains
|
|
182
|
+
no Telegram, Discord, Slack, or other channel-specific implementation.
|
|
183
|
+
|
|
184
|
+
## Verify
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
pnpm --filter openclaw-brokerkit check
|
|
188
|
+
pnpm --filter openclaw-brokerkit test
|
|
189
|
+
pnpm --filter openclaw-brokerkit test:browser
|
|
190
|
+
pnpm --filter openclaw-brokerkit build
|
|
191
|
+
pnpm --filter openclaw-brokerkit test:package
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The browser suite runs direct and delegated hosting in Chromium, Firefox, and
|
|
195
|
+
WebKit at desktop and mobile sizes. The package test installs the tarball in a
|
|
196
|
+
fresh directory beside the exact minimum OpenClaw release and imports the
|
|
197
|
+
public plugin entry.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { definePluginEntry, } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
|
|
4
|
+
import { handleCommand } from "./src/commands.js";
|
|
5
|
+
import { parseConfig } from "./src/config.js";
|
|
6
|
+
import { createHttpHandler } from "./src/http.js";
|
|
7
|
+
import { BrokerRuntime } from "./src/runtime.js";
|
|
8
|
+
const configSchema = { parse: parseConfig };
|
|
9
|
+
export function registerBrokerKit(api) {
|
|
10
|
+
const config = parseConfig(api.pluginConfig);
|
|
11
|
+
const capability = config.mode === "direct"
|
|
12
|
+
? randomBytes(32).toString("base64url")
|
|
13
|
+
: undefined;
|
|
14
|
+
const bootstrap = config.mode === "direct"
|
|
15
|
+
? {
|
|
16
|
+
version: 1,
|
|
17
|
+
mode: "direct",
|
|
18
|
+
capability: requiredCapability(capability),
|
|
19
|
+
}
|
|
20
|
+
: {
|
|
21
|
+
version: 1,
|
|
22
|
+
mode: "delegated-web",
|
|
23
|
+
basePath: config.delegatedWeb.basePath,
|
|
24
|
+
};
|
|
25
|
+
let runtime;
|
|
26
|
+
const requireRuntime = () => {
|
|
27
|
+
if (!runtime)
|
|
28
|
+
throw new Error("BrokerKit runtime is not running");
|
|
29
|
+
return runtime;
|
|
30
|
+
};
|
|
31
|
+
api.session.controls.registerControlUiDescriptor({
|
|
32
|
+
surface: "tab",
|
|
33
|
+
id: "brokerkit",
|
|
34
|
+
label: "Approvals",
|
|
35
|
+
description: "Review pending BrokerKit requests.",
|
|
36
|
+
icon: "shield-check",
|
|
37
|
+
group: "control",
|
|
38
|
+
requiredScopes: ["operator.approvals"],
|
|
39
|
+
path: `/plugins/brokerkit/ui/#${encodeUiBootstrap(bootstrap)}`,
|
|
40
|
+
});
|
|
41
|
+
if (config.mode === "direct") {
|
|
42
|
+
api.registerService({
|
|
43
|
+
id: "brokerkit",
|
|
44
|
+
start: async (ctx) => {
|
|
45
|
+
runtime = new BrokerRuntime(config, {
|
|
46
|
+
resolveCredential: (source) => resolveCredential(api, source),
|
|
47
|
+
deliver: async (subscription, text) => {
|
|
48
|
+
const adapter = await api.runtime.channel.outbound.loadAdapter(subscription.channel);
|
|
49
|
+
if (!adapter?.sendText)
|
|
50
|
+
throw new Error("channel does not support text delivery");
|
|
51
|
+
await adapter.sendText({
|
|
52
|
+
cfg: api.config,
|
|
53
|
+
to: subscription.target,
|
|
54
|
+
text,
|
|
55
|
+
...(subscription.accountId
|
|
56
|
+
? { accountId: subscription.accountId }
|
|
57
|
+
: {}),
|
|
58
|
+
...(subscription.threadId
|
|
59
|
+
? { threadId: subscription.threadId }
|
|
60
|
+
: {}),
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
log: (level, message) => api.logger[level]?.(message),
|
|
64
|
+
});
|
|
65
|
+
await runtime.start(ctx.stateDir);
|
|
66
|
+
},
|
|
67
|
+
stop: async () => {
|
|
68
|
+
await runtime?.stop();
|
|
69
|
+
runtime = undefined;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
api.registerCommand({
|
|
73
|
+
name: "brokerkit",
|
|
74
|
+
description: "Review and decide BrokerKit approval requests.",
|
|
75
|
+
acceptsArgs: true,
|
|
76
|
+
requireAuth: true,
|
|
77
|
+
requiredScopes: ["operator.approvals"],
|
|
78
|
+
handler: (ctx) => handleCommand(requireRuntime(), ctx),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
api.registerHttpRoute({
|
|
82
|
+
path: "/plugins/brokerkit",
|
|
83
|
+
auth: "plugin",
|
|
84
|
+
match: "prefix",
|
|
85
|
+
handler: createHttpHandler(config.mode === "direct" ? requireRuntime : undefined, api.rootDir ?? process.cwd(), capability),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function encodeUiBootstrap(value) {
|
|
89
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
90
|
+
}
|
|
91
|
+
function requiredCapability(value) {
|
|
92
|
+
if (!value)
|
|
93
|
+
throw new Error("BrokerKit UI capability was not initialized");
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
async function resolveCredential(api, source) {
|
|
97
|
+
const resolved = await resolveConfiguredSecretInputString({
|
|
98
|
+
config: api.config,
|
|
99
|
+
env: process.env,
|
|
100
|
+
value: source.operatorCredential,
|
|
101
|
+
path: `plugins.entries.brokerkit.config.brokers.${source.id}.operatorCredential`,
|
|
102
|
+
});
|
|
103
|
+
if (!resolved.value)
|
|
104
|
+
throw new Error(`operator credential unavailable for ${source.id}`);
|
|
105
|
+
return resolved.value;
|
|
106
|
+
}
|
|
107
|
+
export default definePluginEntry({
|
|
108
|
+
id: "brokerkit",
|
|
109
|
+
name: "BrokerKit",
|
|
110
|
+
description: "Provider-neutral BrokerKit approvals",
|
|
111
|
+
configSchema,
|
|
112
|
+
register: registerBrokerKit,
|
|
113
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { parseBrokerEvent, parseDescriptor, parseErrorEnvelope, parseHealth, parseRequest, parseRequestPage, } from "./operator-v1.js";
|
|
2
|
+
const MAX_JSON_BYTES = 2_000_000;
|
|
3
|
+
const MAX_SSE_FRAME_BYTES = 256_000;
|
|
4
|
+
export class BrokerError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
status;
|
|
7
|
+
constructor(code, message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class BrokerClient {
|
|
14
|
+
endpoint;
|
|
15
|
+
credential;
|
|
16
|
+
timeoutMs;
|
|
17
|
+
constructor(endpoint, credential, timeoutMs) {
|
|
18
|
+
this.endpoint = endpoint;
|
|
19
|
+
this.credential = credential;
|
|
20
|
+
this.timeoutMs = timeoutMs;
|
|
21
|
+
}
|
|
22
|
+
async discover() {
|
|
23
|
+
const value = parseDescriptor(await this.json("/.well-known/brokerkit-operator"));
|
|
24
|
+
if (value.api_version !== "brokerkit.io/operator/v1")
|
|
25
|
+
throw new Error(`unsupported BrokerKit API ${value.api_version}`);
|
|
26
|
+
}
|
|
27
|
+
async health() {
|
|
28
|
+
parseHealth(await this.json("/healthz", {}, false));
|
|
29
|
+
}
|
|
30
|
+
async list(status = "pending", cursor) {
|
|
31
|
+
return parseRequestPage(await this.json(`/api/operator/v1/requests?status=${status}&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`));
|
|
32
|
+
}
|
|
33
|
+
async get(id) {
|
|
34
|
+
return parseRequest(await this.json(`/api/operator/v1/requests/${encodeURIComponent(id)}`));
|
|
35
|
+
}
|
|
36
|
+
decide(id, action, decision) {
|
|
37
|
+
return this.json(`/api/operator/v1/requests/${encodeURIComponent(id)}/${action}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify(decision),
|
|
41
|
+
}).then(parseRequest);
|
|
42
|
+
}
|
|
43
|
+
async *events(cursor, signal) {
|
|
44
|
+
const response = await this.request(`/api/operator/v1/events${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { headers: { accept: "text/event-stream" }, signal });
|
|
45
|
+
if (!response.ok || !response.body)
|
|
46
|
+
throw await this.error(response);
|
|
47
|
+
const reader = response.body
|
|
48
|
+
.pipeThrough(new TextDecoderStream())
|
|
49
|
+
.getReader();
|
|
50
|
+
let buffer = "";
|
|
51
|
+
for (;;) {
|
|
52
|
+
const chunk = await reader.read();
|
|
53
|
+
if (chunk.done)
|
|
54
|
+
return;
|
|
55
|
+
buffer += chunk.value;
|
|
56
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_SSE_FRAME_BYTES)
|
|
57
|
+
throw new Error("BrokerKit event frame is too large");
|
|
58
|
+
buffer = buffer.replaceAll("\r\n", "\n");
|
|
59
|
+
for (;;) {
|
|
60
|
+
const boundary = buffer.indexOf("\n\n");
|
|
61
|
+
if (boundary < 0)
|
|
62
|
+
break;
|
|
63
|
+
const frame = buffer.slice(0, boundary);
|
|
64
|
+
buffer = buffer.slice(boundary + 2);
|
|
65
|
+
const id = frame
|
|
66
|
+
.split("\n")
|
|
67
|
+
.find((line) => line.startsWith("id:"))
|
|
68
|
+
?.slice(3)
|
|
69
|
+
.trim();
|
|
70
|
+
const data = frame
|
|
71
|
+
.split("\n")
|
|
72
|
+
.filter((line) => line.startsWith("data:"))
|
|
73
|
+
.map((line) => line.slice(5).trimStart())
|
|
74
|
+
.join("\n");
|
|
75
|
+
if (!data)
|
|
76
|
+
continue;
|
|
77
|
+
const event = parseBrokerEvent(JSON.parse(data));
|
|
78
|
+
if (!id || event.cursor !== id)
|
|
79
|
+
throw new Error("BrokerKit event cursor mismatch");
|
|
80
|
+
yield event;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async json(path, init = {}, authenticated = true) {
|
|
85
|
+
const response = await this.request(path, init, authenticated);
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw await this.error(response);
|
|
88
|
+
requireJSON(response);
|
|
89
|
+
return JSON.parse(await boundedText(response, MAX_JSON_BYTES));
|
|
90
|
+
}
|
|
91
|
+
async request(path, init = {}, authenticated = true) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
94
|
+
const signal = init.signal
|
|
95
|
+
? AbortSignal.any([init.signal, controller.signal])
|
|
96
|
+
: controller.signal;
|
|
97
|
+
try {
|
|
98
|
+
const headers = new Headers(init.headers);
|
|
99
|
+
if (authenticated)
|
|
100
|
+
headers.set("authorization", `Bearer ${await this.credential()}`);
|
|
101
|
+
return await fetch(new URL(path, this.endpoint), {
|
|
102
|
+
...init,
|
|
103
|
+
headers,
|
|
104
|
+
signal,
|
|
105
|
+
redirect: "error",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async error(response) {
|
|
113
|
+
try {
|
|
114
|
+
requireJSON(response);
|
|
115
|
+
const value = parseErrorEnvelope(JSON.parse(await boundedText(response, 64_000)));
|
|
116
|
+
return new BrokerError(value?.error.code ?? "internal_error", value?.error.message ?? "BrokerKit request failed", response.status);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return new BrokerError("internal_error", "BrokerKit request failed", response.status);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function requireJSON(response) {
|
|
124
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
125
|
+
if (!/^application\/json(?:\s*;|$)/iu.test(contentType))
|
|
126
|
+
throw new Error("BrokerKit returned an invalid content type");
|
|
127
|
+
}
|
|
128
|
+
async function boundedText(response, limit) {
|
|
129
|
+
if (!response.body)
|
|
130
|
+
return "";
|
|
131
|
+
const reader = response.body.getReader();
|
|
132
|
+
const chunks = [];
|
|
133
|
+
let size = 0;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done)
|
|
137
|
+
break;
|
|
138
|
+
size += value.byteLength;
|
|
139
|
+
if (size > limit) {
|
|
140
|
+
await reader.cancel();
|
|
141
|
+
throw new Error("BrokerKit response is too large");
|
|
142
|
+
}
|
|
143
|
+
chunks.push(value);
|
|
144
|
+
}
|
|
145
|
+
const joined = new Uint8Array(size);
|
|
146
|
+
let offset = 0;
|
|
147
|
+
for (const chunk of chunks) {
|
|
148
|
+
joined.set(chunk, offset);
|
|
149
|
+
offset += chunk.byteLength;
|
|
150
|
+
}
|
|
151
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
|
|
152
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { pluginErrorCode } from "./errors.js";
|
|
2
|
+
export async function handleCommand(runtime, ctx) {
|
|
3
|
+
if (!ctx.isAuthorizedSender || !ctx.senderId)
|
|
4
|
+
return { text: "Not authorized." };
|
|
5
|
+
const tokens = (ctx.args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
6
|
+
const command = tokens[0] ?? "pending";
|
|
7
|
+
const handle = tokens[1];
|
|
8
|
+
if (command === "subscribe" || command === "unsubscribe") {
|
|
9
|
+
if (tokens.length !== 1)
|
|
10
|
+
return { text: "Invalid BrokerKit command." };
|
|
11
|
+
if (!validRouting(ctx.channel, ctx.to, ctx.accountId, ctx.messageThreadId))
|
|
12
|
+
return { text: "This conversation has no stable delivery target." };
|
|
13
|
+
const value = {
|
|
14
|
+
channel: ctx.channel,
|
|
15
|
+
target: ctx.to,
|
|
16
|
+
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
|
17
|
+
...(ctx.messageThreadId !== undefined
|
|
18
|
+
? { threadId: String(ctx.messageThreadId) }
|
|
19
|
+
: {}),
|
|
20
|
+
};
|
|
21
|
+
if (command === "subscribe") {
|
|
22
|
+
const subscription = runtime.subscribe(value);
|
|
23
|
+
return {
|
|
24
|
+
text: `Subscribed this conversation (${subscription.id.slice(0, 8)}).`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const removed = runtime.unsubscribe(value);
|
|
28
|
+
return {
|
|
29
|
+
text: removed
|
|
30
|
+
? "Unsubscribed this conversation."
|
|
31
|
+
: "This conversation was not subscribed.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (command === "pending") {
|
|
35
|
+
if (tokens.length > 1)
|
|
36
|
+
return { text: "Invalid BrokerKit command." };
|
|
37
|
+
const lines = runtime
|
|
38
|
+
.snapshot()
|
|
39
|
+
.requests.filter((request) => request.status === "pending")
|
|
40
|
+
.map((request) => `${request.handle} · ${request.sourceLabel} · ${request.presentation.title}`);
|
|
41
|
+
return {
|
|
42
|
+
text: lines.length ? lines.join("\n") : "No pending BrokerKit requests.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!handle || tokens.length !== 2)
|
|
46
|
+
return { text: "A single request handle is required." };
|
|
47
|
+
const request = runtime
|
|
48
|
+
.snapshot()
|
|
49
|
+
.requests.find((item) => item.handle === handle);
|
|
50
|
+
if (command === "show")
|
|
51
|
+
return { text: request ? formatRequest(request) : "Request not found." };
|
|
52
|
+
if (["approve", "deny", "cancel", "revoke"].includes(command)) {
|
|
53
|
+
if (!request)
|
|
54
|
+
return { text: "Request not found." };
|
|
55
|
+
try {
|
|
56
|
+
const updated = await runtime.decide(handle, command, request.revision, ctx.senderId);
|
|
57
|
+
return {
|
|
58
|
+
text: `${capitalize(command)} committed for ${updated.presentation.title}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return { text: commandError(error) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { text: "Unknown BrokerKit command." };
|
|
66
|
+
}
|
|
67
|
+
function validRouting(channel, target, accountId, threadId) {
|
|
68
|
+
return Boolean(channel &&
|
|
69
|
+
channel.length <= 128 &&
|
|
70
|
+
target &&
|
|
71
|
+
target.length <= 4096 &&
|
|
72
|
+
(!accountId || accountId.length <= 512) &&
|
|
73
|
+
(threadId === undefined || String(threadId).length <= 512));
|
|
74
|
+
}
|
|
75
|
+
function commandError(error) {
|
|
76
|
+
const code = pluginErrorCode(error);
|
|
77
|
+
if (code === "revision_stale")
|
|
78
|
+
return "This request changed. Review it again before deciding.";
|
|
79
|
+
if (code === "request_not_found" || code === "request_terminal")
|
|
80
|
+
return "This request is no longer actionable.";
|
|
81
|
+
if (code === "action_not_allowed")
|
|
82
|
+
return "That action is not allowed for this request.";
|
|
83
|
+
if (code === "source_unavailable")
|
|
84
|
+
return "The approval source is unavailable. No decision was reported.";
|
|
85
|
+
return "The decision could not be confirmed. No success was reported.";
|
|
86
|
+
}
|
|
87
|
+
function capitalize(value) {
|
|
88
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
89
|
+
}
|
|
90
|
+
function formatRequest(request) {
|
|
91
|
+
const facts = request.presentation.facts?.map((fact) => `${fact.label}: ${fact.value}`) ??
|
|
92
|
+
[];
|
|
93
|
+
return [
|
|
94
|
+
`${request.sourceLabel}: ${request.presentation.title}`,
|
|
95
|
+
request.presentation.summary ?? "",
|
|
96
|
+
...facts,
|
|
97
|
+
`Handle: ${request.handle}`,
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const secretRef = z
|
|
3
|
+
.object({
|
|
4
|
+
source: z.enum(["env", "file", "exec"]),
|
|
5
|
+
provider: z.string().min(1).max(64),
|
|
6
|
+
id: z.string().min(1).max(512),
|
|
7
|
+
})
|
|
8
|
+
.strict();
|
|
9
|
+
const broker = z
|
|
10
|
+
.object({
|
|
11
|
+
id: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/),
|
|
12
|
+
label: z.string().trim().min(1).max(80),
|
|
13
|
+
endpoint: z.url(),
|
|
14
|
+
operatorCredential: secretRef,
|
|
15
|
+
requestTimeoutMs: z.number().int().min(1000).max(60000).default(10000),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
const directConfig = z
|
|
19
|
+
.object({
|
|
20
|
+
mode: z.literal("direct"),
|
|
21
|
+
brokers: z.array(broker).min(1).max(32),
|
|
22
|
+
pollIntervalMs: z.number().int().min(5000).max(300000).default(30000),
|
|
23
|
+
notificationConcurrency: z.number().int().min(1).max(16).default(4),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.superRefine((value, ctx) => {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const [index, source] of value.brokers.entries()) {
|
|
29
|
+
if (seen.has(source.id))
|
|
30
|
+
ctx.addIssue({
|
|
31
|
+
code: "custom",
|
|
32
|
+
path: ["brokers", index, "id"],
|
|
33
|
+
message: "broker ids must be unique",
|
|
34
|
+
});
|
|
35
|
+
seen.add(source.id);
|
|
36
|
+
const endpoint = new URL(source.endpoint);
|
|
37
|
+
if (endpoint.username ||
|
|
38
|
+
endpoint.password ||
|
|
39
|
+
endpoint.search ||
|
|
40
|
+
endpoint.hash ||
|
|
41
|
+
!["http:", "https:"].includes(endpoint.protocol))
|
|
42
|
+
ctx.addIssue({
|
|
43
|
+
code: "custom",
|
|
44
|
+
path: ["brokers", index, "endpoint"],
|
|
45
|
+
message: "endpoint must be a credential-free HTTP URL",
|
|
46
|
+
});
|
|
47
|
+
if (endpoint.protocol === "http:" &&
|
|
48
|
+
!["127.0.0.1", "localhost", "::1"].includes(endpoint.hostname))
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: "custom",
|
|
51
|
+
path: ["brokers", index, "endpoint"],
|
|
52
|
+
message: "plaintext HTTP is limited to loopback",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const delegatedWebConfig = z
|
|
57
|
+
.object({
|
|
58
|
+
mode: z.literal("delegated-web"),
|
|
59
|
+
delegatedWeb: z
|
|
60
|
+
.object({
|
|
61
|
+
basePath: z.string().superRefine((value, ctx) => {
|
|
62
|
+
if (!validDelegatedBasePath(value)) {
|
|
63
|
+
ctx.addIssue({
|
|
64
|
+
code: "custom",
|
|
65
|
+
message: "delegated web basePath must be a normalized same-origin absolute path",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
})
|
|
70
|
+
.strict(),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
export const configSchema = z.discriminatedUnion("mode", [
|
|
74
|
+
directConfig,
|
|
75
|
+
delegatedWebConfig,
|
|
76
|
+
]);
|
|
77
|
+
export function parseConfig(value) {
|
|
78
|
+
return configSchema.parse(value);
|
|
79
|
+
}
|
|
80
|
+
function validDelegatedBasePath(value) {
|
|
81
|
+
if (value.length < 2 ||
|
|
82
|
+
value.length > 256 ||
|
|
83
|
+
!value.startsWith("/") ||
|
|
84
|
+
value.startsWith("//") ||
|
|
85
|
+
value.endsWith("/") ||
|
|
86
|
+
/[?#\\]/u.test(value) ||
|
|
87
|
+
hasControlCharacter(value)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const decoded = decodeURIComponent(value);
|
|
92
|
+
if (decoded !== value || decoded.includes("..") || decoded.includes("\\")) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return value
|
|
100
|
+
.split("/")
|
|
101
|
+
.every((segment, index) => index === 0 || /^[A-Za-z0-9._~-]+$/u.test(segment));
|
|
102
|
+
}
|
|
103
|
+
function hasControlCharacter(value) {
|
|
104
|
+
return [...value].some((character) => {
|
|
105
|
+
const code = character.codePointAt(0) ?? 0;
|
|
106
|
+
return code < 32 || code === 127;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BrokerError } from "./client.js";
|
|
2
|
+
export function pluginErrorCode(error) {
|
|
3
|
+
const code = error instanceof BrokerError
|
|
4
|
+
? error.code
|
|
5
|
+
: error instanceof Error
|
|
6
|
+
? error.message
|
|
7
|
+
: "internal_error";
|
|
8
|
+
if (code === "revision_conflict")
|
|
9
|
+
return "revision_stale";
|
|
10
|
+
if (code === "not_found")
|
|
11
|
+
return "request_not_found";
|
|
12
|
+
if (code === "invalid_transition" || code === "constraint_exceeded")
|
|
13
|
+
return "action_not_allowed";
|
|
14
|
+
return code;
|
|
15
|
+
}
|