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.
- package/.context/pa.md +1 -1
- package/.context/runtime.md +48 -4
- package/.env.example +6 -0
- package/.env.example.full +7 -0
- package/README.md +5 -1
- package/dist/config.js +2 -0
- package/dist/cron/cron-sync-coordinator.js +4 -0
- package/dist/cron/cron-sync-coordinator.test.js +8 -0
- package/dist/cron/executor.js +36 -1
- package/dist/cron/executor.test.js +157 -0
- package/dist/cron/forum-sync.js +47 -0
- package/dist/cron/forum-sync.test.js +234 -0
- package/dist/cron/run-stats.js +10 -3
- package/dist/cron/run-stats.test.js +67 -3
- package/dist/discord/actions-config.js +41 -8
- package/dist/discord/actions-config.test.js +130 -8
- package/dist/discord/actions-crons.js +18 -0
- package/dist/discord/actions-crons.test.js +12 -0
- package/dist/discord/models-command.js +5 -0
- package/dist/index.js +28 -0
- package/dist/mcp-detect.js +74 -0
- package/dist/mcp-detect.test.js +160 -0
- package/dist/runtime/openai-compat.js +224 -90
- package/dist/runtime/openai-compat.test.js +409 -2
- package/dist/runtime/openai-tool-exec.js +433 -0
- package/dist/runtime/openai-tool-exec.test.js +267 -0
- package/dist/runtime/openai-tool-schemas.js +174 -0
- package/dist/runtime/openai-tool-schemas.test.js +74 -0
- package/dist/runtime/tools/fs-glob.js +102 -0
- package/dist/runtime/tools/fs-glob.test.js +67 -0
- package/dist/runtime/tools/fs-read-file.js +49 -0
- package/dist/runtime/tools/fs-read-file.test.js +51 -0
- package/dist/runtime/tools/fs-realpath.js +51 -0
- package/dist/runtime/tools/fs-realpath.test.js +72 -0
- package/dist/runtime/tools/fs-write-file.js +45 -0
- package/dist/runtime/tools/fs-write-file.test.js +56 -0
- package/dist/runtime/tools/image-download.js +138 -0
- package/dist/runtime/tools/image-download.test.js +106 -0
- package/dist/runtime/tools/path-security.js +72 -0
- package/dist/runtime/tools/types.js +4 -0
- package/dist/workspace-bootstrap.js +0 -1
- package/dist/workspace-bootstrap.test.js +0 -2
- package/package.json +1 -1
- package/templates/mcp.json +8 -0
- package/templates/workspace/TOOLS.md +70 -1
- 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
|
+
}
|
|
@@ -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
|
@@ -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.
|