forge-remote 0.1.16 → 0.1.19

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.16",
3
+ "version": "0.1.19",
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,13 +679,59 @@ 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");
663
726
  }
664
727
  session.isFirstPrompt = false;
665
728
 
666
- args.push(prompt);
729
+ // IMPORTANT: When --allowedTools is used, it consumes all remaining
730
+ // positional args. So we must pass the prompt via stdin instead.
731
+ const promptViaStdin = !!session.webhookMeta;
732
+ if (!promptViaStdin) {
733
+ args.push(prompt);
734
+ }
667
735
 
668
736
  log.session(sessionId, `Spawning: ${claudeBinary} ${args.join(" ")}`);
669
737
 
@@ -678,9 +746,15 @@ async function runClaudeProcess(sessionId, prompt) {
678
746
 
679
747
  session.process = claudeProcess;
680
748
 
681
- // Close stdin immediately `-p` mode reads the prompt from args, not stdin.
682
- // Leaving stdin open can cause Claude CLI to hang waiting for input.
683
- claudeProcess.stdin.end();
749
+ // When --allowedTools is used (webhook sessions), the prompt is sent via
750
+ // stdin because --allowedTools is variadic and would consume the prompt
751
+ // if passed as a positional arg. Otherwise, close stdin immediately.
752
+ if (promptViaStdin) {
753
+ claudeProcess.stdin.write(prompt);
754
+ claudeProcess.stdin.end();
755
+ } else {
756
+ claudeProcess.stdin.end();
757
+ }
684
758
 
685
759
  log.session(sessionId, `Process started — PID ${claudeProcess.pid}`);
686
760
 
@@ -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"];
@@ -329,6 +339,15 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
329
339
  return sendJson(res, 200, { status: "ignored", reason: "no event" });
330
340
  }
331
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
+
332
351
  // Ignore bot messages (including our own replies) to prevent loops.
333
352
  if (event.bot_id || event.subtype === "bot_message") {
334
353
  return sendJson(res, 200, { status: "ignored", reason: "bot message" });
@@ -342,6 +361,21 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
342
361
  });
343
362
  }
344
363
 
364
+ // When subscribed to both message and app_mention events, Slack sends
365
+ // separate events for the same user message. Deduplicate by using the
366
+ // Slack message timestamp (ts) which is identical across both events.
367
+ if (event.ts) {
368
+ const tsKey = `slack-ts:${event.channel}:${event.ts}`;
369
+ if (recentDeliveryIds.has(tsKey)) {
370
+ log.info(`Duplicate Slack message ts ignored: ${event.ts}`);
371
+ return sendJson(res, 200, {
372
+ status: "duplicate",
373
+ reason: "same message ts",
374
+ });
375
+ }
376
+ addDeliveryId(tsKey);
377
+ }
378
+
345
379
  // Flatten the Slack event into the payload for template rendering.
346
380
  // This lets templates use {{text}}, {{user}}, {{channel}} directly.
347
381
  payload = { ...payload, ...event };
@@ -355,9 +389,75 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
355
389
  const projectPath = config.projectPath || process.cwd();
356
390
  const model = config.model || "sonnet";
357
391
 
358
- // 8. Start a new session.
392
+ // For Slack, respond immediately with 200 to prevent retries,
393
+ // then process the session asynchronously.
394
+ if (source === "slack") {
395
+ sendJson(res, 200, { status: "accepted" });
396
+
397
+ // Process async — errors are logged but don't affect the HTTP response.
398
+ processWebhookSession(
399
+ webhookId,
400
+ source,
401
+ sourceIp,
402
+ prompt,
403
+ projectPath,
404
+ model,
405
+ config,
406
+ ).catch((err) =>
407
+ log.error(`Async webhook processing failed: ${err.message}`),
408
+ );
409
+ return;
410
+ }
411
+
412
+ // Non-Slack sources: process synchronously and return result.
413
+ const sessionId = await processWebhookSession(
414
+ webhookId,
415
+ source,
416
+ sourceIp,
417
+ prompt,
418
+ projectPath,
419
+ model,
420
+ config,
421
+ );
422
+
423
+ return sendJson(res, 200, {
424
+ status: "accepted",
425
+ sessionId: sessionId || null,
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Process the webhook session — reuse existing or start new.
431
+ * Extracted so Slack can call this async after responding with 200.
432
+ */
433
+ async function processWebhookSession(
434
+ webhookId,
435
+ source,
436
+ sourceIp,
437
+ prompt,
438
+ projectPath,
439
+ model,
440
+ config,
441
+ ) {
359
442
  let sessionId;
360
- try {
443
+ const existingSession = findSessionByWebhook(webhookId);
444
+
445
+ if (existingSession) {
446
+ sessionId = existingSession.sessionId;
447
+ try {
448
+ await sendFollowUpPrompt(sessionId, prompt);
449
+ log.info(
450
+ `Webhook ${webhookId} — reusing session ${sessionId.slice(0, 8)} (follow-up)`,
451
+ );
452
+ } catch (err) {
453
+ log.warn(
454
+ `Follow-up to ${sessionId.slice(0, 8)} failed (${err.message}), starting new session`,
455
+ );
456
+ sessionId = null;
457
+ }
458
+ }
459
+
460
+ if (!sessionId) {
361
461
  sessionId = await startNewSession(currentDesktopId, {
362
462
  prompt,
363
463
  projectPath,
@@ -368,20 +468,10 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
368
468
  replyUrl:
369
469
  source === "slack" ? config.sourceConfig?.replyWebhookUrl : null,
370
470
  },
471
+ allowedTools: config.allowedTools || null,
371
472
  });
372
- } catch (err) {
373
- await writeAuditLog(
374
- webhookId,
375
- source,
376
- sourceIp,
377
- "rejected",
378
- `session start failed: ${err.message}`,
379
- null,
380
- );
381
- return sendJson(res, 500, { error: "failed to start session" });
382
473
  }
383
474
 
384
- // 9. Audit log + update trigger count.
385
475
  await writeAuditLog(webhookId, source, sourceIp, "accepted", null, sessionId);
386
476
  await updateTriggerCount(webhookId);
387
477
 
@@ -389,10 +479,7 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
389
479
  `Webhook ${webhookId} (${source}) accepted — session ${sessionId || "started"}`,
390
480
  );
391
481
 
392
- return sendJson(res, 200, {
393
- status: "accepted",
394
- sessionId: sessionId || null,
395
- });
482
+ return sessionId;
396
483
  }
397
484
 
398
485
  // ---------------------------------------------------------------------------