@agentapprove/opencode 0.1.3 → 0.1.4

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 +114 -14
  2. package/package.json +1 -1
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,33 @@ 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 ivHex = parts[3];
147
+ const ciphertextBase64 = parts[4];
148
+ const hmacHex = parts[5];
149
+ const encKey = deriveEncKey(keyHex);
150
+ const macKey = deriveMacKey(keyHex);
151
+ const iv = Buffer.from(ivHex, "hex");
152
+ const ciphertext = Buffer.from(ciphertextBase64, "base64");
153
+ const expectedHmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
154
+ const actualHmac = Buffer.from(hmacHex, "hex");
155
+ if (expectedHmac.length !== actualHmac.length || !timingSafeEqual(expectedHmac, actualHmac)) {
156
+ return null;
157
+ }
158
+ const decipher = createDecipheriv("aes-256-ctr", encKey, iv);
159
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
160
+ return plaintext.toString("utf-8");
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
138
165
  function applyApprovalE2E(payload, userKey, serverKey) {
139
166
  const sensitiveFields = {};
140
167
  for (const field of ["command", "toolInput", "cwd"]) {
@@ -731,6 +758,45 @@ async function sendApprovalRequest(request, config, pluginPath) {
731
758
  processConfigSync(parsed, config);
732
759
  return parsed;
733
760
  }
761
+ async function sendStopRequest(request, config, pluginPath) {
762
+ if (!config.token) {
763
+ throw new Error("No Agent Approve token configured");
764
+ }
765
+ const payload = applyEventPrivacyFilter(
766
+ { ...request },
767
+ config.privacyTier
768
+ );
769
+ const bodyStr = JSON.stringify(payload);
770
+ const pluginHash = getPluginHash(pluginPath, config.debug);
771
+ const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
772
+ const headers = {
773
+ "Authorization": `Bearer ${config.token}`,
774
+ ...hmacHeaders
775
+ };
776
+ const url = `${config.apiUrl}/${config.apiVersion}/stop`;
777
+ const stopTimeoutMs = 3e5;
778
+ if (config.debug) {
779
+ debugLog(`Sending stop request to ${url}`);
780
+ debugLog(`=== SENT TO ${url} ===`);
781
+ debugLogRawInline(bodyStr);
782
+ debugLog("=== END SENT ===");
783
+ }
784
+ const response = await httpPost(url, bodyStr, headers, stopTimeoutMs);
785
+ if (config.debug) {
786
+ debugLog(`Stop response: ${response.body || "<empty>"}`);
787
+ }
788
+ if (response.status !== 200) {
789
+ throw new Error(`Stop API returned status ${response.status}: ${response.body.slice(0, 200)}`);
790
+ }
791
+ let parsed;
792
+ try {
793
+ parsed = JSON.parse(response.body);
794
+ } catch {
795
+ parsed = {};
796
+ }
797
+ processConfigSync(parsed, config);
798
+ return parsed;
799
+ }
734
800
  async function sendEvent(event, config, pluginPath) {
735
801
  if (!config.token) return;
736
802
  const eventType = event.eventType;
@@ -795,9 +861,11 @@ var fallbackSessionId = randomBytes2(12).toString("hex");
795
861
  var COMPACTION_DEDUP_WINDOW_MS = 2e3;
796
862
  var openCodeClient = void 0;
797
863
  var lastCompactionEventAt = 0;
864
+ var processingIdleSessions = /* @__PURE__ */ new Set();
798
865
  var seenUnmappedEventTypes = /* @__PURE__ */ new Set();
799
866
  var sentFinalMessageIds = /* @__PURE__ */ new Set();
800
867
  var messageParts = /* @__PURE__ */ new Map();
868
+ var NOISY_EVENT_TYPES = /* @__PURE__ */ new Set(["message.part.delta"]);
801
869
  function classifyTool(toolName) {
802
870
  const lower = toolName.toLowerCase();
803
871
  if (lower === "bash") {
@@ -1109,10 +1177,11 @@ async function plugin(input) {
1109
1177
  "event": async (input2) => {
1110
1178
  const eventName = input2.event?.type;
1111
1179
  if (!eventName) return;
1180
+ if (NOISY_EVENT_TYPES.has(eventName)) return;
1112
1181
  const eventProperties = input2.event.properties || {};
1113
1182
  const eventSessionId = getEventSessionId(eventName, eventProperties);
1114
1183
  const ids = resolveIds(eventSessionId);
1115
- if (config.debug && !seenUnmappedEventTypes.has(`_logged_${eventName}`)) {
1184
+ if (config.debug) {
1116
1185
  debugLog(`Event received: ${eventName}`, HOOK_EVENT);
1117
1186
  }
1118
1187
  if (eventName === "message.part.updated") {
@@ -1190,40 +1259,71 @@ async function plugin(input) {
1190
1259
  }
1191
1260
  if (eventName === "session.idle") return;
1192
1261
  const status = eventProperties.status;
1262
+ if (eventName === "session.status" && status?.type !== "idle") {
1263
+ processingIdleSessions.delete(ids.sessionId);
1264
+ return;
1265
+ }
1193
1266
  if (eventName === "session.status" && status?.type === "idle") {
1267
+ if (processingIdleSessions.has(ids.sessionId)) {
1268
+ if (config.debug) {
1269
+ debugLog("Skipping duplicate session.status idle (already processing)", HOOK_EVENT);
1270
+ }
1271
+ return;
1272
+ }
1273
+ processingIdleSessions.add(ids.sessionId);
1194
1274
  if (config.debug) {
1195
- debugLog(`Session idle detected via session.status, sending blocking stop event`, HOOK_EVENT);
1275
+ debugLog("Session idle detected via session.status, sending stop request", HOOK_EVENT);
1196
1276
  }
1197
1277
  const stopRequest = {
1198
- toolName: "session_idle",
1199
- toolType: "session",
1200
1278
  agent: config.agentType,
1201
1279
  agentName: config.agentName,
1202
- hookType: "stop",
1280
+ eventType: "stop",
1281
+ status: "completed",
1282
+ loopCount: 0,
1203
1283
  sessionId: ids.sessionId,
1204
1284
  conversationId: ids.conversationId,
1205
- projectPath,
1206
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1285
+ timestamp: Date.now(),
1286
+ ...config.e2eEnabled && config.e2eUserKey ? { e2eKeyId: keyId(config.e2eUserKey) } : {}
1207
1287
  };
1208
1288
  try {
1209
- const response = await sendApprovalRequest(stopRequest, config, pluginFilePath);
1210
- if ((response.decision === "approve" || response.decision === "allow") && response.reason && openCodeClient) {
1289
+ const response = await sendStopRequest(stopRequest, config, pluginFilePath);
1290
+ if (config.debug) {
1291
+ debugLog(`Stop response: followup=${response.followup_message ? "yes" : "no"}`, HOOK_EVENT);
1292
+ }
1293
+ if (response.followup_message && openCodeClient) {
1294
+ let followupText = response.followup_message;
1295
+ if (config.e2eEnabled && config.e2eUserKey && isE2EEncrypted(followupText)) {
1296
+ const decrypted = e2eDecrypt(config.e2eUserKey, followupText);
1297
+ if (decrypted) {
1298
+ if (config.debug) {
1299
+ debugLog(`Decrypted follow-up: ${decrypted.slice(0, 50)}...`, HOOK_EVENT);
1300
+ }
1301
+ followupText = decrypted;
1302
+ } else {
1303
+ if (config.debug) {
1304
+ debugLog("E2E decryption failed for follow-up message, skipping injection", HOOK_EVENT);
1305
+ }
1306
+ return;
1307
+ }
1308
+ }
1211
1309
  if (config.debug) {
1212
- debugLog(`Stop approved with follow-up input: ${response.reason.slice(0, 100)}`, HOOK_EVENT);
1310
+ debugLog(`Injecting user follow-up: ${followupText.slice(0, 100)}`, HOOK_EVENT);
1213
1311
  }
1214
1312
  try {
1215
- await openCodeClient.tui.appendPrompt({ body: { text: response.reason } });
1313
+ await openCodeClient.tui.appendPrompt({ body: { text: followupText } });
1216
1314
  await openCodeClient.tui.submitPrompt();
1217
1315
  } catch (injectionError) {
1218
1316
  if (config.debug) {
1219
- debugLog(`Failed to inject follow-up input: ${injectionError instanceof Error ? injectionError.message : String(injectionError)}`, HOOK_EVENT);
1317
+ debugLog(`Failed to inject follow-up: ${injectionError instanceof Error ? injectionError.message : String(injectionError)}`, HOOK_EVENT);
1220
1318
  }
1221
1319
  }
1222
1320
  }
1223
1321
  } catch (error) {
1224
1322
  if (config.debug) {
1225
- debugLog(`Stop event error: ${error instanceof Error ? error.message : String(error)}`, HOOK_EVENT);
1323
+ debugLog(`Stop request error: ${error instanceof Error ? error.message : String(error)}`, HOOK_EVENT);
1226
1324
  }
1325
+ } finally {
1326
+ processingIdleSessions.delete(ids.sessionId);
1227
1327
  }
1228
1328
  return;
1229
1329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentapprove/opencode",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Agent Approve plugin for OpenCode - approve or deny AI agent tool calls from your iPhone and Apple Watch",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {