@better_openclaw/betterclaw 2.0.3 → 2.1.1
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/package.json +1 -1
- package/src/index.ts +12 -1
- package/src/jwt.ts +147 -0
- package/src/pipeline.ts +9 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { ProactiveEngine } from "./triggers.js";
|
|
|
9
9
|
import { processEvent } from "./pipeline.js";
|
|
10
10
|
import type { PipelineDeps } from "./pipeline.js";
|
|
11
11
|
import { BETTERCLAW_COMMANDS, mergeAllowCommands } from "./cli.js";
|
|
12
|
+
import { storeJwt } from "./jwt.js";
|
|
12
13
|
import { loadTriageProfile, runLearner } from "./learner.js";
|
|
13
14
|
import { ReactionTracker } from "./reactions.js";
|
|
14
15
|
import * as os from "node:os";
|
|
@@ -114,12 +115,22 @@ export default {
|
|
|
114
115
|
})();
|
|
115
116
|
|
|
116
117
|
// Ping health check
|
|
117
|
-
api.registerGatewayMethod("betterclaw.ping", ({ params, respond }) => {
|
|
118
|
+
api.registerGatewayMethod("betterclaw.ping", async ({ params, respond }) => {
|
|
118
119
|
const validTiers: Array<"free" | "premium" | "premium+"> = ["free", "premium", "premium+"];
|
|
119
120
|
const rawTier = (params as Record<string, unknown>)?.tier as string;
|
|
120
121
|
const tier = validTiers.includes(rawTier as any) ? (rawTier as "free" | "premium" | "premium+") : "free";
|
|
121
122
|
const smartMode = (params as Record<string, unknown>)?.smartMode === true;
|
|
122
123
|
|
|
124
|
+
const jwt = (params as Record<string, unknown>)?.jwt as string | undefined;
|
|
125
|
+
if (jwt) {
|
|
126
|
+
const payload = await storeJwt(jwt);
|
|
127
|
+
if (payload) {
|
|
128
|
+
api.logger.info(`betterclaw: JWT verified, entitlements=${payload.ent.join(",")}`);
|
|
129
|
+
} else {
|
|
130
|
+
api.logger.warn("betterclaw: JWT verification failed");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
ctxManager.setRuntimeState({ tier, smartMode });
|
|
124
135
|
|
|
125
136
|
const meta = ctxManager.get().meta;
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export interface JwtPayload {
|
|
2
|
+
sub: string;
|
|
3
|
+
aud: string;
|
|
4
|
+
ent: string[];
|
|
5
|
+
iat: number;
|
|
6
|
+
exp: number;
|
|
7
|
+
iss: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const EXPECTED_AUD = "betterclaw";
|
|
11
|
+
const EXPECTED_ISS = "api.betterclaw.app";
|
|
12
|
+
|
|
13
|
+
function base64urlDecode(str: string): Uint8Array {
|
|
14
|
+
const padded = str + "=".repeat((4 - (str.length % 4)) % 4);
|
|
15
|
+
const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
16
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function importPublicKey(pem: string): Promise<CryptoKey> {
|
|
20
|
+
const b64 = pem
|
|
21
|
+
.replace(/-----BEGIN PUBLIC KEY-----/, "")
|
|
22
|
+
.replace(/-----END PUBLIC KEY-----/, "")
|
|
23
|
+
.replace(/\s/g, "");
|
|
24
|
+
const binary = atob(b64);
|
|
25
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
26
|
+
return crypto.subtle.importKey(
|
|
27
|
+
"spki",
|
|
28
|
+
bytes,
|
|
29
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
30
|
+
false,
|
|
31
|
+
["verify"]
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verify an ES256 JWT and return the payload, or null if invalid.
|
|
37
|
+
* Never throws — all failures return null and are logged.
|
|
38
|
+
*/
|
|
39
|
+
export async function verifyJwt(
|
|
40
|
+
token: string,
|
|
41
|
+
publicKeyPem: string
|
|
42
|
+
): Promise<JwtPayload | null> {
|
|
43
|
+
try {
|
|
44
|
+
const parts = token.split(".");
|
|
45
|
+
if (parts.length !== 3) return null;
|
|
46
|
+
|
|
47
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
48
|
+
|
|
49
|
+
// Verify signature
|
|
50
|
+
const publicKey = await importPublicKey(publicKeyPem);
|
|
51
|
+
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
52
|
+
const signature = base64urlDecode(signatureB64);
|
|
53
|
+
|
|
54
|
+
const valid = await crypto.subtle.verify(
|
|
55
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
56
|
+
publicKey,
|
|
57
|
+
signature,
|
|
58
|
+
signingInput
|
|
59
|
+
);
|
|
60
|
+
if (!valid) return null;
|
|
61
|
+
|
|
62
|
+
// Decode and validate payload
|
|
63
|
+
const payload: JwtPayload = JSON.parse(
|
|
64
|
+
new TextDecoder().decode(base64urlDecode(payloadB64))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (payload.aud !== EXPECTED_AUD) return null;
|
|
68
|
+
if (payload.iss !== EXPECTED_ISS) return null;
|
|
69
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
70
|
+
if (!Array.isArray(payload.ent)) return null;
|
|
71
|
+
|
|
72
|
+
return payload;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a verified JWT payload has a specific entitlement.
|
|
80
|
+
*/
|
|
81
|
+
export function hasEntitlement(
|
|
82
|
+
payload: JwtPayload | null,
|
|
83
|
+
entitlement: string
|
|
84
|
+
): boolean {
|
|
85
|
+
return payload !== null && payload.ent.includes(entitlement);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ES256 public key for JWT verification (from betterclaw-api)
|
|
89
|
+
const JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
90
|
+
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENEZHoGBTF5wHq6p7GTDRl5b24aSS
|
|
91
|
+
Jw9NZAbe/inE4VynwiMvl3IxS+CdJYSm4CKbeCGXxy/5jCBk6Mzod+0ICg==
|
|
92
|
+
-----END PUBLIC KEY-----`;
|
|
93
|
+
|
|
94
|
+
// Module-level JWT state — safe because this is per-plugin-instance,
|
|
95
|
+
// not per-request. Updated only from the heartbeat handler.
|
|
96
|
+
let currentJwtToken: string | null = null;
|
|
97
|
+
let currentPayload: JwtPayload | null = null;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Store and verify a JWT received from heartbeat.
|
|
101
|
+
* Only re-verifies if the token has changed.
|
|
102
|
+
*/
|
|
103
|
+
export async function storeJwt(jwt: string): Promise<JwtPayload | null> {
|
|
104
|
+
if (jwt === currentJwtToken) return currentPayload;
|
|
105
|
+
currentJwtToken = jwt;
|
|
106
|
+
currentPayload = await verifyJwt(jwt, JWT_PUBLIC_KEY);
|
|
107
|
+
return currentPayload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the current verified payload (may be null).
|
|
112
|
+
*/
|
|
113
|
+
export function getVerifiedPayload(): JwtPayload | null {
|
|
114
|
+
return currentPayload;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if the current JWT grants an entitlement.
|
|
119
|
+
* Returns null if entitled, or an error message string if not.
|
|
120
|
+
*/
|
|
121
|
+
export function requireEntitlement(entitlement: string): string | null {
|
|
122
|
+
if (!currentPayload) {
|
|
123
|
+
return "This feature requires an active Premium subscription. Please open BetterClaw and check your subscription status.";
|
|
124
|
+
}
|
|
125
|
+
if (!hasEntitlement(currentPayload, entitlement)) {
|
|
126
|
+
if (entitlement === "shortcuts") {
|
|
127
|
+
return "This feature requires the Shortcuts Pack add-on. Please open BetterClaw and check your subscription status.";
|
|
128
|
+
}
|
|
129
|
+
return "This feature requires an active Premium subscription. Please open BetterClaw and check your subscription status.";
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reset JWT state (for testing).
|
|
136
|
+
*/
|
|
137
|
+
export function _resetJwtState(): void {
|
|
138
|
+
currentJwtToken = null;
|
|
139
|
+
currentPayload = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Inject a payload directly (for testing requireEntitlement without real keys).
|
|
144
|
+
*/
|
|
145
|
+
export function _setPayloadForTesting(payload: JwtPayload | null): void {
|
|
146
|
+
currentPayload = payload;
|
|
147
|
+
}
|
package/src/pipeline.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ReactionTracker } from "./reactions.js";
|
|
|
6
6
|
import type { DeviceEvent, DeviceContext, PluginConfig } from "./types.js";
|
|
7
7
|
import { triageEvent } from "./triage.js";
|
|
8
8
|
import { loadTriageProfile } from "./learner.js";
|
|
9
|
+
import { requireEntitlement } from "./jwt.js";
|
|
9
10
|
|
|
10
11
|
export interface PipelineDeps {
|
|
11
12
|
api: OpenClawPluginApi;
|
|
@@ -20,10 +21,17 @@ export interface PipelineDeps {
|
|
|
20
21
|
export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Promise<void> {
|
|
21
22
|
const { api, config, context, events, rules } = deps;
|
|
22
23
|
|
|
23
|
-
// Always update context
|
|
24
|
+
// Always update context (even for non-premium users)
|
|
24
25
|
context.updateFromEvent(event);
|
|
25
26
|
await context.save();
|
|
26
27
|
|
|
28
|
+
// Gate event forwarding behind premium entitlement
|
|
29
|
+
const entitlementError = requireEntitlement("premium");
|
|
30
|
+
if (entitlementError) {
|
|
31
|
+
api.logger.info(`betterclaw: event blocked (no premium entitlement)`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
// If smartMode is OFF, store only — no filtering or pushing
|
|
28
36
|
if (!context.getRuntimeState().smartMode) {
|
|
29
37
|
await events.append({ event, decision: "stored", reason: "smartMode off", timestamp: Date.now() / 1000 });
|