discoclaw 0.2.4 → 0.3.0

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.
Files changed (46) hide show
  1. package/.context/pa.md +1 -1
  2. package/.context/runtime.md +48 -4
  3. package/.env.example +6 -0
  4. package/.env.example.full +7 -0
  5. package/README.md +5 -1
  6. package/dist/config.js +2 -0
  7. package/dist/cron/cron-sync-coordinator.js +4 -0
  8. package/dist/cron/cron-sync-coordinator.test.js +8 -0
  9. package/dist/cron/executor.js +36 -1
  10. package/dist/cron/executor.test.js +157 -0
  11. package/dist/cron/forum-sync.js +47 -0
  12. package/dist/cron/forum-sync.test.js +234 -0
  13. package/dist/cron/run-stats.js +10 -3
  14. package/dist/cron/run-stats.test.js +67 -3
  15. package/dist/discord/actions-config.js +41 -8
  16. package/dist/discord/actions-config.test.js +130 -8
  17. package/dist/discord/actions-crons.js +18 -0
  18. package/dist/discord/actions-crons.test.js +12 -0
  19. package/dist/discord/models-command.js +5 -0
  20. package/dist/index.js +28 -0
  21. package/dist/mcp-detect.js +74 -0
  22. package/dist/mcp-detect.test.js +160 -0
  23. package/dist/runtime/openai-compat.js +224 -90
  24. package/dist/runtime/openai-compat.test.js +409 -2
  25. package/dist/runtime/openai-tool-exec.js +433 -0
  26. package/dist/runtime/openai-tool-exec.test.js +267 -0
  27. package/dist/runtime/openai-tool-schemas.js +174 -0
  28. package/dist/runtime/openai-tool-schemas.test.js +74 -0
  29. package/dist/runtime/tools/fs-glob.js +102 -0
  30. package/dist/runtime/tools/fs-glob.test.js +67 -0
  31. package/dist/runtime/tools/fs-read-file.js +49 -0
  32. package/dist/runtime/tools/fs-read-file.test.js +51 -0
  33. package/dist/runtime/tools/fs-realpath.js +51 -0
  34. package/dist/runtime/tools/fs-realpath.test.js +72 -0
  35. package/dist/runtime/tools/fs-write-file.js +45 -0
  36. package/dist/runtime/tools/fs-write-file.test.js +56 -0
  37. package/dist/runtime/tools/image-download.js +138 -0
  38. package/dist/runtime/tools/image-download.test.js +106 -0
  39. package/dist/runtime/tools/path-security.js +72 -0
  40. package/dist/runtime/tools/types.js +4 -0
  41. package/dist/workspace-bootstrap.js +0 -1
  42. package/dist/workspace-bootstrap.test.js +0 -2
  43. package/package.json +1 -1
  44. package/templates/mcp.json +8 -0
  45. package/templates/workspace/TOOLS.md +70 -1
  46. package/templates/workspace/HEARTBEAT.md +0 -10
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Path security utilities for filesystem tool handlers.
3
+ *
4
+ * Ensures all file operations stay within allowed root directories,
5
+ * resolving symlinks to prevent escape attacks.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ /**
10
+ * Canonicalize allowed roots once (resolves symlinks in the roots themselves).
11
+ * Falls back to path.resolve if the root dir doesn't exist yet.
12
+ */
13
+ export async function canonicalizeRoots(roots) {
14
+ const canonical = [];
15
+ for (const root of roots) {
16
+ try {
17
+ canonical.push(await fs.realpath(root));
18
+ }
19
+ catch {
20
+ canonical.push(path.resolve(root));
21
+ }
22
+ }
23
+ return canonical;
24
+ }
25
+ /**
26
+ * Verify that `targetPath` falls under at least one allowed root.
27
+ * Uses fs.realpath to resolve symlinks, preventing symlink escapes.
28
+ * When the target (or its parent) doesn't exist, walks up the directory tree
29
+ * to find an existing ancestor and validates that.
30
+ */
31
+ export async function assertPathAllowed(targetPath, allowedRoots, checkParent = false) {
32
+ const canonicalRoots = await canonicalizeRoots(allowedRoots);
33
+ const toCheck = checkParent ? path.dirname(targetPath) : targetPath;
34
+ // Walk up to the nearest existing ancestor for realpath resolution
35
+ let canonical;
36
+ let current = toCheck;
37
+ for (;;) {
38
+ try {
39
+ canonical = await fs.realpath(current);
40
+ break;
41
+ }
42
+ catch (err) {
43
+ if (err.code === 'ENOENT') {
44
+ const parent = path.dirname(current);
45
+ if (parent === current) {
46
+ throw new Error(`Path not accessible: ${toCheck}`);
47
+ }
48
+ current = parent;
49
+ continue;
50
+ }
51
+ throw new Error(`Path not accessible: ${toCheck}`);
52
+ }
53
+ }
54
+ // Reconstruct the full canonical path by appending the non-existing suffix
55
+ const suffix = path.relative(current, toCheck);
56
+ if (suffix && suffix !== '.') {
57
+ canonical = path.join(canonical, suffix);
58
+ }
59
+ const allowed = canonicalRoots.some((root) => canonical === root || canonical.startsWith(root + path.sep));
60
+ if (!allowed) {
61
+ throw new Error(`Path outside allowed roots: ${targetPath}`);
62
+ }
63
+ }
64
+ /**
65
+ * Resolve a file_path argument against the first allowed root,
66
+ * then validate it falls within allowed roots.
67
+ */
68
+ export async function resolveAndCheck(filePath, allowedRoots, checkParent = false) {
69
+ const resolved = path.resolve(allowedRoots[0], filePath);
70
+ await assertPathAllowed(resolved, allowedRoots, checkParent);
71
+ return resolved;
72
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared types for standalone OpenAI function-calling tool modules.
3
+ */
4
+ export {};
@@ -11,7 +11,6 @@ const TEMPLATE_FILES = [
11
11
  'USER.md',
12
12
  'AGENTS.md',
13
13
  'TOOLS.md',
14
- 'HEARTBEAT.md',
15
14
  'MEMORY.md',
16
15
  ];
17
16
  /** Marker text present in the template IDENTITY.md but removed during onboarding. */
@@ -16,7 +16,6 @@ const ALL_TEMPLATE_FILES = [
16
16
  'USER.md',
17
17
  'AGENTS.md',
18
18
  'TOOLS.md',
19
- 'HEARTBEAT.md',
20
19
  'MEMORY.md',
21
20
  ];
22
21
  /** Real IDENTITY.md content that passes onboarding check (no template marker). */
@@ -199,7 +198,6 @@ describe('ensureWorkspaceBootstrapFiles', () => {
199
198
  'SOUL.md': 'My soul',
200
199
  'TOOLS.md': 'My tools',
201
200
  'USER.md': 'My user',
202
- 'HEARTBEAT.md': 'My heartbeat',
203
201
  'MEMORY.md': 'My memory',
204
202
  };
205
203
  for (const [file, content] of Object.entries(customFiles)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "filesystem": {
4
+ "command": "npx",
5
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
6
+ }
7
+ }
8
+ }
@@ -167,6 +167,73 @@ When the user asks for "a rebuild," follow a consistent sequence and wait for co
167
167
 
168
168
  If the user separately asks for a restart, only then execute `systemctl --user restart discoclaw`, following the existing restart procedure (status → restart → status/logs). Never restart before the rebuild workflow has succeeded and been confirmed; the rebuild must be confirmed first, then the restart follows as a distinct, second step.
169
169
 
170
+ ## Webhook Server
171
+
172
+ Discoclaw includes an inbound webhook server that lets external services (GitHub, monitoring tools, etc.) trigger AI-powered responses in Discord channels.
173
+
174
+ ### Enabling
175
+
176
+ Set these environment variables:
177
+
178
+ - `DISCOCLAW_WEBHOOK_ENABLED=1` — enables the server
179
+ - `DISCOCLAW_WEBHOOK_PORT=9400` — TCP port (default: 9400)
180
+ - `DISCOCLAW_WEBHOOK_CONFIG=/absolute/path/to/webhooks.json` — path to the source config file
181
+
182
+ ### Config format
183
+
184
+ The config file is a JSON object mapping source names to their settings:
185
+
186
+ ```json
187
+ {
188
+ "github": {
189
+ "secret": "your-hmac-secret",
190
+ "channel": "dev-updates",
191
+ "prompt": "A GitHub event was received from {{source}}:\n\n{{body}}\n\nSummarize what happened."
192
+ },
193
+ "monitoring": {
194
+ "secret": "another-secret",
195
+ "channel": "alerts"
196
+ }
197
+ }
198
+ ```
199
+
200
+ **Fields per source:**
201
+ - `secret` (required): HMAC-SHA256 secret for verifying `X-Hub-Signature-256` headers (same convention as GitHub webhooks).
202
+ - `channel` (required): Discord channel name or ID where the webhook posts.
203
+ - `prompt` (optional): Instruction sent to the runtime. Supports `{{body}}` (raw request body) and `{{source}}` (source name) placeholders. If omitted, a default prompt is built from the source name and payload.
204
+
205
+ ### How it works
206
+
207
+ 1. External service sends `POST /webhook/<source>` with an HMAC signature header.
208
+ 2. Server verifies the signature, returns 202 immediately.
209
+ 3. Dispatches through the cron execution pipeline — same runtime invocation, channel routing, and logging as automations.
210
+ 4. Webhook executions run with Discord actions and tools disabled for security.
211
+
212
+ ### Endpoint format
213
+
214
+ ```
215
+ POST http://localhost:9400/webhook/<source-name>
216
+ X-Hub-Signature-256: sha256=<hex-hmac-digest>
217
+ Content-Type: application/json
218
+
219
+ { ... payload ... }
220
+ ```
221
+
222
+ ### Exposure
223
+
224
+ The server binds to `127.0.0.1` by default (loopback only). To receive webhooks from external services like GitHub, you need to expose the port. See `docs/webhook-exposure.md` for setup instructions covering Tailscale Funnel, ngrok, and Caddy reverse proxy.
225
+
226
+ ### Security notes
227
+
228
+ - Unknown sources return 404 (doesn't leak which sources exist).
229
+ - Failed signature verification returns 401.
230
+ - Max request body: 256 KB.
231
+ - Webhook jobs run without Discord action permissions or tool access.
232
+
233
+ ### Webhooks vs. Automations
234
+
235
+ Webhooks and automations (crons) are two trigger mechanisms for the same execution pipeline. Automations are **time-driven** — they fire on a cron schedule (e.g., "every weekday at 7 AM"). Webhooks are **event-driven** — they fire when an external service sends an HTTP POST. Both dispatch through the same `executeCronJob` pipeline, sharing runtime invocation, channel routing, model selection, and logging. If you're setting up one, you should know the other exists — see the [Cron Actions](#cron-actions-automations) section below for time-driven automations.
236
+
170
237
  ## Plan-Audit-Implement Workflow
171
238
 
172
239
  A structured dev workflow for producing audited plans before writing code. Use this for any non-trivial change — features, bug fixes, refactors. Triggered by **"plan this"**, **"let's plan"**, or the `!plan` / `!forge` Discord commands.
@@ -339,7 +406,7 @@ Use taskList to check existing tasks before creating duplicates. Use taskShow/ta
339
406
 
340
407
  ### Cron Actions (Automations)
341
408
 
342
- **Automations** is the user-facing name for crons. Each automation lives as a thread in a dedicated Discord forum channel (typically called "automations"). When a user says "create an automation," "set up a scheduled task," or "run X every morning/weekly/etc.," respond with `cronCreate`. Use `cronList` to check what's already running before creating a new one.
409
+ **Automations** is the user-facing name for crons. Each automation lives as a thread in a dedicated Discord forum channel (typically called "automations"). When a user says "create an automation," "set up a scheduled task," or "run X every morning/weekly/etc.," respond with `cronCreate`. Use `cronList` to check what's already running before creating a new one. For event-driven triggers (external HTTP POSTs from GitHub, monitoring, etc.), see the [Webhook Server](#webhook-server) section above.
343
410
 
344
411
  **cronCreate** — Create a new scheduled task:
345
412
  ```
@@ -430,3 +497,5 @@ Changes are **ephemeral** -- they take effect immediately but revert on restart.
430
497
  Set `cron-exec` to `default` to clear the override and fall back to the chat model.
431
498
 
432
499
  Note: The `cron` role controls auto-tagging only. Use `cron-exec` to set the default execution model for all cron jobs.
500
+
501
+ **Scope:** `modelSet` changes the model within the *current* runtime adapter (e.g., switching from `sonnet` to `opus` within the Claude adapter, or switching between models on OpenRouter). Setting `model` to a runtime name like `openrouter` or `gemini` on the `chat` role swaps the active adapter entirely. However, there is no per-message prefix routing (e.g., `/sonnet` or `/gemini-flash` in a message does nothing) — all messages in a session use the active runtime until changed via `modelSet`.
@@ -1,10 +0,0 @@
1
- # HEARTBEAT.md
2
-
3
- # Keep this file empty (or with only comments) to skip heartbeat tasks.
4
-
5
- # Add tasks below when you want the agent to check something periodically.
6
-
7
- # --- Memory maintenance (uncomment to enable) ---
8
- # - Review daily logs older than 7 days in memory/. Distill anything worth keeping into MEMORY.md, then delete the old daily file.
9
- # - Check MEMORY.md for stale or superseded entries. Remove anything no longer relevant.
10
- # - If MEMORY.md exceeds ~2 KB, prune aggressively — keep only what matters for future sessions.