@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 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
 
@@ -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", "@every 30m"), interval ("every 2h"), or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")',
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',