forge-remote 0.1.16 → 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 +64 -1
- package/src/webhook-server.js +89 -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");
|
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" });
|
|
@@ -355,9 +374,75 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
|
|
|
355
374
|
const projectPath = config.projectPath || process.cwd();
|
|
356
375
|
const model = config.model || "sonnet";
|
|
357
376
|
|
|
358
|
-
//
|
|
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
|
+
) {
|
|
359
427
|
let sessionId;
|
|
360
|
-
|
|
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) {
|
|
361
446
|
sessionId = await startNewSession(currentDesktopId, {
|
|
362
447
|
prompt,
|
|
363
448
|
projectPath,
|
|
@@ -368,20 +453,10 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
|
|
|
368
453
|
replyUrl:
|
|
369
454
|
source === "slack" ? config.sourceConfig?.replyWebhookUrl : null,
|
|
370
455
|
},
|
|
456
|
+
allowedTools: config.allowedTools || null,
|
|
371
457
|
});
|
|
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
458
|
}
|
|
383
459
|
|
|
384
|
-
// 9. Audit log + update trigger count.
|
|
385
460
|
await writeAuditLog(webhookId, source, sourceIp, "accepted", null, sessionId);
|
|
386
461
|
await updateTriggerCount(webhookId);
|
|
387
462
|
|
|
@@ -389,10 +464,7 @@ async function handleWebhookPost(req, res, webhookId, sourceIp) {
|
|
|
389
464
|
`Webhook ${webhookId} (${source}) accepted — session ${sessionId || "started"}`,
|
|
390
465
|
);
|
|
391
466
|
|
|
392
|
-
return
|
|
393
|
-
status: "accepted",
|
|
394
|
-
sessionId: sessionId || null,
|
|
395
|
-
});
|
|
467
|
+
return sessionId;
|
|
396
468
|
}
|
|
397
469
|
|
|
398
470
|
// ---------------------------------------------------------------------------
|