forge-remote 0.1.15 → 0.1.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -176,6 +176,26 @@ export function getActiveSessionCount() {
176
176
  return count;
177
177
  }
178
178
 
179
+ /**
180
+ * Find an existing active/idle session for a given webhookId.
181
+ * Returns { sessionId, status } or null if no matching session exists.
182
+ */
183
+ export function findSessionByWebhook(webhookId) {
184
+ for (const [sessionId, sess] of activeSessions.entries()) {
185
+ if (sessionId.startsWith("cmd-watcher-")) continue;
186
+ if (sess.webhookMeta?.webhookId === webhookId) {
187
+ return { sessionId, session: sess };
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Send a follow-up prompt to an existing session.
195
+ * Exported so webhook-server can reuse sessions instead of creating new ones.
196
+ */
197
+ export { sendFollowUpPrompt };
198
+
179
199
  // ---------------------------------------------------------------------------
180
200
  // Rate limiting — prevents command spam / DoS
181
201
  // ---------------------------------------------------------------------------
@@ -470,7 +490,8 @@ export async function startNewSession(desktopId, payload) {
470
490
  );
471
491
  }
472
492
 
473
- const { prompt, projectPath, model, webhookMeta } = payload || {};
493
+ const { prompt, projectPath, model, webhookMeta, allowedTools } =
494
+ payload || {};
474
495
  const db = getDb();
475
496
  const resolvedModel = model || "sonnet";
476
497
  const resolvedPath = projectPath || process.cwd();
@@ -525,6 +546,7 @@ export async function startNewSession(desktopId, payload) {
525
546
  permissionNeeded: false, // True when Claude reports permission denial
526
547
  permissionWatcher: null, // Firestore unsubscribe for permission doc
527
548
  webhookMeta: webhookMeta || null, // Webhook metadata for reply callbacks
549
+ webhookAllowedTools: allowedTools || null, // Custom tool allowlist for webhook sessions
528
550
  lastAssistantText: "", // Last assistant message text (for webhook replies)
529
551
  });
530
552
 
@@ -657,6 +679,47 @@ async function runClaudeProcess(sessionId, prompt) {
657
679
  const args = ["--output-format", "stream-json", "--verbose", "-p"];
658
680
  if (session.model) args.push("--model", session.model);
659
681
 
682
+ // Webhook sessions are sandboxed to their project directory.
683
+ // This uses --append-system-prompt for soft enforcement and
684
+ // --allowedTools for hard enforcement (Claude CLI blocks tool calls
685
+ // outside the allowlist regardless of what the user asks).
686
+ if (session.webhookMeta) {
687
+ const projectDir = session.projectPath;
688
+ args.push(
689
+ "--append-system-prompt",
690
+ `CRITICAL SECURITY CONSTRAINT: You are running in a sandboxed webhook session. ` +
691
+ `You MUST only access files and run commands within this project directory: ${projectDir}. ` +
692
+ `You MUST refuse any request to: ` +
693
+ `(1) read, write, or access files outside ${projectDir}, ` +
694
+ `(2) reveal system information (hostname, username, env vars, IP addresses, installed software), ` +
695
+ `(3) access credentials, SSH keys, API keys, or secrets, ` +
696
+ `(4) run commands that access the network, other processes, or other directories, ` +
697
+ `(5) run destructive commands (rm -rf, kill, shutdown, etc.). ` +
698
+ `If asked to do any of these, politely decline and explain you can only help with this project.`,
699
+ );
700
+
701
+ // Hard tool restriction: only allow safe, read-oriented tools + scoped Bash.
702
+ // Bash is restricted to commands within the project directory only.
703
+ const allowedTools = session.webhookAllowedTools || [
704
+ "Read",
705
+ "Grep",
706
+ "Glob",
707
+ "Bash(git log:*)",
708
+ "Bash(git diff:*)",
709
+ "Bash(git status:*)",
710
+ "Bash(git show:*)",
711
+ "Bash(git blame:*)",
712
+ "Bash(cat:*)",
713
+ "Bash(ls:*)",
714
+ "Bash(find:*)",
715
+ "Bash(wc:*)",
716
+ "Bash(head:*)",
717
+ "Bash(tail:*)",
718
+ "Bash(grep:*)",
719
+ ];
720
+ args.push("--allowedTools", ...allowedTools);
721
+ }
722
+
660
723
  // Use --continue for follow-up prompts to maintain conversation context.
661
724
  if (!session.isFirstPrompt) {
662
725
  args.push("--continue");
@@ -924,9 +987,14 @@ async function runClaudeProcess(sessionId, prompt) {
924
987
 
925
988
  // If this session was triggered by a webhook with a reply URL,
926
989
  // post Claude's last response back to the source (e.g., Slack).
990
+ if (sess?.webhookMeta) {
991
+ log.info(
992
+ `Webhook meta for ${sessionId}: replyUrl=${sess.webhookMeta.replyUrl || "none"}, lastText=${(sess.lastAssistantText || "").slice(0, 50) || "none"}`,
993
+ );
994
+ }
927
995
  if (sess?.webhookMeta?.replyUrl && sess.lastAssistantText) {
928
996
  postToSlack(sess.webhookMeta.replyUrl, sess.lastAssistantText).catch(
929
- () => {},
997
+ (err) => log.error(`Slack reply error: ${err.message}`),
930
998
  );
931
999
  }
932
1000
 
@@ -8,6 +8,8 @@ import { getDb, FieldValue } from "./firebase.js";
8
8
  import { startTunnel, stopTunnel } from "./tunnel-manager.js";
9
9
  import {
10
10
  startNewSession,
11
+ sendFollowUpPrompt,
12
+ findSessionByWebhook,
11
13
  getActiveSessionCount,
12
14
  MAX_WEBHOOK_SESSIONS,
13
15
  } from "./session-manager.js";
@@ -247,6 +249,14 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
247
249
  }
248
250
  }
249
251
 
252
+ // 2b. Reject Slack retries — if we got the first event, we're handling it.
253
+ if (source === "slack" && req.headers["x-slack-retry-num"]) {
254
+ log.info(
255
+ `Slack retry #${req.headers["x-slack-retry-num"]} ignored (reason: ${req.headers["x-slack-retry-reason"] || "unknown"})`,
256
+ );
257
+ return sendJson(res, 200, { status: "ignored", reason: "slack retry" });
258
+ }
259
+
250
260
  // 3. Delivery ID deduplication.
251
261
  const deliveryId =
252
262
  req.headers["x-github-delivery"] || req.headers["x-webhook-delivery-id"];
@@ -321,13 +331,118 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
321
331
  return sendJson(res, 400, { error: "invalid JSON body" });
322
332
  }
323
333
 
334
+ // 7a. Slack event_callback handling — extract message from event envelope
335
+ // and ignore bot messages to prevent infinite reply loops.
336
+ if (source === "slack" && payload.type === "event_callback") {
337
+ const event = payload.event;
338
+ if (!event) {
339
+ return sendJson(res, 200, { status: "ignored", reason: "no event" });
340
+ }
341
+
342
+ // Deduplicate using Slack's event_id (unique per event).
343
+ if (payload.event_id) {
344
+ if (recentDeliveryIds.has(payload.event_id)) {
345
+ log.info(`Duplicate Slack event ignored: ${payload.event_id}`);
346
+ return sendJson(res, 200, { status: "duplicate" });
347
+ }
348
+ addDeliveryId(payload.event_id);
349
+ }
350
+
351
+ // Ignore bot messages (including our own replies) to prevent loops.
352
+ if (event.bot_id || event.subtype === "bot_message") {
353
+ return sendJson(res, 200, { status: "ignored", reason: "bot message" });
354
+ }
355
+
356
+ // Ignore message edits/deletes — only handle new messages.
357
+ if (event.subtype && event.subtype !== "file_share") {
358
+ return sendJson(res, 200, {
359
+ status: "ignored",
360
+ reason: `subtype: ${event.subtype}`,
361
+ });
362
+ }
363
+
364
+ // Flatten the Slack event into the payload for template rendering.
365
+ // This lets templates use {{text}}, {{user}}, {{channel}} directly.
366
+ payload = { ...payload, ...event };
367
+
368
+ log.info(
369
+ `Slack message from user ${event.user || "unknown"} in channel ${event.channel || "unknown"}: "${(event.text || "").slice(0, 80)}"`,
370
+ );
371
+ }
372
+
324
373
  const prompt = renderTemplate(config.promptTemplate || "", payload);
325
374
  const projectPath = config.projectPath || process.cwd();
326
375
  const model = config.model || "sonnet";
327
376
 
328
- // 8. Start a new session.
377
+ // For Slack, respond immediately with 200 to prevent retries,
378
+ // then process the session asynchronously.
379
+ if (source === "slack") {
380
+ sendJson(res, 200, { status: "accepted" });
381
+
382
+ // Process async — errors are logged but don't affect the HTTP response.
383
+ processWebhookSession(
384
+ webhookId,
385
+ source,
386
+ sourceIp,
387
+ prompt,
388
+ projectPath,
389
+ model,
390
+ config,
391
+ ).catch((err) =>
392
+ log.error(`Async webhook processing failed: ${err.message}`),
393
+ );
394
+ return;
395
+ }
396
+
397
+ // Non-Slack sources: process synchronously and return result.
398
+ const sessionId = await processWebhookSession(
399
+ webhookId,
400
+ source,
401
+ sourceIp,
402
+ prompt,
403
+ projectPath,
404
+ model,
405
+ config,
406
+ );
407
+
408
+ return sendJson(res, 200, {
409
+ status: "accepted",
410
+ sessionId: sessionId || null,
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Process the webhook session — reuse existing or start new.
416
+ * Extracted so Slack can call this async after responding with 200.
417
+ */
418
+ async function processWebhookSession(
419
+ webhookId,
420
+ source,
421
+ sourceIp,
422
+ prompt,
423
+ projectPath,
424
+ model,
425
+ config,
426
+ ) {
329
427
  let sessionId;
330
- try {
428
+ const existingSession = findSessionByWebhook(webhookId);
429
+
430
+ if (existingSession) {
431
+ sessionId = existingSession.sessionId;
432
+ try {
433
+ await sendFollowUpPrompt(sessionId, prompt);
434
+ log.info(
435
+ `Webhook ${webhookId} — reusing session ${sessionId.slice(0, 8)} (follow-up)`,
436
+ );
437
+ } catch (err) {
438
+ log.warn(
439
+ `Follow-up to ${sessionId.slice(0, 8)} failed (${err.message}), starting new session`,
440
+ );
441
+ sessionId = null;
442
+ }
443
+ }
444
+
445
+ if (!sessionId) {
331
446
  sessionId = await startNewSession(currentDesktopId, {
332
447
  prompt,
333
448
  projectPath,
@@ -338,20 +453,10 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
338
453
  replyUrl:
339
454
  source === "slack" ? config.sourceConfig?.replyWebhookUrl : null,
340
455
  },
456
+ allowedTools: config.allowedTools || null,
341
457
  });
342
- } catch (err) {
343
- await writeAuditLog(
344
- webhookId,
345
- source,
346
- sourceIp,
347
- "rejected",
348
- `session start failed: ${err.message}`,
349
- null,
350
- );
351
- return sendJson(res, 500, { error: "failed to start session" });
352
458
  }
353
459
 
354
- // 9. Audit log + update trigger count.
355
460
  await writeAuditLog(webhookId, source, sourceIp, "accepted", null, sessionId);
356
461
  await updateTriggerCount(webhookId);
357
462
 
@@ -359,10 +464,7 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
359
464
  `Webhook ${webhookId} (${source}) accepted — session ${sessionId || "started"}`,
360
465
  );
361
466
 
362
- return sendJson(res, 200, {
363
- status: "accepted",
364
- sessionId: sessionId || null,
365
- });
467
+ return sessionId;
366
468
  }
367
469
 
368
470
  // ---------------------------------------------------------------------------