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 +1 -1
- package/src/session-manager.js +79 -5
- package/src/webhook-server.js +104 -17
package/package.json
CHANGED
package/src/session-manager.js
CHANGED
|
@@ -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 } =
|
|
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
|
-
|
|
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
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
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
|
|
package/src/webhook-server.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
393
|
-
status: "accepted",
|
|
394
|
-
sessionId: sessionId || null,
|
|
395
|
-
});
|
|
482
|
+
return sessionId;
|
|
396
483
|
}
|
|
397
484
|
|
|
398
485
|
// ---------------------------------------------------------------------------
|