@goodandready/dsh-cron 0.2.32 → 0.2.34

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.
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import { logger } from './logger.js';
2
3
  import { sendJson, readBody } from './http-utils.js';
3
4
  import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
@@ -5,6 +6,7 @@ import { handleTelegramCommand } from './telegram-commands.js';
5
6
  import { bestEffort } from './best-effort.js';
6
7
 
7
8
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
9
+ const seenUpdateIds = new Set();
8
10
 
9
11
  export async function handleTelegramWebhook({ store, scheduler, req, res }) {
10
12
  if (req.method !== 'POST') {
@@ -19,13 +21,45 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
19
21
  const botToken = settings.botToken || defaults.botToken;
20
22
  const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
21
23
 
24
+ // Secret token verification (Issue #208)
25
+ const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
26
+ if (webhookSecret) {
27
+ const receivedSecret = req.headers['x-telegram-bot-api-secret-token'];
28
+ if (!receivedSecret || typeof receivedSecret !== 'string') {
29
+ logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
30
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
31
+ return;
32
+ }
33
+ const secretBuf = Buffer.from(String(webhookSecret));
34
+ const receivedBuf = Buffer.from(String(receivedSecret));
35
+ if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
36
+ logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
37
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
38
+ return;
39
+ }
40
+ }
41
+
42
+ // Deduplicate updates by update_id (Issue #208)
43
+ if (body.update_id != null) {
44
+ if (seenUpdateIds.has(body.update_id)) {
45
+ sendJson(res, 200, { ok: true, duplicate: true });
46
+ return;
47
+ }
48
+ seenUpdateIds.add(body.update_id);
49
+ if (seenUpdateIds.size > 2000) {
50
+ const first = seenUpdateIds.values().next().value;
51
+ seenUpdateIds.delete(first);
52
+ }
53
+ }
54
+
22
55
  // 1. Text command message (#175)
23
56
  const message = body.message;
24
57
  if (message && typeof message.text === 'string' && message.text.trim().startsWith('/')) {
25
58
  const fromId = String(message.from?.id || '');
26
59
  const chatId = String(message.chat?.id || fromId);
27
- if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
28
- logger.warn(`[dsh-cron] unauthorized telegram command from chat ${chatId} / user ${fromId}`);
60
+ // Fail-closed: empty allowedChatId denies control (Issue #208)
61
+ if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
62
+ logger.warn(`[dsh-cron] unauthorized telegram command from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
29
63
  sendJson(res, 403, { ok: false, error: 'Forbidden' });
30
64
  return;
31
65
  }
@@ -52,8 +86,8 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
52
86
  const fromId = String(callbackQuery.from?.id || '');
53
87
  const chatId = String(callbackQuery.message?.chat?.id || fromId);
54
88
 
55
- if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
56
- logger.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
89
+ if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
90
+ logger.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
57
91
  if (botToken) {
58
92
  await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
59
93
  }
@@ -82,6 +116,15 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
82
116
  return;
83
117
  }
84
118
 
119
+ const isConfigTask = task.source === 'config' || task.origin === 'config' || task.readOnly || task.managedBy === 'config';
120
+ if ((verb === 'pause' || verb === 'resume') && isConfigTask) {
121
+ if (botToken) {
122
+ await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Cannot modify config-owned task', showAlert: true });
123
+ }
124
+ sendJson(res, 400, { ok: false, error: 'Cannot modify config-owned task' });
125
+ return;
126
+ }
127
+
85
128
  if (verb === 'run') {
86
129
  try {
87
130
  await scheduler.triggerManualRun(taskId);
package/lib/api.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  buildDuplicateTask,
18
18
  RESERVED_TASK_IDS,
19
19
  PATCHABLE_TASK_FIELDS,
20
+ isCodeExecutingTask,
20
21
  } from './task-transfer.js';
21
22
  import {
22
23
  handleTaskExport,
@@ -190,8 +191,8 @@ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
190
191
  return;
191
192
  }
192
193
  const taskType = normalizeTaskType(body.type);
193
- if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
194
- sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
194
+ if ((CODE_EXECUTING_TYPES.includes(taskType) || isCodeExecutingTask(body)) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
195
+ sendJson(res, 403, { ok: false, error: `Creating code-executing tasks over HTTP requires the x-dsh-cron-confirm: script header` });
195
196
  return;
196
197
  }
197
198
  const typeError = validateTaskType(taskType, body);
package/lib/client.js CHANGED
@@ -1530,10 +1530,19 @@ window.__ModuleLoader__.load({
1530
1530
  const RESERVED_TASK_IDS = ['export', 'import'];
1531
1531
 
1532
1532
  /** Import needs the script-confirm header when the file has code tasks. */
1533
+ const isCodeTaskForm = (x) => {
1534
+ if (!x || typeof x !== 'object') return false;
1535
+ if (CODE_FORM_TYPES.includes(x.type)) return true;
1536
+ if (x.command && String(x.command).trim()) return true;
1537
+ if (x.preflightType === 'command' || (x.preflightCommand && String(x.preflightCommand).trim())) return true;
1538
+ if (x.selfHealingCommand && String(x.selfHealingCommand).trim()) return true;
1539
+ return false;
1540
+ };
1541
+
1533
1542
  const importHeaders = (document_) => {
1534
1543
  const headers = { 'Content-Type': 'application/json' };
1535
1544
  const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
1536
- if (tasks.some((x) => CODE_FORM_TYPES.includes(x && x.type))) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1545
+ if (tasks.some(isCodeTaskForm)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1537
1546
  return headers;
1538
1547
  };
1539
1548
 
@@ -1976,7 +1985,12 @@ window.__ModuleLoader__.load({
1976
1985
  // Cross-origin hardening: every code-executing type needs the explicit
1977
1986
  // confirm header, which forged cross-site posts cannot attach (#86)
1978
1987
  const headers = { 'Content-Type': 'application/json' };
1979
- if (CODE_FORM_TYPES.includes(formType)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1988
+ const hasCodeExec = CODE_FORM_TYPES.includes(formType) ||
1989
+ Boolean(payload.command && payload.command.trim()) ||
1990
+ payload.preflightType === 'command' ||
1991
+ Boolean(payload.preflightCommand && payload.preflightCommand.trim()) ||
1992
+ Boolean(payload.selfHealingCommand && payload.selfHealingCommand.trim());
1993
+ if (hasCodeExec) headers[SCRIPT_CONFIRM_HEADER] = 'script';
1980
1994
  const res = await fetch('/dsh-cron/tasks', {
1981
1995
  method: 'POST',
1982
1996
  headers,
@@ -7,16 +7,20 @@ import { bestEffort } from './best-effort.js';
7
7
  */
8
8
  export function resolveCwd(task, ctx = null) {
9
9
  if (task.cwd) return String(task.cwd);
10
- if (task.workspaceId && ctx && typeof ctx.get === 'function') {
11
- const resolved = bestEffort('resolve-workspace-path', () => {
12
- const workspaces = ctx.get('workspaces');
13
- if (workspaces && typeof workspaces.get === 'function') {
14
- const ws = workspaces.get(task.workspaceId);
15
- return ws && ws.path ? String(ws.path) : null;
16
- }
17
- return null;
18
- });
19
- if (resolved) return resolved;
10
+ if (task.workspaceId) {
11
+ if (ctx && typeof ctx.get === 'function') {
12
+ const resolved = bestEffort('resolve-workspace-path', () => {
13
+ const workspaces = ctx.get('workspaces');
14
+ if (workspaces && typeof workspaces.get === 'function') {
15
+ const ws = workspaces.get(task.workspaceId);
16
+ return ws && ws.path ? String(ws.path) : null;
17
+ }
18
+ return null;
19
+ });
20
+ if (resolved) return resolved;
21
+ }
22
+ // Issue #231: fail-closed if workspaceId was specified but cannot be resolved
23
+ throw new Error(`Workspace "${task.workspaceId}" requested by task could not be resolved`);
20
24
  }
21
25
  return process.cwd();
22
26
  }
package/lib/runner.js CHANGED
@@ -297,18 +297,15 @@ export class SessionRunner {
297
297
  };
298
298
  }
299
299
 
300
- /** Working directory for the run, in an isolated worktree when requested (#29). */
300
+ /** Working directory for the run, in an isolated worktree when requested (#29, #231). */
301
301
  async _resolveRunContext(task) {
302
302
  const baseCwd = this.resolveCwd(task);
303
303
  let cwd = baseCwd;
304
304
  let taskWorktree = null;
305
305
  if (task.worktree) {
306
- try {
307
- taskWorktree = await this.createTaskWorktree(baseCwd, task);
308
- cwd = taskWorktree;
309
- } catch (wtErr) {
310
- logger.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
311
- }
306
+ // Issue #231: Fail-closed — if worktree creation fails, abort run and preserve base workspace
307
+ taskWorktree = await this.createTaskWorktree(baseCwd, task);
308
+ cwd = taskWorktree;
312
309
  }
313
310
  return { baseCwd, cwd, taskWorktree };
314
311
  }
package/lib/runtimes.js CHANGED
@@ -123,18 +123,34 @@ export function buildSshInvocation(task) {
123
123
  return { file: 'ssh', args };
124
124
  }
125
125
 
126
- /** #8: build a Docker run invocation. */
127
- export function buildDockerInvocation(task, env) {
126
+ export const DOCKER_HOST_ENV_ALLOWLIST = ['TZ', 'LANG', 'LC_ALL'];
127
+
128
+ /** #8/#226: build Docker run invocation without leaking host process secrets into container. */
129
+ export function buildDockerInvocation(task, explicitEnv) {
128
130
  const image = String(task.dockerImage || '').trim();
129
131
  if (!image) throw new Error('Docker task requires dockerImage');
130
132
  const args = ['run', '--rm'];
131
- for (const [key, value] of Object.entries(env || {})) {
133
+
134
+ // Issue #226: isolate container environment. If explicitEnv is passed directly
135
+ // (e.g. test or explicit invocation), use it; otherwise use task.env only.
136
+ let envMap = explicitEnv;
137
+ if (!envMap) {
138
+ envMap = {};
139
+ if (task && task.env && typeof task.env === 'object') {
140
+ for (const [k, v] of Object.entries(task.env)) {
141
+ if (v !== null && v !== undefined) envMap[k] = String(v);
142
+ }
143
+ }
144
+ }
145
+
146
+ for (const [key, value] of Object.entries(envMap)) {
132
147
  if (key === 'PATH' || key === 'HOME') continue; // container owns those
148
+ if (value === null || value === undefined) continue;
133
149
  args.push('-e', `${key}=${value}`);
134
150
  }
135
151
  if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
136
152
  args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
137
- return { file: 'docker', args };
153
+ return { file: 'docker', args, env: envMap };
138
154
  }
139
155
 
140
156
  /** #38: merge task env vars over the base environment. */
@@ -165,8 +181,11 @@ export function buildInvocation(task, cwd, baseEnv) {
165
181
  return { type, env, http: buildHttpRequest(task) };
166
182
  case 'ssh':
167
183
  return { type, env, ...buildSshInvocation(task) };
168
- case 'docker':
169
- return { type, env, ...buildDockerInvocation(task, env) };
184
+ case 'docker': {
185
+ // Issue #226: do not pass full host process.env into docker run
186
+ const dockerInvocation = buildDockerInvocation(task, task.env);
187
+ return { type, env: dockerInvocation.env, file: dockerInvocation.file, args: dockerInvocation.args };
188
+ }
170
189
  case 'script':
171
190
  default:
172
191
  return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
package/lib/settings.js CHANGED
@@ -15,6 +15,7 @@ if (typeof z.prototype?.volatile !== 'function') {
15
15
  export const Config = z.object({
16
16
  botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)').volatile(),
17
17
  botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token').volatile(),
18
+ telegramWebhookSecret: z.string().role('secret').default('').description('Secret token for validating incoming Telegram webhook requests (X-Telegram-Bot-Api-Secret-Token)').volatile(),
18
19
  chatId: z.string().default('').description('Telegram chat ID that receives task reports').volatile(),
19
20
  notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task').volatile(),
20
21
  onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs').volatile(),
@@ -75,7 +76,7 @@ export function unwrapConfig(cfg) {
75
76
  }
76
77
 
77
78
  export const SETTINGS_SYNC_KEYS = [
78
- 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
79
+ 'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
79
80
  'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
80
81
  'discordWebhookUrl', 'slackWebhookUrl',
81
82
  'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
package/lib/store.js CHANGED
@@ -215,7 +215,7 @@ export class TaskStore {
215
215
  * fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
216
216
  */
217
217
  static get MASKED_SETTING_KEYS() {
218
- return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey', 'apiToken'];
218
+ return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey', 'apiToken', 'telegramWebhookSecret'];
219
219
  }
220
220
 
221
221
  saveSettings(newSettings = {}) {
package/lib/task-patch.js CHANGED
@@ -22,6 +22,26 @@ export function isCodeTypeSwitch(current, nextType) {
22
22
  return CODE_EXECUTING_TYPES.includes(target) && !CODE_EXECUTING_TYPES.includes(current.type);
23
23
  }
24
24
 
25
+ /** True when a patch introduces code-executing type or shell hooks (#212). */
26
+ export function isCodeExecutionIntroduced(current, body) {
27
+ if (!current || !body) return false;
28
+ if (body.type !== undefined && isCodeTypeSwitch(current, body.type)) return true;
29
+
30
+ const hadPreflight = Boolean(current.preflightCommand && String(current.preflightCommand).trim()) || current.preflightType === 'command';
31
+ const hasPreflight = Boolean(body.preflightCommand && String(body.preflightCommand).trim()) || body.preflightType === 'command';
32
+ if (hasPreflight && !hadPreflight) return true;
33
+
34
+ const hadSelfHealing = Boolean(current.selfHealingCommand && String(current.selfHealingCommand).trim());
35
+ const hasSelfHealing = Boolean(body.selfHealingCommand && String(body.selfHealingCommand).trim());
36
+ if (hasSelfHealing && !hadSelfHealing) return true;
37
+
38
+ const hadCommand = Boolean(current.command && String(current.command).trim());
39
+ const hasCommand = Boolean(body.command && String(body.command).trim());
40
+ if (hasCommand && !hadCommand && !CODE_EXECUTING_TYPES.includes(current.type)) return true;
41
+
42
+ return false;
43
+ }
44
+
25
45
  /**
26
46
  * Normalise a patch: whitelist the fields, resolve the type, re-parse the
27
47
  * schedule so cron patterns and one-shot markers stay consistent.
@@ -85,11 +105,11 @@ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = f
85
105
  if (isConfigOwned(current)) {
86
106
  return { ok: false, configOwned: true, error: configOwnedMessage(id) };
87
107
  }
88
- if (isCodeTypeSwitch(current, body && body.type) && allowCodeSwitch !== true) {
108
+ if (isCodeExecutionIntroduced(current, body) && allowCodeSwitch !== true) {
89
109
  return {
90
110
  ok: false,
91
111
  needsConfirmation: true,
92
- error: `Switching this task to ${normalizeTaskType(body.type)} makes it execute code; confirm that explicitly and retry`,
112
+ error: `Adding code-executing commands or switching this task to execute code requires explicit confirmation`,
93
113
  };
94
114
  }
95
115
  const built = buildTaskPatch(current, body);
@@ -222,10 +222,21 @@ export function validateImportDocument(doc) {
222
222
  return { ok: true, tasks };
223
223
  }
224
224
 
225
- /** True when any task in a validated batch executes code on the host. */
226
- export function hasCodeExecutingTask(tasks) {
225
+ /** True when a task executes host code or contains shell hooks (#212). */
226
+ export function isCodeExecutingTask(task) {
227
+ if (!task || typeof task !== 'object') return false;
227
228
  const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
228
- return (tasks || []).some((task) => CODE_TYPES.includes(normalizeTaskType(task.type)));
229
+ if (CODE_TYPES.includes(normalizeTaskType(task.type))) return true;
230
+ if (task.command && String(task.command).trim()) return true;
231
+ if (task.preflightType === 'command' || (task.preflightCommand && String(task.preflightCommand).trim())) return true;
232
+ if (task.selfHealingCommand && String(task.selfHealingCommand).trim()) return true;
233
+ if (task.preflightScript && String(task.preflightScript).trim()) return true;
234
+ return false;
235
+ }
236
+
237
+ /** True when any task in a validated batch executes code on the host (#212). */
238
+ export function hasCodeExecutingTask(tasks) {
239
+ return (tasks || []).some(isCodeExecutingTask);
229
240
  }
230
241
 
231
242
  /**
@@ -210,6 +210,10 @@ export async function handleTelegramCommand({
210
210
  await reply(`❌ Task \`${targetId}\` not found.`);
211
211
  return { ok: false, error: 'Task not found' };
212
212
  }
213
+ if (task.source === 'config' || task.origin === 'config' || task.readOnly || task.managedBy === 'config') {
214
+ await reply(`❌ Cannot modify config-owned task *${task.title}* (\`${task.id}\`).`);
215
+ return { ok: false, error: 'Cannot modify config-owned task' };
216
+ }
213
217
  scheduler.pauseTask(task.id, 'Paused via Telegram');
214
218
  await reply(`⏸ Task *${task.title}* (\`${task.id}\`) paused.`);
215
219
  return { ok: true, command, taskId: task.id };
@@ -226,6 +230,10 @@ export async function handleTelegramCommand({
226
230
  await reply(`❌ Task \`${targetId}\` not found.`);
227
231
  return { ok: false, error: 'Task not found' };
228
232
  }
233
+ if (task.source === 'config' || task.origin === 'config' || task.readOnly || task.managedBy === 'config') {
234
+ await reply(`❌ Cannot modify config-owned task *${task.title}* (\`${task.id}\`).`);
235
+ return { ok: false, error: 'Cannot modify config-owned task' };
236
+ }
229
237
  scheduler.resumeTask(task.id);
230
238
  await reply(`▶️ Task *${task.title}* (\`${task.id}\`) resumed.`);
231
239
  return { ok: true, command, taskId: task.id };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.32",
3
+ "version": "0.2.34",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,6 +16,12 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "build:client": "node scripts/build-client.mjs",
22
+ "pretest": "node scripts/build-client.mjs",
23
+ "preflight": "node scripts/ci-preflight.mjs"
24
+ },
19
25
  "keywords": [
20
26
  "dsh",
21
27
  "dsh-plugin",
@@ -83,11 +89,5 @@
83
89
  "publishConfig": {
84
90
  "registry": "https://registry.npmjs.org",
85
91
  "access": "public"
86
- },
87
- "scripts": {
88
- "test": "node --test test/*.test.mjs",
89
- "build:client": "node scripts/build-client.mjs",
90
- "pretest": "node scripts/build-client.mjs",
91
- "preflight": "node scripts/ci-preflight.mjs"
92
92
  }
93
- }
93
+ }