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 +1 -1
- package/src/session-manager.js +70 -2
- package/src/webhook-server.js +119 -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,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
|
|
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"];
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
363
|
-
status: "accepted",
|
|
364
|
-
sessionId: sessionId || null,
|
|
365
|
-
});
|
|
467
|
+
return sessionId;
|
|
366
468
|
}
|
|
367
469
|
|
|
368
470
|
// ---------------------------------------------------------------------------
|