wood-fired-tasks 2.2.0 → 2.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/AGENTS.md +10 -0
- package/CHANGELOG.md +40 -0
- package/SECURITY.md +3 -3
- package/dist/api/routes/auth/callback.js +11 -1
- package/dist/api/routes/auth/device-code.d.ts +17 -1
- package/dist/api/routes/auth/device-code.js +26 -3
- package/dist/api/routes/auth/device-html.js +11 -1
- package/dist/api/routes/auth/device-token.js +14 -1
- package/dist/api/routes/auth/login.js +13 -1
- package/dist/api/server.js +34 -2
- package/dist/cli/auth/browser-open.js +21 -4
- package/dist/config/env.d.ts +7 -0
- package/dist/config/env.js +63 -0
- package/dist/skills/tasks/loop-shared.md +8 -0
- package/dist/skills/tasks/update.md +8 -0
- package/dist/slack/commands/tasks-command.js +11 -7
- package/dist/slack/formatters/project-formatter.js +7 -4
- package/dist/slack/mrkdwn.d.ts +1 -0
- package/dist/slack/mrkdwn.js +26 -0
- package/dist/slack/task-formatter.js +12 -11
- package/docs/INTERFACES.md +8 -1
- package/docs/SETUP.md +39 -27
- package/package.json +5 -5
- package/packages/wft-router/README.md +2 -0
- package/packages/wft-router/dist/config/triggers-schema.d.ts +4 -0
- package/packages/wft-router/dist/config/triggers-schema.js +72 -0
- package/packages/wft-router/dist/daemon.d.ts +52 -0
- package/packages/wft-router/dist/daemon.js +74 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack mrkdwn escaping primitive.
|
|
3
|
+
*
|
|
4
|
+
* Slack's mrkdwn parser treats `<...>` as control sequences: `<!channel>` /
|
|
5
|
+
* `<!here>` broadcast pings, `<@U123>` user mentions, and `<https://x|label>`
|
|
6
|
+
* spoofable links. User-controlled text (task/project/comment fields) flowing
|
|
7
|
+
* into a `mrkdwn` (or even a `plain_text`) block must be escaped so it renders
|
|
8
|
+
* as literal characters instead of injecting pings or fake links.
|
|
9
|
+
*
|
|
10
|
+
* Per Slack's spec only three characters need escaping, and ORDER MATTERS:
|
|
11
|
+
* `&` first so the `&` we emit for `<`/`>` isn't itself re-escaped, then
|
|
12
|
+
* `<` → `<`, then `>` → `>`. Mirrors `escapeHtml` in `src/web/html.ts`.
|
|
13
|
+
*
|
|
14
|
+
* null / undefined collapse to '' so optional fields render cleanly without
|
|
15
|
+
* forcing every call site to defend against missing data.
|
|
16
|
+
*/
|
|
17
|
+
const AMP = /&/g;
|
|
18
|
+
const LT = /</g;
|
|
19
|
+
const GT = />/g;
|
|
20
|
+
export function escapeSlackMrkdwn(value) {
|
|
21
|
+
if (value === null || value === undefined)
|
|
22
|
+
return '';
|
|
23
|
+
const s = typeof value === 'string' ? value : String(value);
|
|
24
|
+
return s.replace(AMP, '&').replace(LT, '<').replace(GT, '>');
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=mrkdwn.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeSlackMrkdwn } from './mrkdwn.js';
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// Lookup maps — exported so project-formatter.ts can reuse them (Plan 02)
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
@@ -54,12 +55,12 @@ export function formatTaskList(tasks) {
|
|
|
54
55
|
for (const task of displayTasks) {
|
|
55
56
|
const emoji = STATUS_EMOJI[task.status] ?? '❓';
|
|
56
57
|
const priority = PRIORITY_INDICATOR[task.priority] ?? task.priority;
|
|
57
|
-
const assignee = task.assignee ? `@${task.assignee}` : '_unassigned_';
|
|
58
|
+
const assignee = task.assignee ? `@${escapeSlackMrkdwn(task.assignee)}` : '_unassigned_';
|
|
58
59
|
const section = {
|
|
59
60
|
type: 'section',
|
|
60
61
|
text: {
|
|
61
62
|
type: 'mrkdwn',
|
|
62
|
-
text: `${emoji} *#${task.id} ${task.title}*\n${priority} · ${assignee}`,
|
|
63
|
+
text: `${emoji} *#${task.id} ${escapeSlackMrkdwn(task.title)}*\n${priority} · ${assignee}`,
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
blocks.push(section);
|
|
@@ -91,20 +92,20 @@ export function formatTaskDetail(task) {
|
|
|
91
92
|
const title = rawTitle.length > 150 ? rawTitle.slice(0, 147) + '...' : rawTitle;
|
|
92
93
|
const header = {
|
|
93
94
|
type: 'header',
|
|
94
|
-
text: { type: 'plain_text', text: title, emoji: true },
|
|
95
|
+
text: { type: 'plain_text', text: escapeSlackMrkdwn(title), emoji: true },
|
|
95
96
|
};
|
|
96
97
|
blocks.push(header);
|
|
97
98
|
// Fields section — 2-column key/value pairs (max 10 items in Slack API)
|
|
98
99
|
const fields = [
|
|
99
100
|
{ type: 'mrkdwn', text: `*Status*\n${STATUS_EMOJI[task.status] ?? '❓'} ${task.status}` },
|
|
100
101
|
{ type: 'mrkdwn', text: `*Priority*\n${PRIORITY_INDICATOR[task.priority] ?? task.priority}` },
|
|
101
|
-
{ type: 'mrkdwn', text: `*Assignee*\n${task.assignee
|
|
102
|
-
{ type: 'mrkdwn', text: `*Due Date*\n${task.due_date
|
|
102
|
+
{ type: 'mrkdwn', text: `*Assignee*\n${escapeSlackMrkdwn(task.assignee) || '_unassigned_'}` },
|
|
103
|
+
{ type: 'mrkdwn', text: `*Due Date*\n${escapeSlackMrkdwn(task.due_date) || '_none_'}` },
|
|
103
104
|
{ type: 'mrkdwn', text: `*Project*\n#${task.project_id}` },
|
|
104
|
-
{ type: 'mrkdwn', text: `*Created by*\n${task.created_by}` },
|
|
105
|
+
{ type: 'mrkdwn', text: `*Created by*\n${escapeSlackMrkdwn(task.created_by)}` },
|
|
105
106
|
];
|
|
106
107
|
if (task.tags.length > 0) {
|
|
107
|
-
fields.push({ type: 'mrkdwn', text: `*Tags*\n${task.tags.join(', ')}` });
|
|
108
|
+
fields.push({ type: 'mrkdwn', text: `*Tags*\n${escapeSlackMrkdwn(task.tags.join(', '))}` });
|
|
108
109
|
}
|
|
109
110
|
const fieldsSection = { type: 'section', fields };
|
|
110
111
|
blocks.push(fieldsSection);
|
|
@@ -114,7 +115,7 @@ export function formatTaskDetail(task) {
|
|
|
114
115
|
blocks.push(divider);
|
|
115
116
|
const descSection = {
|
|
116
117
|
type: 'section',
|
|
117
|
-
text: { type: 'mrkdwn', text: task.description.slice(0, 3000) },
|
|
118
|
+
text: { type: 'mrkdwn', text: escapeSlackMrkdwn(task.description.slice(0, 3000)) },
|
|
118
119
|
};
|
|
119
120
|
blocks.push(descSection);
|
|
120
121
|
}
|
|
@@ -134,12 +135,12 @@ export function formatTaskNotification(event, projectName) {
|
|
|
134
135
|
const emoji = STATUS_EMOJI[task.status] ?? '❓';
|
|
135
136
|
const actor = metadata.actor ?? 'system';
|
|
136
137
|
const label = EVENT_LABELS[eventType] ?? eventType;
|
|
137
|
-
const assignee = task.assignee ? `@${task.assignee}` : '_unassigned_';
|
|
138
|
+
const assignee = task.assignee ? `@${escapeSlackMrkdwn(task.assignee)}` : '_unassigned_';
|
|
138
139
|
const priority = PRIORITY_INDICATOR[task.priority] ?? task.priority;
|
|
139
140
|
const text = [
|
|
140
141
|
`*${label}* by ${actor}`,
|
|
141
|
-
`${emoji} *#${task.id} ${task.title}*`,
|
|
142
|
-
...(projectName ? [`_${projectName}_`] : []),
|
|
142
|
+
`${emoji} *#${task.id} ${escapeSlackMrkdwn(task.title)}*`,
|
|
143
|
+
...(projectName ? [`_${escapeSlackMrkdwn(projectName)}_`] : []),
|
|
143
144
|
`${priority} · ${assignee}`,
|
|
144
145
|
`\`/tasks show ${task.id}\``,
|
|
145
146
|
].join('\n');
|
package/docs/INTERFACES.md
CHANGED
|
@@ -320,7 +320,14 @@ filters. MCP list tools wrap the same envelope under a domain key
|
|
|
320
320
|
|
|
321
321
|
Global rate limit (`@fastify/rate-limit`) applies to every REST route except
|
|
322
322
|
`/health*`; defaults are 1000 req/min, tunable via `RATE_LIMIT_MAX` and
|
|
323
|
-
`RATE_LIMIT_TIME_WINDOW`.
|
|
323
|
+
`RATE_LIMIT_TIME_WINDOW`. Sensitive auth/device routes (`/auth/login`,
|
|
324
|
+
`/auth/callback`, `/auth/device/code`, `/auth/device/verify`,
|
|
325
|
+
`/auth/device/token`) carry tighter per-route budgets (`RATE_LIMIT_AUTH_MAX`,
|
|
326
|
+
default 10; `RATE_LIMIT_DEVICE_TOKEN_MAX`, default 30). Client identity is
|
|
327
|
+
keyed on the authenticated principal when present, else `request.ip`; behind a
|
|
328
|
+
reverse proxy set `TRUST_PROXY` (default off) so `request.ip` derives from
|
|
329
|
+
`X-Forwarded-For` — with it off, a spoofed forwarded header cannot change a
|
|
330
|
+
client's bucket. Response shape on breach:
|
|
324
331
|
`{ error: 'TOO_MANY_REQUESTS', message }` with HTTP 429.
|
|
325
332
|
|
|
326
333
|
## Error handling cheatsheet
|
package/docs/SETUP.md
CHANGED
|
@@ -391,46 +391,47 @@ visiting `/auth/login`; otherwise the session cookie is dropped and login
|
|
|
391
391
|
silently loops back to the login page. In `development`/`test` the cookie
|
|
392
392
|
is non-`secure`, so plain `http://localhost` works.
|
|
393
393
|
|
|
394
|
+
### 3a. Device-flow verification origin & trust boundary
|
|
395
|
+
|
|
396
|
+
The RFC 8628 **device flow** returns a `verification_uri` derived **per-request**
|
|
397
|
+
from the address the client connected to (`Host` / `X-Forwarded-{Host,Proto}`;
|
|
398
|
+
see `resolveVerificationOrigin`) so a LAN/remote client gets a routable URL not
|
|
399
|
+
`localhost`. It is **not** host-header injection — the URI is returned only to
|
|
400
|
+
the requesting client, so a spoofed `Host` only misdirects the spoofer; the
|
|
401
|
+
server trusts its front-proxy to set `X-Forwarded-*` honestly (the same trust
|
|
402
|
+
`TRUST_PROXY` extends to rate-limit client identity). To pin the boundary, set
|
|
403
|
+
`DEVICE_FLOW_TRUSTED_HOSTS` (comma-separated hostname allowlist): a `Host` not on
|
|
404
|
+
it falls back to the `OIDC_REDIRECT_URI` origin; unset = all.
|
|
405
|
+
|
|
394
406
|
### 4. (Optional) `LEGACY_AUTH_SUNSET_DATE`
|
|
395
407
|
|
|
396
|
-
[NOTE] The legacy `X-API-Key` auth path has been **removed**
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
backward compatibility but has no runtime effect; new deployments can omit it.
|
|
400
|
-
|
|
401
|
-
```bash
|
|
402
|
-
# Still accepted (YYYY-MM-DD) but inert — retained for backward compatibility.
|
|
403
|
-
LEGACY_AUTH_SUNSET_DATE=2026-12-31
|
|
404
|
-
```
|
|
408
|
+
[NOTE] The legacy `X-API-Key` auth path has been **removed** (rejected with 401),
|
|
409
|
+
so this var is now inert — still parsed (`YYYY-MM-DD`) for backward
|
|
410
|
+
compatibility but with no runtime effect. New deployments can omit it.
|
|
405
411
|
|
|
406
412
|
### 5. Verify the OIDC flow
|
|
407
413
|
|
|
408
414
|
1. Restart the server (`npm run dev` locally).
|
|
409
415
|
2. In a browser, visit `http://localhost:3000/auth/login`.
|
|
410
|
-
3.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
4. From `/me` you can mint a new PAT (the value is shown **once**, copy
|
|
414
|
-
it then) or revoke an existing one.
|
|
416
|
+
3. Complete Google consent and land on `/me` (shows your email, display
|
|
417
|
+
name, and PATs).
|
|
418
|
+
4. From `/me` mint a new PAT (value shown **once**, copy it then) or revoke one.
|
|
415
419
|
|
|
416
420
|
### 6. Bootstrap a PAT without a browser (servers, CI, headless agents)
|
|
417
421
|
|
|
418
|
-
For deployments where no browser is available, mint the first PAT
|
|
419
|
-
|
|
422
|
+
For deployments where no browser is available, mint the first PAT directly
|
|
423
|
+
against the SQLite database (`--user` = numeric id, email, or legacy
|
|
424
|
+
display_name; `--name` = required label):
|
|
420
425
|
|
|
421
426
|
```bash
|
|
422
|
-
# Adds a row to api_tokens for the named user.
|
|
423
|
-
# --user accepts a numeric id, email (case-insensitive), or legacy display_name.
|
|
424
|
-
# --name is a required human-readable label for the token.
|
|
425
427
|
node dist/cli/bin/tasks.js db mint-token --user you@example.com --name my-laptop
|
|
426
428
|
```
|
|
427
429
|
|
|
428
430
|
The command prints the raw PAT to stdout once. Use it as the
|
|
429
|
-
`Authorization: Bearer wft_pat_<…>` value on subsequent requests, or as
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
**Authentication Architecture** for the full chain.
|
|
431
|
+
`Authorization: Bearer wft_pat_<…>` value on subsequent requests, or as the
|
|
432
|
+
`WFT_API_KEY` env var in MCP/CLI clients (the REST client auto-switches to
|
|
433
|
+
`Authorization: Bearer …` for `wft_pat_` values). See [`SECURITY.md`](../SECURITY.md)
|
|
434
|
+
→ **Authentication Architecture** for the full chain.
|
|
434
435
|
|
|
435
436
|
[WARNING] **PATs have NO default expiry.** The `api_tokens.expires_at`
|
|
436
437
|
column is nullable (migration `008-identity-tables.ts`) and is written
|
|
@@ -1379,16 +1380,27 @@ variable the server reads, plus the CLI- and MCP-specific variables.
|
|
|
1379
1380
|
| `SSE_MAX_CONNECTIONS_PER_KEY` | no | `4` | Per-credential (PAT) cap on concurrent SSE connections. 429 with `Retry-After` when exceeded. |
|
|
1380
1381
|
| `SSE_MAX_CONNECTIONS_PER_IP` | no | `8` | Per-IP cap on concurrent SSE connections. |
|
|
1381
1382
|
| `SSE_MAX_CONNECTIONS` | no | `200` | Global cap on concurrent SSE connections. |
|
|
1383
|
+
| `DEVICE_FLOW_TRUSTED_HOSTS` | no | — (all hosts honored) | Optional comma-separated allowlist of hostnames the device-flow `verification_uri` may be built from (`host` or `host:port`; port ignored in match). When set, a request whose `Host`/`X-Forwarded-Host` is not on the list falls back to the configured origin. See [§3a](#3a-device-flow-verification-origin--trust-boundary). |
|
|
1382
1384
|
| `SLACK_BOT_TOKEN` | conditional | — | Slack bot token (`xoxb-…`). Required if any Slack var is set; refused alone (see [`docs/SLACK.md`](SLACK.md)). |
|
|
1383
1385
|
| `SLACK_APP_TOKEN` | conditional | — | Slack app-level token (`xapp-…`) for Socket Mode. Must be set together with `SLACK_BOT_TOKEN` or neither. |
|
|
1384
1386
|
| `SLACK_SIGNING_SECRET` | conditional | — | Slack request signing secret. Required when running Slack in HTTP mode; harmless in Socket Mode. |
|
|
1385
1387
|
|
|
1386
|
-
### Rate limiting (
|
|
1388
|
+
### Rate limiting (validated in `src/config/env.ts`)
|
|
1389
|
+
|
|
1390
|
+
The global limiter keys on the authenticated principal (PAT token id, else user
|
|
1391
|
+
id), falling back to `request.ip`; the five sensitive auth/device routes get a
|
|
1392
|
+
tighter per-route budget. `TRUST_PROXY` (default off — see [§3a](#3a-device-flow-verification-origin--trust-boundary))
|
|
1393
|
+
makes `request.ip` resolve from `X-Forwarded-For`; off, a spoofed header cannot
|
|
1394
|
+
move a bucket; behind a proxy, enable it or all clients share one IP bucket.
|
|
1387
1395
|
|
|
1388
1396
|
| Variable | Required | Default | Description |
|
|
1389
1397
|
|----------|----------|---------|-------------|
|
|
1390
|
-
| `RATE_LIMIT_MAX` | no | `1000` |
|
|
1391
|
-
| `RATE_LIMIT_TIME_WINDOW` | no | `1 minute` |
|
|
1398
|
+
| `RATE_LIMIT_MAX` | no | `1000` | Global max requests/window. `/health` is allow-listed. |
|
|
1399
|
+
| `RATE_LIMIT_TIME_WINDOW` | no | `1 minute` | Global window string. |
|
|
1400
|
+
| `RATE_LIMIT_AUTH_MAX` | no | `10` | Per-route max for login, callback, device/code, device/verify. |
|
|
1401
|
+
| `RATE_LIMIT_AUTH_TIME_WINDOW` | no | `1 minute` | Window for the per-route auth limits (incl. device/token). |
|
|
1402
|
+
| `RATE_LIMIT_DEVICE_TOKEN_MAX` | no | `30` | Per-route max for `/auth/device/token` (CLI polls it — looser). |
|
|
1403
|
+
| `TRUST_PROXY` | no | `false` | `false`/unset = ignore forwarded headers; `true` = trust all hops; integer = trust N hops; `ip,cidr,…` = trust only those proxy IPs/CIDRs. |
|
|
1392
1404
|
|
|
1393
1405
|
### Model catalog (read directly in `src/index.ts`)
|
|
1394
1406
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wood-fired-tasks",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Network-wide task tracking system for Wood Fired Games",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"task-tracker",
|
|
@@ -122,11 +122,11 @@
|
|
|
122
122
|
"@fastify/cookie": "^11.0.2",
|
|
123
123
|
"@fastify/formbody": "^8.0.2",
|
|
124
124
|
"@fastify/helmet": "^13.0.2",
|
|
125
|
-
"@fastify/rate-limit": "^
|
|
125
|
+
"@fastify/rate-limit": "^11.0.0",
|
|
126
126
|
"@fastify/secure-session": "^8.3.0",
|
|
127
127
|
"@fastify/sse": "^0.4.0",
|
|
128
128
|
"@fastify/swagger": "^9.7.0",
|
|
129
|
-
"@fastify/swagger-ui": "^
|
|
129
|
+
"@fastify/swagger-ui": "^6.0.0",
|
|
130
130
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
131
131
|
"@slack/bolt": "^4.6.0",
|
|
132
132
|
"better-sqlite3": "^12.6.2",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"dotenv": "^17.3.1",
|
|
137
137
|
"env-paths": "^4.0.0",
|
|
138
138
|
"fastify": "^5.8.5",
|
|
139
|
-
"fastify-plugin": "^
|
|
139
|
+
"fastify-plugin": "^6.0.0",
|
|
140
140
|
"fastify-type-provider-zod": "^6.1.0",
|
|
141
141
|
"openid-client": "^6.8.4",
|
|
142
142
|
"pino": "^10.3.1",
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"qs": "^6.15.2"
|
|
151
151
|
},
|
|
152
152
|
"devDependencies": {
|
|
153
|
-
"@biomejs/biome": "2.
|
|
153
|
+
"@biomejs/biome": "2.5.0",
|
|
154
154
|
"@fast-check/vitest": "^0.4.1",
|
|
155
155
|
"@slack/types": "^2.20.0",
|
|
156
156
|
"@slack/web-api": "^7.15.2",
|
|
@@ -68,6 +68,8 @@ wake their targets. Dedup: `sweep:<rule>:<floor(now / idempotency_window)>`,
|
|
|
68
68
|
so a restart within the same window dispatches nothing. See
|
|
69
69
|
[docs/event-router-design.md §Cold-start sweep](../../docs/event-router-design.md).
|
|
70
70
|
|
|
71
|
+
**Periodic re-sweep** (opt-in, default off): `sweep_interval_s: <seconds>` (per rule or under `defaults:`) re-runs that sweep on a timer — no restart, no SSE event — re-kicking sessions that went idle mid-run; same per-window dedup.
|
|
72
|
+
|
|
71
73
|
- **Recipes** (full walkthroughs): [docs/automation-recipes/](https://github.com/Wood-Fired-Games/wood-fired-tasks/tree/main/docs/automation-recipes)
|
|
72
74
|
- [claude-routines.md](../../docs/automation-recipes/claude-routines.md) — dispatch a routine on task close
|
|
73
75
|
- [persistent-agent-sessions.md](../../docs/automation-recipes/persistent-agent-sessions.md) — drive a long-lived agent session
|
|
@@ -181,6 +181,7 @@ export declare const RuleSchema: z.ZodObject<{
|
|
|
181
181
|
max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
|
|
182
182
|
max_retries: z.ZodOptional<z.ZodNumber>;
|
|
183
183
|
sweep_on_start: z.ZodOptional<z.ZodBoolean>;
|
|
184
|
+
sweep_interval_s: z.ZodOptional<z.ZodNumber>;
|
|
184
185
|
}, z.core.$strict>;
|
|
185
186
|
/** `defaults:` block — optional, all sub-keys optional with documented values. */
|
|
186
187
|
export declare const DefaultsSchema: z.ZodObject<{
|
|
@@ -189,6 +190,7 @@ export declare const DefaultsSchema: z.ZodObject<{
|
|
|
189
190
|
max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
|
|
190
191
|
max_retries: z.ZodOptional<z.ZodNumber>;
|
|
191
192
|
sweep_on_start: z.ZodOptional<z.ZodBoolean>;
|
|
193
|
+
sweep_interval_s: z.ZodOptional<z.ZodNumber>;
|
|
192
194
|
}, z.core.$strict>;
|
|
193
195
|
/** Top-level triggers.yaml schema. */
|
|
194
196
|
export declare const TriggersConfigSchema: z.ZodObject<{
|
|
@@ -199,6 +201,7 @@ export declare const TriggersConfigSchema: z.ZodObject<{
|
|
|
199
201
|
max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
|
|
200
202
|
max_retries: z.ZodOptional<z.ZodNumber>;
|
|
201
203
|
sweep_on_start: z.ZodOptional<z.ZodBoolean>;
|
|
204
|
+
sweep_interval_s: z.ZodOptional<z.ZodNumber>;
|
|
202
205
|
}, z.core.$strict>>;
|
|
203
206
|
rules: z.ZodArray<z.ZodObject<{
|
|
204
207
|
name: z.ZodString;
|
|
@@ -280,6 +283,7 @@ export declare const TriggersConfigSchema: z.ZodObject<{
|
|
|
280
283
|
max_dispatches_per_minute: z.ZodOptional<z.ZodNumber>;
|
|
281
284
|
max_retries: z.ZodOptional<z.ZodNumber>;
|
|
282
285
|
sweep_on_start: z.ZodOptional<z.ZodBoolean>;
|
|
286
|
+
sweep_interval_s: z.ZodOptional<z.ZodNumber>;
|
|
283
287
|
}, z.core.$strict>>;
|
|
284
288
|
}, z.core.$strict>;
|
|
285
289
|
export type TriggersConfig = z.infer<typeof TriggersConfigSchema>;
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* any specific receiver. (See docs/event-router-design.md §"Vendor-neutral
|
|
34
34
|
* guardrails".)
|
|
35
35
|
*/
|
|
36
|
+
import { lstatSync, realpathSync, statSync } from 'node:fs';
|
|
36
37
|
import { readFile } from 'node:fs/promises';
|
|
37
38
|
import { parse as parseYaml } from 'yaml';
|
|
38
39
|
import { z } from 'zod';
|
|
@@ -101,6 +102,16 @@ export const RuleSchema = z
|
|
|
101
102
|
* handler path. Default OFF: absent = no startup behavior change.
|
|
102
103
|
*/
|
|
103
104
|
sweep_on_start: z.boolean().optional(),
|
|
105
|
+
/**
|
|
106
|
+
* Periodic re-sweep interval in seconds (task #1035). When set (here or
|
|
107
|
+
* in `defaults:`), the daemon re-runs the SAME sweep this rule's
|
|
108
|
+
* `sweep_on_start` path uses, every `sweep_interval_s` seconds, with NO
|
|
109
|
+
* router restart and NO new SSE event — closing the steady-state gap
|
|
110
|
+
* where a session goes idle while the router is healthy. Bucket
|
|
111
|
+
* idempotency (`sweep:<rule>:<floor(now / idempotency_window)>`) caps it
|
|
112
|
+
* at one kick per window. Default OFF: absent (or 0) = no timer.
|
|
113
|
+
*/
|
|
114
|
+
sweep_interval_s: z.number().int().positive().optional(),
|
|
104
115
|
})
|
|
105
116
|
.strict();
|
|
106
117
|
/** `defaults:` block — optional, all sub-keys optional with documented values. */
|
|
@@ -116,6 +127,8 @@ export const DefaultsSchema = z
|
|
|
116
127
|
max_retries: z.number().int().nonnegative().optional(),
|
|
117
128
|
/** Global cold-start sweep opt-in; per-rule `sweep_on_start` overrides. Default false. */
|
|
118
129
|
sweep_on_start: z.boolean().optional(),
|
|
130
|
+
/** Global periodic re-sweep interval (s); per-rule `sweep_interval_s` overrides. Default off. */
|
|
131
|
+
sweep_interval_s: z.number().int().positive().optional(),
|
|
119
132
|
})
|
|
120
133
|
.strict();
|
|
121
134
|
/** Top-level triggers.yaml schema. */
|
|
@@ -213,6 +226,58 @@ function checkStringForTemplatingViolations(s, path, issues) {
|
|
|
213
226
|
'at bare JSON value positions only)',
|
|
214
227
|
});
|
|
215
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Enforce the documented `triggers.yaml` trust posture at startup: the file
|
|
231
|
+
* that drives shell_exec / webhook_post / create_task / agent dispatch MUST be
|
|
232
|
+
* owner-only (mode `0600`) and owned by the router user, because edit access is
|
|
233
|
+
* equivalent to arbitrary code execution as the router. See
|
|
234
|
+
* docs/event-router-design.md §"`triggers.yaml` trust posture".
|
|
235
|
+
*
|
|
236
|
+
* Mirrors the stat/owner/symlink hardening already used by
|
|
237
|
+
* `handlers/agent-session-dispatch.ts:resolveAdapter`, applied to a SINGLE file:
|
|
238
|
+
* - The path is resolved through {@link realpathSync} and the REAL file is
|
|
239
|
+
* stat'd, so a symlink cannot point at a 0600 file while the link target is
|
|
240
|
+
* attacker-writable.
|
|
241
|
+
* - Mode is rejected if ANY group/other bit is set — `(mode & 0o077) !== 0`.
|
|
242
|
+
* This is the strict `0600` reading promised by the design doc, so
|
|
243
|
+
* 0640/0644/0660/0666 all fail while 0600 passes.
|
|
244
|
+
* - Owner is rejected (POSIX only) when `process.getuid()` is defined and the
|
|
245
|
+
* real file's `uid` differs. `getuid` is undefined on Windows, where mode
|
|
246
|
+
* and uid bits are not meaningful — there, enforcement is skipped and file
|
|
247
|
+
* permissions are a deployment requirement.
|
|
248
|
+
*
|
|
249
|
+
* Returns `null` when the file passes (or the platform is non-POSIX); otherwise
|
|
250
|
+
* a single ` - <file>: <message>` error string in the established result shape.
|
|
251
|
+
*/
|
|
252
|
+
function checkTriggersFilePermissions(filePath) {
|
|
253
|
+
const routerUid = process.getuid?.();
|
|
254
|
+
// Non-POSIX (Windows): mode/uid bits are unreliable. Skip enforcement — file
|
|
255
|
+
// permissions are a documented deployment requirement on that platform.
|
|
256
|
+
if (routerUid === undefined) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
let st;
|
|
260
|
+
try {
|
|
261
|
+
// Resolve symlinks so the bytes actually read are the ones we vet. lstat
|
|
262
|
+
// first to surface a dangling-symlink/missing-target clearly.
|
|
263
|
+
lstatSync(filePath);
|
|
264
|
+
const realPath = realpathSync(filePath);
|
|
265
|
+
st = statSync(realPath);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
269
|
+
return ` - ${filePath}: cannot stat for permission check: ${message}`;
|
|
270
|
+
}
|
|
271
|
+
if ((st.mode & 0o077) !== 0) {
|
|
272
|
+
return ` - ${filePath}: insecure permissions (mode ${(st.mode & 0o777)
|
|
273
|
+
.toString(8)
|
|
274
|
+
.padStart(4, '0')}); must be mode 0600 / not accessible to group or other`;
|
|
275
|
+
}
|
|
276
|
+
if (st.uid !== routerUid) {
|
|
277
|
+
return ` - ${filePath}: not owned by the router user (file uid ${st.uid}, router uid ${routerUid})`;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
216
281
|
/**
|
|
217
282
|
* Read a `triggers.yaml` file from disk, parse it, run the zod schema, and
|
|
218
283
|
* then run the templating-safety pass. On success returns the typed config;
|
|
@@ -229,6 +294,13 @@ export async function loadAndValidateTriggers(filePath) {
|
|
|
229
294
|
const message = err instanceof Error ? err.message : String(err);
|
|
230
295
|
return { ok: false, errors: [` - <file>: cannot read ${filePath}: ${message}`] };
|
|
231
296
|
}
|
|
297
|
+
// Trust gate: the file just read must be owner-only and owned by the router
|
|
298
|
+
// user (POSIX). Reject before parsing so an attacker-writable config never
|
|
299
|
+
// reaches the handler dispatch surface. See checkTriggersFilePermissions.
|
|
300
|
+
const permError = checkTriggersFilePermissions(filePath);
|
|
301
|
+
if (permError !== null) {
|
|
302
|
+
return { ok: false, errors: [permError] };
|
|
303
|
+
}
|
|
232
304
|
let parsed;
|
|
233
305
|
try {
|
|
234
306
|
parsed = parseYaml(raw);
|
|
@@ -74,6 +74,29 @@ export type HandlerRegistry = Record<TriggersRule['do'], Handler>;
|
|
|
74
74
|
* this seam so tests can hand it a fake generator they fully control.
|
|
75
75
|
*/
|
|
76
76
|
export type SSESourceFactory = (signal: AbortSignal) => AsyncGenerator<SSEEvent, ExitCode>;
|
|
77
|
+
/** Opaque handle returned by an {@link IntervalScheduler}'s `set`. */
|
|
78
|
+
export type IntervalHandle = unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Minimal interval-scheduler seam (task #1035). The periodic re-sweep timer
|
|
81
|
+
* fires through this rather than calling `setInterval` directly, so tests can
|
|
82
|
+
* inject a fake that captures the callback and drive it deterministically
|
|
83
|
+
* alongside the injected `now` clock (the sweep's bucket identity is derived
|
|
84
|
+
* from `now`, so the two must advance together under test control). Production
|
|
85
|
+
* defaults to {@link DEFAULT_INTERVAL_SCHEDULER} (real `setInterval`, unref'd
|
|
86
|
+
* so the timer never independently holds the event loop open).
|
|
87
|
+
*/
|
|
88
|
+
export interface IntervalScheduler {
|
|
89
|
+
/** Schedule `cb` to run every `ms`; return an opaque cancel handle. */
|
|
90
|
+
set(cb: () => void, ms: number): IntervalHandle;
|
|
91
|
+
/** Cancel a previously scheduled interval. */
|
|
92
|
+
clear(handle: IntervalHandle): void;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Production interval scheduler: real `setInterval`/`clearInterval`. The
|
|
96
|
+
* timer is `unref`'d so it never keeps the process alive on its own — the SSE
|
|
97
|
+
* consume loop owns process liveness; the periodic sweep is a passenger.
|
|
98
|
+
*/
|
|
99
|
+
export declare const DEFAULT_INTERVAL_SCHEDULER: IntervalScheduler;
|
|
77
100
|
/** Minimal structured-logger surface the daemon needs (pino-compatible). */
|
|
78
101
|
export interface DaemonLogger extends HandlerLogger {
|
|
79
102
|
info(obj: Record<string, unknown>, msg?: string): void;
|
|
@@ -118,6 +141,13 @@ export interface DaemonDeps {
|
|
|
118
141
|
env?: NodeJS.ProcessEnv;
|
|
119
142
|
/** Injected clock for debounce/rate-limit math in tests. Default: Date.now. */
|
|
120
143
|
now?: () => number;
|
|
144
|
+
/**
|
|
145
|
+
* Injected interval scheduler for the periodic re-sweep timer (task #1035).
|
|
146
|
+
* Default: {@link DEFAULT_INTERVAL_SCHEDULER} (real, unref'd `setInterval`).
|
|
147
|
+
* Tests inject a fake that captures the tick callback so the periodic sweep
|
|
148
|
+
* can be driven deterministically together with the injected `now`.
|
|
149
|
+
*/
|
|
150
|
+
intervalScheduler?: IntervalScheduler;
|
|
121
151
|
/**
|
|
122
152
|
* Optional Prometheus metrics registry. ADDITIVE (task #434): when present,
|
|
123
153
|
* the daemon increments it at the pipeline points it already tracks (events
|
|
@@ -189,6 +219,8 @@ export declare class WftRouterDaemon {
|
|
|
189
219
|
private readonly metrics?;
|
|
190
220
|
/** Injected clock (shared with debounce/rate-limit; drives sweep buckets). */
|
|
191
221
|
private readonly now;
|
|
222
|
+
/** Injected interval scheduler for the periodic re-sweep timer (task #1035). */
|
|
223
|
+
private readonly intervalScheduler;
|
|
192
224
|
private phase;
|
|
193
225
|
private readonly abortController;
|
|
194
226
|
/** The background consume loop; awaited by `stop()` so drain is ordered. */
|
|
@@ -203,6 +235,8 @@ export declare class WftRouterDaemon {
|
|
|
203
235
|
private exitCode;
|
|
204
236
|
/** The one-shot cold-start sweep (task #1005); null when no rule opted in. */
|
|
205
237
|
private sweepPromise;
|
|
238
|
+
/** Live periodic re-sweep timer handles (task #1035); cleared in `stop()`. */
|
|
239
|
+
private readonly intervalHandles;
|
|
206
240
|
constructor(deps: DaemonDeps);
|
|
207
241
|
/**
|
|
208
242
|
* Begin consuming the SSE generator in a background loop. Returns
|
|
@@ -217,6 +251,14 @@ export declare class WftRouterDaemon {
|
|
|
217
251
|
* can deterministically observe "sweep finished" without racing `stop()`.
|
|
218
252
|
*/
|
|
219
253
|
waitForSweep(): Promise<void>;
|
|
254
|
+
/**
|
|
255
|
+
* Await every currently in-flight dispatch fan-out + sweep tick (task
|
|
256
|
+
* #1035). Exposed so tests driving the periodic timer can deterministically
|
|
257
|
+
* observe a tick's sweep settling without calling `stop()` (which would
|
|
258
|
+
* tear the timer down). Snapshots the set so work scheduled AFTER the call
|
|
259
|
+
* does not extend the wait.
|
|
260
|
+
*/
|
|
261
|
+
settle(): Promise<void>;
|
|
220
262
|
/**
|
|
221
263
|
* Abort the SSE generator, then drain all in-flight dispatch fan-outs and
|
|
222
264
|
* any pending debounced buckets. Idempotent — a second call returns the
|
|
@@ -258,6 +300,16 @@ export declare class WftRouterDaemon {
|
|
|
258
300
|
* down the live SSE pipeline.
|
|
259
301
|
*/
|
|
260
302
|
private runStartupSweep;
|
|
303
|
+
/**
|
|
304
|
+
* Periodic re-sweep (task #1035): schedule a timer that re-runs THIS rule's
|
|
305
|
+
* sweep every `intervalMs`. The tick runs `sweepRule` — the exact same
|
|
306
|
+
* query → predicate → at-most-one-dispatch path the cold-start sweep uses —
|
|
307
|
+
* so the deterministic `sweep:<rule>:<bucket>` identity + idempotency claim
|
|
308
|
+
* cap it at one kick per window (AC #1/#2). A tick error is isolated: it
|
|
309
|
+
* logs a WARN and the timer keeps firing (AC #4). Each tick's in-flight
|
|
310
|
+
* work is `track`ed so `stop()` drains it (AC #5).
|
|
311
|
+
*/
|
|
312
|
+
private startPeriodicSweep;
|
|
261
313
|
/** Sweep ONE rule: query, predicate-match, then dispatch at most once. */
|
|
262
314
|
private sweepRule;
|
|
263
315
|
/**
|
|
@@ -60,6 +60,23 @@ import { findFirstMatchingOpenTask, sweepEventId } from './dispatch/startup-swee
|
|
|
60
60
|
import { agentSessionDispatch, createTaskInProject, shellExec, webhookPost, } from './handlers/index.js';
|
|
61
61
|
import { ExitCode, isControlEvent } from './sse/index.js';
|
|
62
62
|
import { omitUndefined } from './util/omit-undefined.js';
|
|
63
|
+
/**
|
|
64
|
+
* Production interval scheduler: real `setInterval`/`clearInterval`. The
|
|
65
|
+
* timer is `unref`'d so it never keeps the process alive on its own — the SSE
|
|
66
|
+
* consume loop owns process liveness; the periodic sweep is a passenger.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_INTERVAL_SCHEDULER = {
|
|
69
|
+
set(cb, ms) {
|
|
70
|
+
const handle = setInterval(cb, ms);
|
|
71
|
+
if (typeof handle.unref === 'function') {
|
|
72
|
+
handle.unref();
|
|
73
|
+
}
|
|
74
|
+
return handle;
|
|
75
|
+
},
|
|
76
|
+
clear(handle) {
|
|
77
|
+
clearInterval(handle);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
63
80
|
/**
|
|
64
81
|
* The default production handler registry: the four real handlers keyed by
|
|
65
82
|
* their `do:` name. Tests override this with recording fakes.
|
|
@@ -155,6 +172,8 @@ export class WftRouterDaemon {
|
|
|
155
172
|
metrics;
|
|
156
173
|
/** Injected clock (shared with debounce/rate-limit; drives sweep buckets). */
|
|
157
174
|
now;
|
|
175
|
+
/** Injected interval scheduler for the periodic re-sweep timer (task #1035). */
|
|
176
|
+
intervalScheduler;
|
|
158
177
|
phase = 'idle';
|
|
159
178
|
abortController = new AbortController();
|
|
160
179
|
/** The background consume loop; awaited by `stop()` so drain is ordered. */
|
|
@@ -169,6 +188,8 @@ export class WftRouterDaemon {
|
|
|
169
188
|
exitCode = ExitCode.CleanShutdown;
|
|
170
189
|
/** The one-shot cold-start sweep (task #1005); null when no rule opted in. */
|
|
171
190
|
sweepPromise = null;
|
|
191
|
+
/** Live periodic re-sweep timer handles (task #1035); cleared in `stop()`. */
|
|
192
|
+
intervalHandles = [];
|
|
172
193
|
constructor(deps) {
|
|
173
194
|
this.config = deps.config;
|
|
174
195
|
this.store = deps.store;
|
|
@@ -194,6 +215,7 @@ export class WftRouterDaemon {
|
|
|
194
215
|
}
|
|
195
216
|
const now = deps.now ?? Date.now;
|
|
196
217
|
this.now = now;
|
|
218
|
+
this.intervalScheduler = deps.intervalScheduler ?? DEFAULT_INTERVAL_SCHEDULER;
|
|
197
219
|
this.rateLimiter =
|
|
198
220
|
deps.rateLimiter ??
|
|
199
221
|
new RateLimiter({
|
|
@@ -236,6 +258,19 @@ export class WftRouterDaemon {
|
|
|
236
258
|
this.sweepPromise = this.runStartupSweep(sweepRules);
|
|
237
259
|
this.track(this.sweepPromise);
|
|
238
260
|
}
|
|
261
|
+
// Periodic re-sweep (task #1035): OPT-IN timed re-run of the SAME sweep
|
|
262
|
+
// for rules with a positive `sweep_interval_s` (per-rule or via
|
|
263
|
+
// `defaults:`). Independent of `sweep_on_start` — a rule may opt into the
|
|
264
|
+
// periodic path alone. Each opted-in rule gets its OWN timer so one rule's
|
|
265
|
+
// sweep failure can never stall another's (per-rule error isolation,
|
|
266
|
+
// AC #4). Absent / non-positive interval = no timer (zero behavior change,
|
|
267
|
+
// AC #3). Cleared in `stop()`.
|
|
268
|
+
for (const rule of this.config.rules) {
|
|
269
|
+
const intervalS = rule.sweep_interval_s ?? this.config.defaults?.sweep_interval_s ?? 0;
|
|
270
|
+
if (intervalS > 0) {
|
|
271
|
+
this.startPeriodicSweep(rule, intervalS * 1000);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
239
274
|
}
|
|
240
275
|
/**
|
|
241
276
|
* Await the cold-start sweep's completion (resolves immediately when no
|
|
@@ -247,6 +282,16 @@ export class WftRouterDaemon {
|
|
|
247
282
|
await this.sweepPromise;
|
|
248
283
|
}
|
|
249
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Await every currently in-flight dispatch fan-out + sweep tick (task
|
|
287
|
+
* #1035). Exposed so tests driving the periodic timer can deterministically
|
|
288
|
+
* observe a tick's sweep settling without calling `stop()` (which would
|
|
289
|
+
* tear the timer down). Snapshots the set so work scheduled AFTER the call
|
|
290
|
+
* does not extend the wait.
|
|
291
|
+
*/
|
|
292
|
+
async settle() {
|
|
293
|
+
await Promise.allSettled(Array.from(this.inFlight));
|
|
294
|
+
}
|
|
250
295
|
/**
|
|
251
296
|
* Abort the SSE generator, then drain all in-flight dispatch fan-outs and
|
|
252
297
|
* any pending debounced buckets. Idempotent — a second call returns the
|
|
@@ -267,8 +312,14 @@ export class WftRouterDaemon {
|
|
|
267
312
|
return;
|
|
268
313
|
}
|
|
269
314
|
this.phase = 'stopping';
|
|
270
|
-
// 1. Stop reading new SSE events immediately
|
|
315
|
+
// 1. Stop reading new SSE events immediately, and cancel the periodic
|
|
316
|
+
// re-sweep timers so no new tick fires during drain (task #1035). Any
|
|
317
|
+
// tick already in flight is awaited at step 4 via `inFlight`.
|
|
271
318
|
this.abortController.abort();
|
|
319
|
+
for (const handle of this.intervalHandles) {
|
|
320
|
+
this.intervalScheduler.clear(handle);
|
|
321
|
+
}
|
|
322
|
+
this.intervalHandles.length = 0;
|
|
272
323
|
// 2. Flush debounced buckets so their trailing-edge dispatch fires now
|
|
273
324
|
// rather than waiting the full window on the way down.
|
|
274
325
|
await this.debouncer.flushAll();
|
|
@@ -354,6 +405,28 @@ export class WftRouterDaemon {
|
|
|
354
405
|
}
|
|
355
406
|
}
|
|
356
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Periodic re-sweep (task #1035): schedule a timer that re-runs THIS rule's
|
|
410
|
+
* sweep every `intervalMs`. The tick runs `sweepRule` — the exact same
|
|
411
|
+
* query → predicate → at-most-one-dispatch path the cold-start sweep uses —
|
|
412
|
+
* so the deterministic `sweep:<rule>:<bucket>` identity + idempotency claim
|
|
413
|
+
* cap it at one kick per window (AC #1/#2). A tick error is isolated: it
|
|
414
|
+
* logs a WARN and the timer keeps firing (AC #4). Each tick's in-flight
|
|
415
|
+
* work is `track`ed so `stop()` drains it (AC #5).
|
|
416
|
+
*/
|
|
417
|
+
startPeriodicSweep(rule, intervalMs) {
|
|
418
|
+
const handle = this.intervalScheduler.set(() => {
|
|
419
|
+
// A tick that fires mid-shutdown (timer not yet cleared) is a no-op.
|
|
420
|
+
if (this.phase !== 'running') {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const tick = this.sweepRule(rule).catch((err) => {
|
|
424
|
+
this.logger.warn({ rule_name: rule.name, error: err instanceof Error ? err.message : String(err) }, 'wft_router_periodic_sweep_failed');
|
|
425
|
+
});
|
|
426
|
+
this.track(tick);
|
|
427
|
+
}, intervalMs);
|
|
428
|
+
this.intervalHandles.push(handle);
|
|
429
|
+
}
|
|
357
430
|
/** Sweep ONE rule: query, predicate-match, then dispatch at most once. */
|
|
358
431
|
async sweepRule(rule) {
|
|
359
432
|
const match = await findFirstMatchingOpenTask(rule, {
|