@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.
Files changed (2) hide show
  1. package/dist/index.js +280 -10
  2. 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.1.4",
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
- void sendEvent({
1110
- eventType: "stop",
1330
+ const stopRequest = {
1111
1331
  agent: config.agentType,
1112
1332
  agentName: config.agentName,
1113
- hookType: "agent_end",
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
- status: event.success ? "completed" : "error",
1117
- durationMs: event.durationMs,
1118
- response: event.error || void 0,
1119
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1120
- }, config, pluginFilePath, HOOK_AGENT_END);
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.1.8",
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"