@goodandready/dsh-cron 0.3.10 → 0.3.12
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/README.md +24 -0
- package/README.ru.md +19 -0
- package/README.zh.md +13 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +247 -0
- package/lib/api.js +39 -16
- package/lib/cron-tool.js +1 -1
- package/lib/http-utils.js +128 -35
- package/lib/index.js +2 -2
- package/lib/nl-schedule.js +338 -0
- package/lib/prompt-interpolation.js +76 -4
- package/lib/routes.js +27 -19
- package/lib/scheduler.js +21 -8
- package/lib/store.js +6 -0
- package/lib/task-create.js +4 -0
- package/lib/task-patch.js +12 -0
- package/lib/task-transfer.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -588,6 +588,8 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Endpoints are
|
|
|
588
588
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
|
|
589
589
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
|
|
590
590
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
|
|
591
|
+
| `POST` | `/dsh-cron/webhook/:id` | Incoming webhook trigger: trigger task with HMAC SHA-256 / Bearer token authentication, debouncing and JSON payload prompt interpolation (#348) |
|
|
592
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | External token-guarded incoming webhook trigger (#348) |
|
|
591
593
|
|
|
592
594
|
---
|
|
593
595
|
|
|
@@ -619,6 +621,28 @@ Save LLM tokens and API quotas on watcher tasks by dynamically slowing down exec
|
|
|
619
621
|
|
|
620
622
|
Connect to `GET /dsh-cron/tasks/:id/stream` using Server-Sent Events (SSE) to receive live stdout/stderr chunks and execution completion events with an automatic 500-line ring buffer.
|
|
621
623
|
|
|
624
|
+
### 🪝 Incoming HTTP Webhook Triggers (#348)
|
|
625
|
+
|
|
626
|
+
Execute tasks instantaneously on external events via incoming HTTP POST requests with enterprise-grade security and dynamic template interpolation:
|
|
627
|
+
|
|
628
|
+
- **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens passed via headers (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer <token>`) against task `webhookToken` or master `apiToken`. Query parameter token authentication (`?token=`) is strictly rejected to prevent secret leakage in proxy logs and browser history (#358). Fail-closed security rejects unconfigured webhooks.
|
|
629
|
+
- **Fail-Closed API Authentication**: All REST routes verify callers via DSH `ctx.connection.requestRejection(req)`, token verification, and loopback verification. Requests without socket/remoteAddress and without valid authentication are strictly rejected with 401/403, preventing remote unauthenticated state modification and RCE (#357).
|
|
630
|
+
- **Deep JSON Payload Interpolation**: Dynamic prompt variables resolve incoming payload fields using dot notation (`{webhook.body.repository.full_name}`, `{webhook.headers.x_github_event}`, `{webhook.query.ref}`, `{webhook.body}`).
|
|
631
|
+
- **Script Environment Injection**: Script and shell tasks automatically receive the raw request body and headers in `DSH_CRON_WEBHOOK_BODY` and `DSH_CRON_WEBHOOK_HEADERS`.
|
|
632
|
+
- **Anti-Spam Debouncing**: Configurable per-task `webhookDebounceMs` (default 500ms) guarantees protection against webhook storms and burst duplicate executions.
|
|
633
|
+
|
|
634
|
+
### 🗣️ Multi-Lingual Natural Language Scheduling (#349)
|
|
635
|
+
|
|
636
|
+
Define schedules naturally in conversation, tool calls, and API inputs in English and Russian without wrestling with cron syntax:
|
|
637
|
+
|
|
638
|
+
- **Intervals**: `"every 15 minutes"`, `"каждые 30 минут"`, `"every 2 hours"`, `"каждые 4 часа"`.
|
|
639
|
+
- **Daily & Time-of-Day**: `"every day at 9:00"`, `"каждый день в 18:30"`, `"every morning at 8am"`, `"каждый вечер в 21:00"`, `"at midnight"`, `"в полночь"`, `"at noon"`, `"в полдень"`.
|
|
640
|
+
- **Weekdays & Weekends**: `"every weekday at 9:00"`, `"по будням в 10:00"`, `"on weekends at noon"`, `"по выходным в 12:00"`.
|
|
641
|
+
- **Days of the Week**: `"every Monday at 10am"`, `"каждую пятницу в 17:00"`, `"every Tuesday and Thursday at 14:00"`.
|
|
642
|
+
- **Relative One-Shots**: `"in 15 minutes"`, `"через 2 часа"`, `"in 3 days at 10:00"`, `"через 30 минут"`.
|
|
643
|
+
|
|
644
|
+
The dedicated parser (`lib/nl-schedule.js`) translates expressions into strict Croner expressions or deterministic one-shot execution timestamps seamlessly.
|
|
645
|
+
|
|
622
646
|
---
|
|
623
647
|
|
|
624
648
|
## 🧪 Testing & Preflight Verification
|
package/README.ru.md
CHANGED
|
@@ -569,6 +569,25 @@ bash deploy.sh verify [exact-version]
|
|
|
569
569
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
|
|
570
570
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
|
|
571
571
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
|
|
572
|
+
| `POST` | `/dsh-cron/webhook/:id` | Входящий HTTP Webhook: запуск задачи по событию с проверкой HMAC SHA-256 / токена, дебаунсом и подстановкой JSON payload в промпт (#348) |
|
|
573
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | Внешний эндпоинт входящего вебхука под токеном (#348) |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
### 32. Входящие HTTP Webhook триггеры и естественно-языковой парсер расписаний (v0.3.11, #348, #349)
|
|
578
|
+
|
|
579
|
+
- **Входящие Webhook триггеры (`/dsh-cron/webhook/:id`)**: Моментальный запуск задач по внешним событиям (GitHub, Gitea, CI/CD, сторонние сервисы).
|
|
580
|
+
- **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) исключительно через заголовки (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer`), а также мастер-токена `apiToken`. Приём токенов из query-параметров (`?token=`) строго заблокирован для предотвращения утечки в логи прокси и историю браузера (#358).
|
|
581
|
+
- **Fail-closed защита API и RCE Guard (#357)**: Все маршруты плагина проверяются через DSH `ctx.connection.requestRejection(req)` для допуска браузерных сессий DSH, а также требуют валидный Bearer-токен или локальный loopback адрес. Запросы без валидной аутентификации отсекаются с 401/403 до парсинга тела, предотвращая несанкционированное создание задач и удалённое выполнение кода.
|
|
582
|
+
- **Глубокая интерполяция payload**: Шаблоны `{webhook.body.path.to.key}`, `{webhook.headers.key}`, `{webhook.query.param}` и `{webhook.body}` автоматически раскрываются в промпте задачи.
|
|
583
|
+
- **Проброс в окружение**: Скриптовым и shell-задачам передаются переменные `DSH_CRON_WEBHOOK_BODY` и `DSH_CRON_WEBHOOK_HEADERS`.
|
|
584
|
+
- **Дебаунс от штормов**: Настраиваемый `webhookDebounceMs` (по умолчанию 500 мс) защищает от дублирующих всплесков событий.
|
|
585
|
+
- **Естественно-языковой парсер расписаний (`lib/nl-schedule.js`)**: Умное распознавание расписаний на русском и английском языках в чате, туле агента и API:
|
|
586
|
+
- Интервалы: `"каждые 15 минут"`, `"every 2 hours"`, `"каждые 4 часа"`.
|
|
587
|
+
- Время суток: `"каждый день в 9:00"`, `"every morning at 8am"`, `"в полночь"`, `"в полдень"`.
|
|
588
|
+
- Будни и выходные: `"по будням в 10:00"`, `"every weekday at 9:00"`, `"по выходным в 12:00"`, `"on weekends at noon"`.
|
|
589
|
+
- Дни недели: `"каждую пятницу в 17:00"`, `"every Monday at 10am"`, `"по вторникам и четвергам в 14:00"`.
|
|
590
|
+
- Относительные отложенные задачи: `"через 15 минут"`, `"in 2 hours"`, `"через 3 дня в 10:00"`.
|
|
572
591
|
|
|
573
592
|
---
|
|
574
593
|
|
package/README.zh.md
CHANGED
|
@@ -569,6 +569,19 @@ dsh-cron:
|
|
|
569
569
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
|
|
570
570
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
|
|
571
571
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | 令牌保护的外部接口:强制执行(#54) |
|
|
572
|
+
| `POST` | `/dsh-cron/webhook/:id` | 入站 HTTP Webhook 触发器:支持 HMAC SHA-256 / 令牌鉴权、防抖与 JSON 负载提示词变量插值(#348) |
|
|
573
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | 令牌保护的入站 Webhook 外部接口(#348) |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
### 32. 入站 HTTP Webhook 与接口鉴权加固 (v0.3.12, #348, #357, #358)
|
|
578
|
+
|
|
579
|
+
- **入站 HTTP Webhook 触发器 (`/dsh-cron/webhook/:id`)**:外部事件(GitHub、Gitea、CI/CD 及第三方服务)实时触发任务执行。
|
|
580
|
+
- **安全鉴权**:支持基于任务级 `webhookSecret` 的 HMAC SHA-256 签名(`x-hub-signature-256`、`x-signature-sha256`)、任务级 `webhookToken`(仅限标头:`x-dsh-cron-webhook-token`、`x-webhook-token`、`Authorization: Bearer`)以及全局 `apiToken` 鉴权。严格禁止从 URL 查询参数(`?token=`)传递令牌以防止日志泄露(#358)。
|
|
581
|
+
- **深度 JSON 负载插值**:提示词中通过 `{webhook.body.path.to.key}`、`{webhook.headers.key}`、`{webhook.query.param}` 及 `{webhook.body}` 自动解构提取入站数据。
|
|
582
|
+
- **环境注入**:脚本与 shell 任务自动注入 `DSH_CRON_WEBHOOK_BODY` 和 `DSH_CRON_WEBHOOK_HEADERS` 环境变量。
|
|
583
|
+
- **防抖保护**:可配置 `webhookDebounceMs`(默认 500ms)有效防止 Webhook 事件风暴与瞬态重复触发。
|
|
584
|
+
- **Fail-Closed 接口鉴权与 RCE 防御(#357)**:所有 REST 端点通过 DSH `ctx.connection.requestRejection(req)` 校验会话权限,并对无有效令牌及非本地回环地址的请求实行 Fail-closed 严格拦截(401/403),杜绝未授权远程任务创建与代码执行。
|
|
572
585
|
|
|
573
586
|
---
|
|
574
587
|
|
package/lib/api-helpers.js
CHANGED
|
@@ -4,13 +4,13 @@ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
|
|
|
4
4
|
|
|
5
5
|
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
6
6
|
|
|
7
|
-
export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken }) {
|
|
7
|
+
export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken, ctx }) {
|
|
8
8
|
if (req.method !== 'POST') {
|
|
9
9
|
sendJson(res, 405, NOT_ALLOWED);
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
13
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
13
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
14
14
|
|
|
15
15
|
const task = store.get(taskId);
|
|
16
16
|
if (!task) {
|
|
@@ -25,9 +25,9 @@ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explici
|
|
|
25
25
|
sendJson(res, 200, result);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken }) {
|
|
28
|
+
export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken, ctx }) {
|
|
29
29
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
30
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
30
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
31
31
|
|
|
32
32
|
let schedule = '';
|
|
33
33
|
let timezone = '';
|
|
@@ -61,9 +61,9 @@ export async function handleSchedulePreview({ req, res, url, store, apiToken: ex
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export async function handleDryRunTask({ store, scheduler, req, res, id }) {
|
|
65
|
-
const apiToken = store?.getSettings?.()?.apiToken;
|
|
66
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
64
|
+
export async function handleDryRunTask({ store, scheduler, req, res, id, apiToken: explicitToken, ctx }) {
|
|
65
|
+
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
66
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
67
67
|
const task = store.get(id);
|
|
68
68
|
if (!task) {
|
|
69
69
|
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
@@ -78,9 +78,9 @@ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken }) {
|
|
81
|
+
export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken, ctx }) {
|
|
82
82
|
const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
|
|
83
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
83
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
84
84
|
|
|
85
85
|
if (req.method !== 'GET') {
|
|
86
86
|
sendJson(res, 405, NOT_ALLOWED);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/api-task-webhook.js — Incoming HTTP Webhook Triggers (#348).
|
|
3
|
+
*
|
|
4
|
+
* Implements POST /dsh-cron/webhook/:taskId and POST /dsh-cron/api/webhook/:taskId.
|
|
5
|
+
* Authenticates via HMAC SHA-256 signature (GitHub/Stripe style), per-task token,
|
|
6
|
+
* or master apiToken, applies debouncing, parses JSON/text payload, and triggers
|
|
7
|
+
* the task with variable interpolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
import { sendJson } from './http-utils.js';
|
|
13
|
+
|
|
14
|
+
const MAX_WEBHOOK_BYTES = 512 * 1024; // 512 KiB limit
|
|
15
|
+
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
16
|
+
const lastWebhookRuns = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string/buffer comparison.
|
|
20
|
+
*/
|
|
21
|
+
export function safeCompare(a, b) {
|
|
22
|
+
if (!a || !b) return false;
|
|
23
|
+
const bufA = Buffer.from(String(a), 'utf8');
|
|
24
|
+
const bufB = Buffer.from(String(b), 'utf8');
|
|
25
|
+
if (bufA.length !== bufB.length) return false;
|
|
26
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify HMAC SHA-256 signature from headers.
|
|
31
|
+
* Supports x-hub-signature-256 (sha256=...), x-signature-256, or x-dsh-cron-signature.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
34
|
+
if (!secret || !signatureHeader) return false;
|
|
35
|
+
const expectedHex = crypto.createHmac('sha256', secret).update(rawBuffer).digest('hex');
|
|
36
|
+
const provided = String(signatureHeader).replace(/^sha256=/i, '').trim();
|
|
37
|
+
return safeCompare(expectedHex, provided);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify token from header or Bearer auth.
|
|
42
|
+
* Query parameter authentication is strictly rejected to prevent token leakage in proxy logs and browser history (#358).
|
|
43
|
+
*/
|
|
44
|
+
export function verifyToken(expectedToken, req, url) {
|
|
45
|
+
if (!expectedToken) return false;
|
|
46
|
+
const headers = req?.headers || {};
|
|
47
|
+
const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
|
|
48
|
+
const authHeader = headers['authorization'];
|
|
49
|
+
let bearerToken = '';
|
|
50
|
+
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
|
|
51
|
+
bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
52
|
+
}
|
|
53
|
+
const candidate = headerToken || bearerToken;
|
|
54
|
+
if (!candidate) return false;
|
|
55
|
+
return safeCompare(expectedToken, candidate);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read request body with raw Buffer preserved for signature verification.
|
|
60
|
+
*/
|
|
61
|
+
export function readRawBody(req, limit = MAX_WEBHOOK_BYTES) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const fail = (err) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
reject(err);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
req.on('data', (chunk) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
76
|
+
bytes += buf.length;
|
|
77
|
+
if (bytes > limit) {
|
|
78
|
+
fail(Object.assign(new Error('Webhook payload exceeds 512 KiB limit'), { statusCode: 413 }));
|
|
79
|
+
req.destroy();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
chunks.push(buf);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
const raw = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
89
|
+
let parsed = null;
|
|
90
|
+
const text = raw.toString('utf8').trim();
|
|
91
|
+
if (text) {
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = text;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
parsed = {};
|
|
99
|
+
}
|
|
100
|
+
resolve({ raw, parsed });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
req.on('error', (err) => {
|
|
104
|
+
fail(Object.assign(err, { statusCode: 400 }));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Handle incoming webhook request for a task.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleTaskWebhookRoute({ store, scheduler, req, res, taskId: explicitTaskId }) {
|
|
113
|
+
if (req.method !== 'POST') {
|
|
114
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
119
|
+
let taskId = explicitTaskId;
|
|
120
|
+
if (!taskId) {
|
|
121
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
122
|
+
// /dsh-cron/webhook/:taskId or /dsh-cron/api/webhook/:taskId
|
|
123
|
+
const webhookIdx = parts.indexOf('webhook');
|
|
124
|
+
if (webhookIdx !== -1 && parts[webhookIdx + 1]) {
|
|
125
|
+
taskId = parts[webhookIdx + 1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!taskId) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'Task ID is required in webhook URL path' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const task = store.get(taskId);
|
|
135
|
+
if (!task) {
|
|
136
|
+
sendJson(res, 404, { ok: false, error: `Task "${taskId}" not found` });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (task.status !== 'active') {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: `Task "${taskId}" is not active (current status: ${task.status})` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isEnabled = Boolean(
|
|
146
|
+
task.webhookEnabled ||
|
|
147
|
+
(task.webhookTrigger && task.webhookTrigger.enabled)
|
|
148
|
+
);
|
|
149
|
+
if (!isEnabled) {
|
|
150
|
+
logger.warn(`[dsh-cron] Webhook received for task "${taskId}" but webhooks are not enabled for this task`);
|
|
151
|
+
sendJson(res, 403, { ok: false, error: `Webhooks are not enabled for task "${taskId}"` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Read raw body
|
|
156
|
+
let bodyData;
|
|
157
|
+
try {
|
|
158
|
+
bodyData = await readRawBody(req);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { raw, parsed } = bodyData;
|
|
165
|
+
const headers = req.headers || {};
|
|
166
|
+
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
167
|
+
|
|
168
|
+
// Authentication check (fail-closed)
|
|
169
|
+
const taskSecret = task.webhookSecret || (task.webhookTrigger && task.webhookTrigger.secret);
|
|
170
|
+
const taskToken = task.webhookToken || (task.webhookTrigger && task.webhookTrigger.token);
|
|
171
|
+
const masterToken = settings.apiToken;
|
|
172
|
+
|
|
173
|
+
let authenticated = false;
|
|
174
|
+
|
|
175
|
+
if (taskSecret) {
|
|
176
|
+
const sigHeader = headers['x-hub-signature-256'] || headers['x-signature-256'] || headers['x-dsh-cron-signature'];
|
|
177
|
+
if (sigHeader && verifyHmacSignature(raw, taskSecret, sigHeader)) {
|
|
178
|
+
authenticated = true;
|
|
179
|
+
} else {
|
|
180
|
+
logger.warn(`[dsh-cron] Invalid HMAC signature for webhook task "${taskId}"`);
|
|
181
|
+
sendJson(res, 401, { ok: false, error: 'Invalid HMAC signature' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
} else if (taskToken) {
|
|
185
|
+
if (verifyToken(taskToken, req, url)) {
|
|
186
|
+
authenticated = true;
|
|
187
|
+
} else {
|
|
188
|
+
logger.warn(`[dsh-cron] Invalid webhook token for task "${taskId}"`);
|
|
189
|
+
sendJson(res, 401, { ok: false, error: 'Invalid webhook token' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} else if (masterToken) {
|
|
193
|
+
if (verifyToken(masterToken, req, url)) {
|
|
194
|
+
authenticated = true;
|
|
195
|
+
} else {
|
|
196
|
+
logger.warn(`[dsh-cron] Invalid API token for webhook task "${taskId}"`);
|
|
197
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid token' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
logger.warn(`[dsh-cron] Webhook task "${taskId}" rejected: no secret or token configured (fail-closed)`);
|
|
202
|
+
sendJson(res, 403, { ok: false, error: 'Webhook secret or token must be configured for security' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Debounce check
|
|
207
|
+
const debounceMs = Math.max(0, Number(task.webhookDebounceMs || (task.webhookTrigger && task.webhookTrigger.debounceMs) || 0));
|
|
208
|
+
if (debounceMs > 0) {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const last = lastWebhookRuns.get(taskId) || 0;
|
|
211
|
+
if (now - last < debounceMs) {
|
|
212
|
+
logger.info(`[dsh-cron] Webhook for task "${taskId}" debounced (${now - last}ms < ${debounceMs}ms)`);
|
|
213
|
+
sendJson(res, 200, { ok: true, debounced: true, message: `Webhook debounced (${debounceMs}ms cooldown)` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
lastWebhookRuns.set(taskId, now);
|
|
217
|
+
} else {
|
|
218
|
+
lastWebhookRuns.set(taskId, Date.now());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
222
|
+
const webhookPayload = {
|
|
223
|
+
body: parsed,
|
|
224
|
+
raw: raw.toString('utf8'),
|
|
225
|
+
headers,
|
|
226
|
+
query,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
logger.info(`[dsh-cron] Webhook accepted for task "${taskId}", triggering execution`);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const runPromise = scheduler.triggerManualRun(taskId, {
|
|
233
|
+
webhook: webhookPayload,
|
|
234
|
+
triggeredBy: 'webhook',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
sendJson(res, 200, {
|
|
238
|
+
ok: true,
|
|
239
|
+
triggered: true,
|
|
240
|
+
taskId,
|
|
241
|
+
message: `Webhook received and task "${task.title}" triggered`,
|
|
242
|
+
});
|
|
243
|
+
} catch (runErr) {
|
|
244
|
+
logger.error(`[dsh-cron] Failed to trigger task "${taskId}" from webhook: ${runErr.message}`);
|
|
245
|
+
sendJson(res, 500, { ok: false, error: `Failed to trigger task: ${runErr.message}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
1
2
|
import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
3
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
3
4
|
import { logStreamer } from './log-streamer.js';
|
|
@@ -70,9 +71,11 @@ function refuseConfigOwned(res, id) {
|
|
|
70
71
|
* deeper path unrouted (#96). Exported for unit tests.
|
|
71
72
|
*/
|
|
72
73
|
export function createCronApiHandler(store, scheduler, collection) {
|
|
74
|
+
const ctx = collection?.ctx;
|
|
73
75
|
return async function handleCronApi(req, res) {
|
|
74
76
|
try {
|
|
75
77
|
const apiToken = store?.getSettings?.()?.apiToken;
|
|
78
|
+
const authOptions = { apiToken, ctx };
|
|
76
79
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
77
80
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
78
81
|
const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
|
|
@@ -83,27 +86,31 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
83
86
|
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
89
|
+
if (scope === 'webhook' && id) {
|
|
90
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: id });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
86
93
|
|
|
87
94
|
if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
|
|
88
|
-
handleHeartbeatPing({ store, req, res, taskId: id });
|
|
95
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
89
96
|
return;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
if (scope === 'schedule' && id === 'preview') {
|
|
93
|
-
await handleSchedulePreview({ req, res, url, store });
|
|
100
|
+
await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
|
|
94
101
|
return;
|
|
95
102
|
}
|
|
96
103
|
if (scope === 'schedule' && id === 'timeline-24h') {
|
|
97
|
-
await handleScheduleTimeline({ req, res, url, store, scheduler });
|
|
104
|
+
await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
|
|
98
105
|
return;
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
if (!id) {
|
|
102
|
-
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
|
|
109
|
+
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
|
|
103
110
|
return;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
113
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
107
114
|
|
|
108
115
|
// Export/import are collection operations that live under /tasks/:id/…,
|
|
109
116
|
// so they are matched before the generic id handling below.
|
|
@@ -124,7 +131,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
124
131
|
return;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken });
|
|
134
|
+
await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx });
|
|
128
135
|
} catch (err) {
|
|
129
136
|
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
130
137
|
}
|
|
@@ -133,19 +140,19 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
133
140
|
|
|
134
141
|
// ------------------------------------------------------------- collection
|
|
135
142
|
|
|
136
|
-
async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken }) {
|
|
143
|
+
async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
|
|
137
144
|
// The alias family only exists for individual tasks.
|
|
138
145
|
if (scope === 'action' || !collection) {
|
|
139
146
|
sendJson(res, 405, NOT_ALLOWED);
|
|
140
147
|
return;
|
|
141
148
|
}
|
|
142
149
|
if (req.method === 'GET') {
|
|
143
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
150
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
144
151
|
listTasks({ store, scheduler, collection, req, res, url });
|
|
145
152
|
return;
|
|
146
153
|
}
|
|
147
154
|
if (req.method === 'POST') {
|
|
148
|
-
await createOrUpdateTask({ store, scheduler, req, res, apiToken });
|
|
155
|
+
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
149
156
|
return;
|
|
150
157
|
}
|
|
151
158
|
sendJson(res, 405, NOT_ALLOWED);
|
|
@@ -182,8 +189,8 @@ function listTasks({ store, scheduler, collection, req, res, url }) {
|
|
|
182
189
|
sendJson(res, 200, payload);
|
|
183
190
|
}
|
|
184
191
|
|
|
185
|
-
async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
|
|
186
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
192
|
+
async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
|
|
193
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
187
194
|
const { body: rawBody, error } = await readBody(req, res);
|
|
188
195
|
if (error) return;
|
|
189
196
|
const existing = rawBody.id ? store.get(rawBody.id) : null;
|
|
@@ -286,14 +293,14 @@ function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
286
293
|
|
|
287
294
|
// ------------------------------------------------------------ single task
|
|
288
295
|
|
|
289
|
-
async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken }) {
|
|
296
|
+
async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken, ctx }) {
|
|
290
297
|
if (action === 'heartbeat') {
|
|
291
|
-
handleHeartbeatPing({ store, req, res, taskId: id });
|
|
298
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
292
299
|
return;
|
|
293
300
|
}
|
|
294
301
|
|
|
295
302
|
if (req.method === 'POST' && action === 'dry-run') {
|
|
296
|
-
await handleDryRunTask({ store, scheduler, req, res, id });
|
|
303
|
+
await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
|
|
297
304
|
return;
|
|
298
305
|
}
|
|
299
306
|
|
|
@@ -302,7 +309,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
302
309
|
logStreamer.handleSseRequest(id, req, res);
|
|
303
310
|
return;
|
|
304
311
|
}
|
|
305
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
312
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
306
313
|
if (action === 'archive') {
|
|
307
314
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
308
315
|
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
@@ -640,6 +647,18 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
640
647
|
targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
|
|
641
648
|
targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
|
|
642
649
|
sessionTitle: body.sessionTitle !== undefined ? String(body.sessionTitle).trim() : (current ? (current.sessionTitle || '') : ''),
|
|
650
|
+
webhookEnabled: body.webhookEnabled !== undefined
|
|
651
|
+
? Boolean(body.webhookEnabled)
|
|
652
|
+
: (body.webhookTrigger?.enabled !== undefined ? Boolean(body.webhookTrigger.enabled) : (current ? Boolean(current.webhookEnabled || current.webhookTrigger?.enabled) : false)),
|
|
653
|
+
webhookToken: body.webhookToken !== undefined
|
|
654
|
+
? String(body.webhookToken).trim()
|
|
655
|
+
: (body.webhookTrigger?.token ? String(body.webhookTrigger.token).trim() : (current ? (current.webhookToken || current.webhookTrigger?.token || '') : '')),
|
|
656
|
+
webhookSecret: body.webhookSecret !== undefined
|
|
657
|
+
? String(body.webhookSecret).trim()
|
|
658
|
+
: (body.webhookTrigger?.secret ? String(body.webhookTrigger.secret).trim() : (current ? (current.webhookSecret || current.webhookTrigger?.secret || '') : '')),
|
|
659
|
+
webhookDebounceMs: body.webhookDebounceMs !== undefined
|
|
660
|
+
? (Number(body.webhookDebounceMs) || 0)
|
|
661
|
+
: (body.webhookTrigger?.debounceMs !== undefined ? (Number(body.webhookTrigger.debounceMs) || 0) : (current ? (Number(current.webhookDebounceMs || current.webhookTrigger?.debounceMs) || 0) : 0)),
|
|
643
662
|
onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
|
|
644
663
|
onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
|
|
645
664
|
heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
|
|
@@ -665,6 +684,10 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
665
684
|
*/
|
|
666
685
|
export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
|
|
667
686
|
const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
|
|
687
|
+
if (parts[2] === 'webhook' && parts[3]) {
|
|
688
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: parts[3] });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
668
691
|
if (parts[2] === 'heartbeat' && parts[3]) {
|
|
669
692
|
handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
|
|
670
693
|
return;
|
|
@@ -685,7 +708,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
685
708
|
return;
|
|
686
709
|
}
|
|
687
710
|
if (req.method === 'POST') {
|
|
688
|
-
await createOrUpdateTask({ store, scheduler, req, res, apiToken });
|
|
711
|
+
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
689
712
|
return;
|
|
690
713
|
}
|
|
691
714
|
sendJson(res, 405, NOT_ALLOWED);
|
package/lib/cron-tool.js
CHANGED
|
@@ -64,7 +64,7 @@ export const cronToolParameters = {
|
|
|
64
64
|
},
|
|
65
65
|
schedule: {
|
|
66
66
|
type: 'string',
|
|
67
|
-
description: 'Schedule expression: cron ("0 9 * * *"), shorthand ("@daily"
|
|
67
|
+
description: 'Schedule expression: natural language ("every day at 9am", "every weekday at 10am", "in 2 hours", Russian phrases supported), cron ("0 9 * * *"), shorthand ("@daily"), interval ("every 2h"), or one-shot ("in 30m")',
|
|
68
68
|
},
|
|
69
69
|
prompt: {
|
|
70
70
|
type: 'string',
|