@agentapprove/openclaw 0.1.8 → 0.2.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/dist/index.js +280 -10
- package/package.json +11 -3
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { homedir as homedir3 } from "os";
|
|
|
9
9
|
import { execSync } from "child_process";
|
|
10
10
|
|
|
11
11
|
// src/e2e-crypto.ts
|
|
12
|
-
import { createHash, createHmac, createCipheriv, randomBytes } from "crypto";
|
|
12
|
+
import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "crypto";
|
|
13
13
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, copyFileSync } from "fs";
|
|
14
14
|
import { join as join2 } from "path";
|
|
15
15
|
import { homedir as homedir2 } from "os";
|
|
@@ -135,6 +135,35 @@ function e2eEncrypt(keyHex, plaintext) {
|
|
|
135
135
|
const hmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest("hex");
|
|
136
136
|
return `E2E:v1:${kid}:${ivHex}:${ciphertextBase64}:${hmac}`;
|
|
137
137
|
}
|
|
138
|
+
function isE2EEncrypted(value) {
|
|
139
|
+
return value.startsWith("E2E:v1:");
|
|
140
|
+
}
|
|
141
|
+
function e2eDecrypt(keyHex, encrypted) {
|
|
142
|
+
try {
|
|
143
|
+
if (!isE2EEncrypted(encrypted)) return null;
|
|
144
|
+
const parts = encrypted.split(":");
|
|
145
|
+
if (parts.length !== 6) return null;
|
|
146
|
+
const kid = parts[2];
|
|
147
|
+
if (kid !== keyId(keyHex)) return null;
|
|
148
|
+
const ivHex = parts[3];
|
|
149
|
+
const ciphertextBase64 = parts[4];
|
|
150
|
+
const hmacHex = parts[5];
|
|
151
|
+
const encKey = deriveEncKey(keyHex);
|
|
152
|
+
const macKey = deriveMacKey(keyHex);
|
|
153
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
154
|
+
const ciphertext = Buffer.from(ciphertextBase64, "base64");
|
|
155
|
+
const expectedHmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
|
|
156
|
+
const actualHmac = Buffer.from(hmacHex, "hex");
|
|
157
|
+
if (expectedHmac.length !== actualHmac.length || !timingSafeEqual(expectedHmac, actualHmac)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const decipher = createDecipheriv("aes-256-ctr", encKey, iv);
|
|
161
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
162
|
+
return plaintext.toString("utf-8");
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
138
167
|
function applyApprovalE2E(payload, userKey, serverKey) {
|
|
139
168
|
const sensitiveFields = {};
|
|
140
169
|
for (const field of ["command", "toolInput", "cwd"]) {
|
|
@@ -386,7 +415,7 @@ function loadConfig(openclawConfig, logger) {
|
|
|
386
415
|
failBehavior,
|
|
387
416
|
privacyTier,
|
|
388
417
|
debug,
|
|
389
|
-
hookVersion: "1.
|
|
418
|
+
hookVersion: "1.2.0",
|
|
390
419
|
agentType: "openclaw",
|
|
391
420
|
agentName,
|
|
392
421
|
e2eEnabled,
|
|
@@ -731,6 +760,51 @@ async function sendApprovalRequest(request, config, pluginPath, hookName = "open
|
|
|
731
760
|
processConfigSync(parsed, config);
|
|
732
761
|
return parsed;
|
|
733
762
|
}
|
|
763
|
+
async function sendStopRequest(request, config, pluginPath, hookName = "openclaw-plugin") {
|
|
764
|
+
if (!config.token) {
|
|
765
|
+
throw new Error("Missing token. Download the iOS app, run npx agentapprove, and pair your device. Subscription required.");
|
|
766
|
+
}
|
|
767
|
+
let payload = applyEventPrivacyFilter(
|
|
768
|
+
{ ...request },
|
|
769
|
+
config.privacyTier
|
|
770
|
+
);
|
|
771
|
+
if (config.e2eEnabled && config.e2eUserKey) {
|
|
772
|
+
payload = applyEventE2E(payload, config.e2eUserKey);
|
|
773
|
+
if (config.debug) {
|
|
774
|
+
debugLog("E2E encryption applied to stop request", hookName);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const bodyStr = JSON.stringify(payload);
|
|
778
|
+
const pluginHash = getPluginHash(pluginPath, config.debug, hookName);
|
|
779
|
+
const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
|
|
780
|
+
const headers = {
|
|
781
|
+
"Authorization": `Bearer ${config.token}`,
|
|
782
|
+
...hmacHeaders
|
|
783
|
+
};
|
|
784
|
+
const url = `${config.apiUrl}/${config.apiVersion}/stop`;
|
|
785
|
+
const stopTimeoutMs = 3e5;
|
|
786
|
+
if (config.debug) {
|
|
787
|
+
debugLog(`Sending stop request to ${url}`, hookName);
|
|
788
|
+
debugLog(`=== SENT TO ${url} ===`, hookName);
|
|
789
|
+
debugLogRawInline(bodyStr);
|
|
790
|
+
debugLog("=== END SENT ===", hookName);
|
|
791
|
+
}
|
|
792
|
+
const response = await httpPost(url, bodyStr, headers, stopTimeoutMs);
|
|
793
|
+
if (config.debug) {
|
|
794
|
+
debugLog(`Stop response: ${response.body || "<empty>"}`, hookName);
|
|
795
|
+
}
|
|
796
|
+
if (response.status !== 200) {
|
|
797
|
+
throw new Error(`Stop API returned status ${response.status}: ${response.body.slice(0, 200)}`);
|
|
798
|
+
}
|
|
799
|
+
let parsed;
|
|
800
|
+
try {
|
|
801
|
+
parsed = JSON.parse(response.body);
|
|
802
|
+
} catch {
|
|
803
|
+
parsed = {};
|
|
804
|
+
}
|
|
805
|
+
processConfigSync(parsed, config);
|
|
806
|
+
return parsed;
|
|
807
|
+
}
|
|
734
808
|
async function sendEvent(event, config, pluginPath, hookName = "openclaw-plugin") {
|
|
735
809
|
if (!config.token) return;
|
|
736
810
|
const eventType = event.eventType;
|
|
@@ -777,6 +851,153 @@ async function sendEvent(event, config, pluginPath, hookName = "openclaw-plugin"
|
|
|
777
851
|
}
|
|
778
852
|
}
|
|
779
853
|
|
|
854
|
+
// src/gateway-client.ts
|
|
855
|
+
import { randomUUID } from "crypto";
|
|
856
|
+
var DEFAULT_GATEWAY_PORT = 18789;
|
|
857
|
+
var CONNECT_TIMEOUT_MS = 1e4;
|
|
858
|
+
var HOOK_NAME = "openclaw-gateway";
|
|
859
|
+
function resolveGatewayConfig(pluginConfig, debug) {
|
|
860
|
+
const gw = pluginConfig?.gateway;
|
|
861
|
+
const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT;
|
|
862
|
+
const envPort = parseInt(envPortRaw || "", 10);
|
|
863
|
+
if (envPortRaw && isNaN(envPort)) {
|
|
864
|
+
debugLog(`Invalid OPENCLAW_GATEWAY_PORT "${envPortRaw}", using default ${DEFAULT_GATEWAY_PORT}`, HOOK_NAME);
|
|
865
|
+
}
|
|
866
|
+
const port = (typeof gw?.port === "number" && gw.port > 0 ? gw.port : void 0) || (!isNaN(envPort) ? envPort : void 0) || DEFAULT_GATEWAY_PORT;
|
|
867
|
+
const auth = gw?.auth;
|
|
868
|
+
const configToken = typeof auth?.token === "string" ? auth.token : "";
|
|
869
|
+
const token = configToken || process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
870
|
+
if (!token) {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
return { port, token, debug };
|
|
874
|
+
}
|
|
875
|
+
async function sendChatMessage(gwConfig, sessionKey, message, idempotencyKey = randomUUID()) {
|
|
876
|
+
let WebSocket;
|
|
877
|
+
try {
|
|
878
|
+
({ default: WebSocket } = await import("ws"));
|
|
879
|
+
} catch {
|
|
880
|
+
throw new Error(
|
|
881
|
+
'The "ws" package is required for Gateway WebSocket support. Install it with: npm install ws'
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return new Promise((resolve, reject) => {
|
|
885
|
+
const url = `ws://127.0.0.1:${gwConfig.port}`;
|
|
886
|
+
let settled = false;
|
|
887
|
+
let connectReqId;
|
|
888
|
+
let chatReqId;
|
|
889
|
+
if (gwConfig.debug) {
|
|
890
|
+
debugLog(`Connecting to gateway at ${url}`, HOOK_NAME);
|
|
891
|
+
}
|
|
892
|
+
const ws = new WebSocket(url);
|
|
893
|
+
const sendOrFail = (payload) => {
|
|
894
|
+
ws.send(payload, (sendErr) => {
|
|
895
|
+
if (sendErr && !settled) {
|
|
896
|
+
settled = true;
|
|
897
|
+
cleanup();
|
|
898
|
+
reject(sendErr);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
const timeout = setTimeout(() => {
|
|
903
|
+
if (!settled) {
|
|
904
|
+
settled = true;
|
|
905
|
+
try {
|
|
906
|
+
ws.terminate();
|
|
907
|
+
} catch {
|
|
908
|
+
}
|
|
909
|
+
reject(new Error(`Gateway connection timed out after ${CONNECT_TIMEOUT_MS}ms`));
|
|
910
|
+
}
|
|
911
|
+
}, CONNECT_TIMEOUT_MS);
|
|
912
|
+
const cleanup = () => {
|
|
913
|
+
clearTimeout(timeout);
|
|
914
|
+
try {
|
|
915
|
+
ws.terminate();
|
|
916
|
+
} catch {
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
ws.on("error", (err) => {
|
|
920
|
+
if (!settled) {
|
|
921
|
+
settled = true;
|
|
922
|
+
cleanup();
|
|
923
|
+
reject(err);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
ws.on("message", (data) => {
|
|
927
|
+
let msg;
|
|
928
|
+
try {
|
|
929
|
+
msg = JSON.parse(data.toString());
|
|
930
|
+
} catch {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (msg.type === "event" && msg.event === "connect.challenge" && !connectReqId) {
|
|
934
|
+
connectReqId = randomUUID();
|
|
935
|
+
sendOrFail(JSON.stringify({
|
|
936
|
+
type: "req",
|
|
937
|
+
id: connectReqId,
|
|
938
|
+
method: "connect",
|
|
939
|
+
params: {
|
|
940
|
+
minProtocol: 3,
|
|
941
|
+
maxProtocol: 3,
|
|
942
|
+
client: { id: "agentapprove-plugin", version: "1.0", platform: "node", mode: "operator" },
|
|
943
|
+
role: "operator",
|
|
944
|
+
scopes: ["operator.admin"],
|
|
945
|
+
auth: { token: gwConfig.token }
|
|
946
|
+
}
|
|
947
|
+
}));
|
|
948
|
+
if (gwConfig.debug) {
|
|
949
|
+
debugLog("Sent connect request", HOOK_NAME);
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (msg.type === "res" && connectReqId && msg.id === connectReqId && !chatReqId) {
|
|
954
|
+
if (!msg.ok) {
|
|
955
|
+
settled = true;
|
|
956
|
+
cleanup();
|
|
957
|
+
reject(new Error(`Gateway auth failed: ${msg.error?.message || "unknown"}`));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
chatReqId = randomUUID();
|
|
961
|
+
sendOrFail(JSON.stringify({
|
|
962
|
+
type: "req",
|
|
963
|
+
id: chatReqId,
|
|
964
|
+
method: "chat.send",
|
|
965
|
+
params: {
|
|
966
|
+
sessionKey,
|
|
967
|
+
message,
|
|
968
|
+
idempotencyKey
|
|
969
|
+
}
|
|
970
|
+
}));
|
|
971
|
+
if (gwConfig.debug) {
|
|
972
|
+
debugLog(`Sent chat.send for session ${sessionKey}`, HOOK_NAME);
|
|
973
|
+
}
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (msg.type === "res" && chatReqId && msg.id === chatReqId) {
|
|
977
|
+
settled = true;
|
|
978
|
+
cleanup();
|
|
979
|
+
if (msg.ok) {
|
|
980
|
+
const runId = msg.payload?.runId;
|
|
981
|
+
if (gwConfig.debug) {
|
|
982
|
+
debugLog(`chat.send succeeded, runId=${runId || "unknown"}`, HOOK_NAME);
|
|
983
|
+
}
|
|
984
|
+
resolve({ runId });
|
|
985
|
+
} else {
|
|
986
|
+
reject(new Error(`chat.send failed: ${msg.error?.message || "unknown"}`));
|
|
987
|
+
}
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
ws.on("close", () => {
|
|
992
|
+
if (!settled) {
|
|
993
|
+
settled = true;
|
|
994
|
+
cleanup();
|
|
995
|
+
reject(new Error("Gateway connection closed unexpectedly"));
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
780
1001
|
// src/index.ts
|
|
781
1002
|
var pluginFilePath;
|
|
782
1003
|
try {
|
|
@@ -1106,18 +1327,67 @@ function register(api) {
|
|
|
1106
1327
|
debugLog(`Agent ended: success=${event.success}${event.durationMs ? `, duration=${event.durationMs}ms` : ""}${event.error ? `, error=${event.error}` : ""}`, HOOK_AGENT_END);
|
|
1107
1328
|
}
|
|
1108
1329
|
const conversationId = resolveConversationId();
|
|
1109
|
-
|
|
1110
|
-
eventType: "stop",
|
|
1330
|
+
const stopRequest = {
|
|
1111
1331
|
agent: config.agentType,
|
|
1112
1332
|
agentName: config.agentName,
|
|
1113
|
-
|
|
1333
|
+
eventType: "stop",
|
|
1334
|
+
status: event.success ? "completed" : "error",
|
|
1335
|
+
loopCount: Array.isArray(event.messages) ? event.messages.length : 0,
|
|
1114
1336
|
sessionId: conversationId,
|
|
1115
1337
|
conversationId,
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1338
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1339
|
+
...config.e2eEnabled && config.e2eUserKey ? { e2eKeyId: keyId(config.e2eUserKey) } : {}
|
|
1340
|
+
};
|
|
1341
|
+
try {
|
|
1342
|
+
const response = await sendStopRequest(stopRequest, config, pluginFilePath, HOOK_AGENT_END);
|
|
1343
|
+
if (config.debug) {
|
|
1344
|
+
debugLog(`Stop response: followup=${response.followup_message ? "yes" : "no"}`, HOOK_AGENT_END);
|
|
1345
|
+
}
|
|
1346
|
+
if (response.followup_message) {
|
|
1347
|
+
let followupText = response.followup_message;
|
|
1348
|
+
if (config.e2eEnabled && config.e2eUserKey && isE2EEncrypted(followupText)) {
|
|
1349
|
+
const decrypted = e2eDecrypt(config.e2eUserKey, followupText);
|
|
1350
|
+
if (!decrypted) {
|
|
1351
|
+
api.logger.warn("Agent Approve: failed to decrypt follow-up message");
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (config.debug) {
|
|
1355
|
+
debugLog(`Decrypted follow-up: ${decrypted.slice(0, 50)}...`, HOOK_AGENT_END);
|
|
1356
|
+
}
|
|
1357
|
+
followupText = decrypted;
|
|
1358
|
+
}
|
|
1359
|
+
const gwConfig = resolveGatewayConfig(api.pluginConfig, config.debug);
|
|
1360
|
+
if (!gwConfig) {
|
|
1361
|
+
api.logger.warn(
|
|
1362
|
+
"Agent Approve: follow-up received but no gateway token configured \u2014 set OPENCLAW_GATEWAY_TOKEN or gateway.auth.token in plugin config"
|
|
1363
|
+
);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (!ctx.sessionKey) {
|
|
1367
|
+
api.logger.warn(
|
|
1368
|
+
"Agent Approve: follow-up received but session key unavailable \u2014 cannot send to OpenClaw"
|
|
1369
|
+
);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
const idempotencyKey = createHash2("sha256").update(`${ctx.sessionKey}:${stopRequest.timestamp}:${followupText}`).digest("hex").slice(0, 32);
|
|
1374
|
+
const result = await sendChatMessage(gwConfig, ctx.sessionKey, followupText, idempotencyKey);
|
|
1375
|
+
if (config.debug) {
|
|
1376
|
+
debugLog(`Follow-up sent via gateway chat.send, runId=${result.runId || "unknown"}`, HOOK_AGENT_END);
|
|
1377
|
+
}
|
|
1378
|
+
api.logger.info(`Agent Approve: follow-up sent as user prompt via gateway`);
|
|
1379
|
+
} catch (injectionError) {
|
|
1380
|
+
const injMsg = injectionError instanceof Error ? injectionError.message : String(injectionError);
|
|
1381
|
+
api.logger.warn(`Agent Approve: follow-up injection via gateway failed \u2014 ${injMsg}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1386
|
+
if (config.debug) {
|
|
1387
|
+
debugLog(`Stop request error: ${msg}`, HOOK_AGENT_END);
|
|
1388
|
+
}
|
|
1389
|
+
api.logger.warn(`Agent Approve: stop request failed \u2014 ${msg}`);
|
|
1390
|
+
}
|
|
1121
1391
|
});
|
|
1122
1392
|
api.on("before_compaction", async (event, ctx) => {
|
|
1123
1393
|
if (config.debug) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentapprove/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Agent Approve plugin for OpenClaw - approve or deny AI agent tool calls from your iPhone and Apple Watch",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process",
|
|
7
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process --external:ws",
|
|
8
8
|
"hash": "shasum -a 256 dist/index.js | cut -d' ' -f1",
|
|
9
|
-
"dev": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process --watch"
|
|
9
|
+
"dev": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process --external:ws --watch"
|
|
10
10
|
},
|
|
11
11
|
"openclaw": {
|
|
12
12
|
"extensions": ["./dist/index.js"]
|
|
@@ -36,6 +36,14 @@
|
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=18"
|
|
38
38
|
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"ws": ">=7.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"ws": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
39
47
|
"devDependencies": {
|
|
40
48
|
"esbuild": "^0.24.0",
|
|
41
49
|
"typescript": "^5.0.0"
|