@goodandready/dsh-cron 0.2.27 → 0.2.29
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 +43 -21
- package/README.ru.md +42 -19
- package/README.zh.md +42 -19
- package/lib/api-helpers.js +13 -3
- package/lib/api.js +65 -42
- package/lib/client.js +1 -1
- package/lib/cron-tool.js +402 -0
- package/lib/http-utils.js +186 -16
- package/lib/index.js +24 -293
- package/lib/prompt.js +7 -4
- package/lib/task-patch.js +4 -3
- package/package.json +2 -2
package/lib/cron-tool.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { executeCreateTask } from './task-create.js';
|
|
2
|
+
import { applyTaskPatch, describeTaskPatch } from './task-patch.js';
|
|
3
|
+
import { isConfigOwned, configOwnedMessage } from './config-jobs.js';
|
|
4
|
+
import { TASK_TYPES } from './runtimes.js';
|
|
5
|
+
import { CHANNEL_IDS } from './channels.js';
|
|
6
|
+
|
|
7
|
+
export const LEGACY_TOOL_NAMES = [
|
|
8
|
+
'cron_create_task',
|
|
9
|
+
'cron_schedule_task',
|
|
10
|
+
'cron_list_tasks',
|
|
11
|
+
'cron_pause_task',
|
|
12
|
+
'cron_resume_task',
|
|
13
|
+
'cron_delete_task',
|
|
14
|
+
'cron_get_task',
|
|
15
|
+
'cron_update_task',
|
|
16
|
+
'cron_run_task',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const LEGACY_ACTION_MAP = {
|
|
20
|
+
cron_create_task: 'create',
|
|
21
|
+
create_task: 'create',
|
|
22
|
+
cron_schedule_task: 'create',
|
|
23
|
+
schedule_task: 'create',
|
|
24
|
+
schedule: 'create',
|
|
25
|
+
cron_list_tasks: 'list',
|
|
26
|
+
list_tasks: 'list',
|
|
27
|
+
cron_get_task: 'get',
|
|
28
|
+
get_task: 'get',
|
|
29
|
+
cron_update_task: 'update',
|
|
30
|
+
update_task: 'update',
|
|
31
|
+
cron_pause_task: 'pause',
|
|
32
|
+
pause_task: 'pause',
|
|
33
|
+
cron_resume_task: 'resume',
|
|
34
|
+
resume_task: 'resume',
|
|
35
|
+
cron_run_task: 'run',
|
|
36
|
+
run_task: 'run',
|
|
37
|
+
cron_delete_task: 'delete',
|
|
38
|
+
delete_task: 'delete',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function rejectLegacyTool(toolName) {
|
|
42
|
+
const mapped = LEGACY_ACTION_MAP[toolName] || 'list';
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
message: `Tool "${toolName}" has been removed and consolidated into the unified "cron" tool. Please call "cron" with action: "${mapped}" instead. For simple in-chat reminders, use the built-in "schedule_create" tool.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const cronToolParameters = {
|
|
50
|
+
action: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
enum: ['create', 'list', 'get', 'update', 'pause', 'resume', 'run', 'delete'],
|
|
53
|
+
description: 'Operation to perform: "create", "list", "get", "update", "pause", "resume", "run", or "delete". For simple reminders in the current chat, use the built-in "schedule_create" tool instead.',
|
|
54
|
+
required: true,
|
|
55
|
+
},
|
|
56
|
+
id: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Task ID (required for get, update, pause, resume, run, delete)',
|
|
59
|
+
},
|
|
60
|
+
title: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Short task name (for create and update)',
|
|
63
|
+
},
|
|
64
|
+
schedule: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
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
|
+
},
|
|
68
|
+
prompt: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Prompt/instruction for the agent at run time or command to execute',
|
|
71
|
+
},
|
|
72
|
+
type: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
enum: TASK_TYPES,
|
|
75
|
+
description: 'Task runtime type: llm | script | node | python | http | ssh | docker | skill | workflow',
|
|
76
|
+
},
|
|
77
|
+
status: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
enum: ['all', 'active', 'paused', 'completed'],
|
|
80
|
+
description: 'Filter tasks by status (for list action)',
|
|
81
|
+
},
|
|
82
|
+
confirmCodeSwitch: {
|
|
83
|
+
type: 'boolean',
|
|
84
|
+
description: 'Explicit confirmation required when updating a task into a code-executing type (script/node/python/ssh/docker)',
|
|
85
|
+
},
|
|
86
|
+
delivery: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
enum: ['current', 'isolated'],
|
|
89
|
+
description: 'Run mode: current (in current chat) or isolated (separate session)',
|
|
90
|
+
},
|
|
91
|
+
provider: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Model provider (optional)',
|
|
94
|
+
},
|
|
95
|
+
model: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Model identifier (optional)',
|
|
98
|
+
},
|
|
99
|
+
fallbackModel: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description: 'Model tried once more when the primary model fails',
|
|
102
|
+
},
|
|
103
|
+
fallbackProvider: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Provider for the fallback model (defaults to task provider)',
|
|
106
|
+
},
|
|
107
|
+
inspectOnFailure: {
|
|
108
|
+
type: 'boolean',
|
|
109
|
+
description: 'Diagnose failed runs with a model and store the diagnosis with the run',
|
|
110
|
+
},
|
|
111
|
+
silentRule: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: 'Plain-language condition for staying silent on a successful run',
|
|
114
|
+
},
|
|
115
|
+
timezone: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'IANA time zone for the schedule, e.g. "Europe/Berlin"',
|
|
118
|
+
},
|
|
119
|
+
notifyTelegram: {
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
description: 'Send the run report to Telegram',
|
|
122
|
+
},
|
|
123
|
+
onlyOnFailure: {
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
description: 'Send reports only for failures',
|
|
126
|
+
},
|
|
127
|
+
timeoutSeconds: {
|
|
128
|
+
type: 'number',
|
|
129
|
+
description: 'Execution time limit in seconds',
|
|
130
|
+
},
|
|
131
|
+
overlapPolicy: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
enum: ['skip', 'queue', 'replace'],
|
|
134
|
+
description: 'Overlap policy: skip | queue | replace',
|
|
135
|
+
},
|
|
136
|
+
misfirePolicy: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
enum: ['skip', 'runOnce', 'catchUpAll'],
|
|
139
|
+
description: 'What to do with a run missed during downtime: skip | runOnce | catchUpAll',
|
|
140
|
+
},
|
|
141
|
+
maxRetries: {
|
|
142
|
+
type: 'number',
|
|
143
|
+
description: 'Automatic retry attempts on failure (0 = off)',
|
|
144
|
+
},
|
|
145
|
+
retryBackoffMs: {
|
|
146
|
+
type: 'number',
|
|
147
|
+
description: 'Base backoff between retries in ms, doubled per attempt',
|
|
148
|
+
},
|
|
149
|
+
permissionPreset: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
enum: ['default', 'read-only', 'workspace-write', 'full'],
|
|
152
|
+
description: 'Permission preset applied to the task session',
|
|
153
|
+
},
|
|
154
|
+
kanbanMode: {
|
|
155
|
+
type: 'string',
|
|
156
|
+
enum: ['none', 'on_failure', 'always'],
|
|
157
|
+
description: 'Create a dsh-kanban card for runs: none | on_failure | always',
|
|
158
|
+
},
|
|
159
|
+
channels: {
|
|
160
|
+
type: 'array',
|
|
161
|
+
items: { type: 'string', enum: CHANNEL_IDS },
|
|
162
|
+
description: 'Notification channels for this task',
|
|
163
|
+
},
|
|
164
|
+
template: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
description: 'Notification template with {title} {status} {output} {error} {duration} placeholders',
|
|
167
|
+
},
|
|
168
|
+
env: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
additionalProperties: true,
|
|
171
|
+
description: 'Environment variables for external runtimes (KEY: value)',
|
|
172
|
+
},
|
|
173
|
+
cwd: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
description: 'Working directory for the run',
|
|
176
|
+
},
|
|
177
|
+
workspaceId: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'Workspace bound to the task',
|
|
180
|
+
},
|
|
181
|
+
worktree: {
|
|
182
|
+
type: 'boolean',
|
|
183
|
+
description: 'Run code-modifying agent tasks in an isolated git worktree',
|
|
184
|
+
},
|
|
185
|
+
keepWorktree: {
|
|
186
|
+
type: 'boolean',
|
|
187
|
+
description: 'Keep the created worktree after the run',
|
|
188
|
+
},
|
|
189
|
+
nodePath: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
description: 'Node.js binary for node tasks',
|
|
192
|
+
},
|
|
193
|
+
pythonPath: {
|
|
194
|
+
type: 'string',
|
|
195
|
+
description: 'Python interpreter for python tasks',
|
|
196
|
+
},
|
|
197
|
+
httpMethod: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
description: 'HTTP method for http tasks',
|
|
200
|
+
},
|
|
201
|
+
httpUrl: {
|
|
202
|
+
type: 'string',
|
|
203
|
+
description: 'URL for http tasks',
|
|
204
|
+
},
|
|
205
|
+
httpHeaders: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
additionalProperties: true,
|
|
208
|
+
description: 'Headers for http tasks',
|
|
209
|
+
},
|
|
210
|
+
httpBody: {
|
|
211
|
+
type: 'string',
|
|
212
|
+
description: 'Request body for http tasks',
|
|
213
|
+
},
|
|
214
|
+
sshProfileId: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'dsh-remote-workspace profile id',
|
|
217
|
+
},
|
|
218
|
+
sshTarget: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
description: 'Fallback user@host when no remote-workspace profile is used',
|
|
221
|
+
},
|
|
222
|
+
sshPort: {
|
|
223
|
+
type: 'number',
|
|
224
|
+
description: 'SSH port',
|
|
225
|
+
},
|
|
226
|
+
sshKeyPath: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
description: 'Private key path',
|
|
229
|
+
},
|
|
230
|
+
dockerImage: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
description: 'Container image for docker tasks',
|
|
233
|
+
},
|
|
234
|
+
skillName: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
description: 'DSH skill name for skill tasks',
|
|
237
|
+
},
|
|
238
|
+
workflowName: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
description: 'DSH workflow name for workflow tasks',
|
|
241
|
+
},
|
|
242
|
+
costLimitUsd: {
|
|
243
|
+
type: 'number',
|
|
244
|
+
description: 'Maximum allowed total cost in USD before auto-pausing',
|
|
245
|
+
},
|
|
246
|
+
dailyCostLimitUsd: {
|
|
247
|
+
type: 'number',
|
|
248
|
+
description: 'Maximum allowed daily cost in USD before auto-pausing',
|
|
249
|
+
},
|
|
250
|
+
tokenLimit: {
|
|
251
|
+
type: 'number',
|
|
252
|
+
description: 'Maximum allowed token usage before auto-pausing',
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const cronToolOutput = {
|
|
257
|
+
schema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
additionalProperties: true,
|
|
260
|
+
properties: {
|
|
261
|
+
success: { type: 'boolean' },
|
|
262
|
+
message: { type: 'string' },
|
|
263
|
+
count: { type: 'number' },
|
|
264
|
+
task: { type: 'object', additionalProperties: true },
|
|
265
|
+
tasks: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
render: (_args, val) => [{ type: 'text', text: val.message || (val.success ? 'Success' : 'Failed') }],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export async function executeCronTool(store, scheduler, args = {}) {
|
|
272
|
+
const action = args.action;
|
|
273
|
+
|
|
274
|
+
if (!action) {
|
|
275
|
+
if (args.schedule && args.prompt) {
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
message: 'Missing "action" parameter. The 9 separate cron tools have been consolidated into "cron". Did you mean action: "create"?',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
success: false,
|
|
283
|
+
message: 'Parameter "action" is required: "create", "list", "get", "update", "pause", "resume", "run", or "delete". Note: For simple in-chat reminders, use built-in "schedule_create".',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (LEGACY_ACTION_MAP[action]) {
|
|
288
|
+
const suggested = LEGACY_ACTION_MAP[action];
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
message: `Action "${action}" is deprecated. The 9 separate tools have been consolidated into the unified "cron" tool. Please use action: "${suggested}".`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
switch (action) {
|
|
296
|
+
case 'create': {
|
|
297
|
+
if (!args.title || !args.schedule || !args.prompt) {
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
message: 'Fields "title", "schedule", and "prompt" are required to create a task.',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return executeCreateTask(store, scheduler, args);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return { success: false, message: err.message };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case 'list': {
|
|
311
|
+
const tasks = store.list({ status: args.status || 'all' });
|
|
312
|
+
return {
|
|
313
|
+
success: true,
|
|
314
|
+
count: tasks.length,
|
|
315
|
+
tasks: tasks.map((t) => ({
|
|
316
|
+
id: t.id,
|
|
317
|
+
title: t.title,
|
|
318
|
+
schedule: t.scheduleText || t.schedule,
|
|
319
|
+
status: t.status,
|
|
320
|
+
nextRunAt: t.nextRunAt ? new Date(t.nextRunAt).toISOString() : null,
|
|
321
|
+
lastStatus: t.lastStatus,
|
|
322
|
+
totalTokens: t.totalTokens || 0,
|
|
323
|
+
totalCostUsd: t.totalCostUsd || 0,
|
|
324
|
+
oneShot: Boolean(t.oneShot),
|
|
325
|
+
})),
|
|
326
|
+
message: `Total tasks: ${tasks.length}`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case 'get': {
|
|
331
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for get action' };
|
|
332
|
+
const task = store.get(args.id);
|
|
333
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
334
|
+
const summary = [
|
|
335
|
+
`Task "${task.title}" (${task.id})`,
|
|
336
|
+
`type: ${task.type || 'llm'}`,
|
|
337
|
+
`schedule: ${task.scheduleText || task.schedule}`,
|
|
338
|
+
`status: ${task.status}`,
|
|
339
|
+
task.type === 'llm' || !task.type ? `model: ${task.model || '(default)'}${task.fallbackModel ? ` (fallback: ${task.fallbackModel})` : ''}` : null,
|
|
340
|
+
`channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
|
|
341
|
+
`prompt: ${String(task.prompt || '').slice(0, 500)}`,
|
|
342
|
+
].filter(Boolean).join('\n');
|
|
343
|
+
return { success: true, message: summary, task };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case 'update': {
|
|
347
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for update action' };
|
|
348
|
+
const { id, confirmCodeSwitch, action: _act, ...patch } = args;
|
|
349
|
+
const result = applyTaskPatch({
|
|
350
|
+
store,
|
|
351
|
+
scheduler,
|
|
352
|
+
id,
|
|
353
|
+
body: patch,
|
|
354
|
+
allowCodeSwitch: confirmCodeSwitch === true,
|
|
355
|
+
});
|
|
356
|
+
if (!result.ok) return { success: false, message: result.error };
|
|
357
|
+
return {
|
|
358
|
+
success: true,
|
|
359
|
+
message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
|
|
360
|
+
task: result.task,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case 'pause': {
|
|
365
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for pause action' };
|
|
366
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
367
|
+
const task = scheduler.pauseTask(args.id);
|
|
368
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
369
|
+
return { success: true, message: `Task "${task.title}" paused` };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
case 'resume': {
|
|
373
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for resume action' };
|
|
374
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
375
|
+
const task = scheduler.resumeTask(args.id);
|
|
376
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
377
|
+
return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case 'run': {
|
|
381
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for run action' };
|
|
382
|
+
const task = store.get(args.id);
|
|
383
|
+
if (!task) return { success: false, message: 'Task not found' };
|
|
384
|
+
scheduler.triggerManualRun(args.id);
|
|
385
|
+
return { success: true, message: `Manual run started for task "${task.title}"` };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
case 'delete': {
|
|
389
|
+
if (!args.id) return { success: false, message: 'Field "id" is required for delete action' };
|
|
390
|
+
if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
|
|
391
|
+
scheduler.pauseTask(args.id);
|
|
392
|
+
const ok = store.delete(args.id);
|
|
393
|
+
return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
default:
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
message: `Unknown action "${action}". Allowed actions: create, list, get, update, pause, resume, run, delete.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
package/lib/http-utils.js
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
|
|
7
7
|
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
8
|
export const SCRIPT_CONFIRM_HEADER = 'x-dsh-cron-confirm';
|
|
9
|
+
export const REDACTED_SECRET = '[REDACTED]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if an IP address is a local loopback address.
|
|
13
|
+
*/
|
|
14
|
+
export function isLoopbackAddress(addr) {
|
|
15
|
+
if (!addr || typeof addr !== 'string') return false;
|
|
16
|
+
return (
|
|
17
|
+
addr === '127.0.0.1' ||
|
|
18
|
+
addr === '::1' ||
|
|
19
|
+
addr === '::ffff:127.0.0.1' ||
|
|
20
|
+
addr === 'localhost'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract bearer authorization or custom token from request headers.
|
|
26
|
+
*/
|
|
27
|
+
export function extractBearerToken(req) {
|
|
28
|
+
const auth = req?.headers?.authorization;
|
|
29
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
30
|
+
return auth.slice(7).trim();
|
|
31
|
+
}
|
|
32
|
+
const custom = req?.headers?.['x-dsh-cron-token'];
|
|
33
|
+
if (typeof custom === 'string') {
|
|
34
|
+
return custom.trim();
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
/**
|
|
11
40
|
* Parse a JSON request body with a hard byte size cap.
|
|
@@ -48,27 +77,63 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
48
77
|
}
|
|
49
78
|
|
|
50
79
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
80
|
+
* Validate that an incoming request originates from a trusted local caller or
|
|
81
|
+
* an authenticated client (Issue #86).
|
|
82
|
+
*
|
|
83
|
+
* Rules:
|
|
84
|
+
* 1. Non-loopback remote callers are rejected unless authenticated with a valid token.
|
|
85
|
+
* 2. Host header must be present and non-empty.
|
|
86
|
+
* 3. Origin header (if present) cannot be null and its host must match Host.
|
|
87
|
+
* 4. Sec-Fetch-Site (if present) must be same-origin or none (cross-site/same-site rejected).
|
|
88
|
+
* 5. Mutating requests (POST, PUT, PATCH, DELETE) must provide browser proof (same-origin / matching Origin)
|
|
89
|
+
* or an explicit capability header (confirm header, token, or update header).
|
|
55
90
|
*/
|
|
56
|
-
export function
|
|
91
|
+
export function isTrustedRequest(req, options = {}) {
|
|
92
|
+
if (!req) return false;
|
|
57
93
|
const headers = req.headers || {};
|
|
94
|
+
|
|
95
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
96
|
+
const hasRemote = Boolean(remote && typeof remote === 'string');
|
|
97
|
+
const isLoopback = !hasRemote || isLoopbackAddress(remote);
|
|
98
|
+
|
|
99
|
+
const token = extractBearerToken(req);
|
|
100
|
+
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
101
|
+
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
102
|
+
|
|
103
|
+
// 1. Non-loopback remote addresses require explicit token authentication (#86)
|
|
104
|
+
if (hasRemote && !isLoopback && !isTokenAuthenticated) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Validate Origin header if present: cannot be null, must match Host
|
|
58
109
|
const origin = headers.origin;
|
|
110
|
+
const host = headers.host || '';
|
|
59
111
|
if (origin !== undefined) {
|
|
60
|
-
if (origin === 'null') return
|
|
112
|
+
if (origin === 'null' || !origin) return false;
|
|
61
113
|
try {
|
|
62
114
|
const parsed = new URL(origin);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return true;
|
|
115
|
+
if (!parsed.host || !host || parsed.host !== host) return false;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
67
118
|
}
|
|
68
119
|
}
|
|
120
|
+
|
|
121
|
+
// 3. Validate Sec-Fetch-Site header if present
|
|
69
122
|
const site = headers['sec-fetch-site'];
|
|
70
|
-
if (site
|
|
71
|
-
|
|
123
|
+
if (site !== undefined) {
|
|
124
|
+
if (site !== 'same-origin' && site !== 'none') {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Backward-compatible wrapper for checking cross-origin / untrusted requests.
|
|
134
|
+
*/
|
|
135
|
+
export function isCrossOrigin(req, options) {
|
|
136
|
+
return !isTrustedRequest(req, options);
|
|
72
137
|
}
|
|
73
138
|
|
|
74
139
|
/**
|
|
@@ -91,9 +156,9 @@ export function sendJson(res, statusCode, data) {
|
|
|
91
156
|
res.end(JSON.stringify(data));
|
|
92
157
|
}
|
|
93
158
|
|
|
94
|
-
export function rejectCrossOrigin(req, res) {
|
|
95
|
-
if (
|
|
96
|
-
sendJson(res, 403, { ok: false, error: '
|
|
159
|
+
export function rejectCrossOrigin(req, res, options) {
|
|
160
|
+
if (!isTrustedRequest(req, options)) {
|
|
161
|
+
sendJson(res, 403, { ok: false, error: 'Forbidden: untrusted or cross-origin request' });
|
|
97
162
|
return true;
|
|
98
163
|
}
|
|
99
164
|
return false;
|
|
@@ -108,4 +173,109 @@ export async function readBody(req, res) {
|
|
|
108
173
|
}
|
|
109
174
|
}
|
|
110
175
|
|
|
111
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Redact sensitive fields (env, httpHeaders, httpBody) from task records before returning in GET responses (Issue #86).
|
|
178
|
+
*/
|
|
179
|
+
export function redactTaskSecrets(task) {
|
|
180
|
+
if (!task || typeof task !== 'object') return task;
|
|
181
|
+
const clone = { ...task };
|
|
182
|
+
|
|
183
|
+
if (clone.env && typeof clone.env === 'object') {
|
|
184
|
+
const redactedEnv = {};
|
|
185
|
+
for (const [k, v] of Object.entries(clone.env)) {
|
|
186
|
+
redactedEnv[k] = v ? REDACTED_SECRET : v;
|
|
187
|
+
}
|
|
188
|
+
clone.env = redactedEnv;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (clone.httpHeaders) {
|
|
192
|
+
if (typeof clone.httpHeaders === 'object' && clone.httpHeaders !== null) {
|
|
193
|
+
const redactedHeaders = {};
|
|
194
|
+
for (const [k, v] of Object.entries(clone.httpHeaders)) {
|
|
195
|
+
redactedHeaders[k] = v ? REDACTED_SECRET : v;
|
|
196
|
+
}
|
|
197
|
+
clone.httpHeaders = redactedHeaders;
|
|
198
|
+
} else if (typeof clone.httpHeaders === 'string') {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(clone.httpHeaders);
|
|
201
|
+
if (parsed && typeof parsed === 'object') {
|
|
202
|
+
const redactedHeaders = {};
|
|
203
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
204
|
+
redactedHeaders[k] = v ? REDACTED_SECRET : v;
|
|
205
|
+
}
|
|
206
|
+
clone.httpHeaders = JSON.stringify(redactedHeaders);
|
|
207
|
+
} else {
|
|
208
|
+
clone.httpHeaders = REDACTED_SECRET;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
clone.httpHeaders = REDACTED_SECRET;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (clone.httpBody) {
|
|
217
|
+
clone.httpBody = REDACTED_SECRET;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return clone;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Restore redacted secret fields from the existing record when updating a task (Issue #86).
|
|
225
|
+
*/
|
|
226
|
+
export function restoreTaskSecrets(incoming, current) {
|
|
227
|
+
if (!incoming || typeof incoming !== 'object' || !current || typeof current !== 'object') {
|
|
228
|
+
return incoming;
|
|
229
|
+
}
|
|
230
|
+
const restored = { ...incoming };
|
|
231
|
+
|
|
232
|
+
// Restore env values
|
|
233
|
+
if (restored.env && typeof restored.env === 'object' && current.env && typeof current.env === 'object') {
|
|
234
|
+
const mergedEnv = { ...restored.env };
|
|
235
|
+
for (const [k, v] of Object.entries(mergedEnv)) {
|
|
236
|
+
if (v === REDACTED_SECRET && current.env[k] !== undefined) {
|
|
237
|
+
mergedEnv[k] = current.env[k];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
restored.env = mergedEnv;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Restore httpHeaders values
|
|
244
|
+
if (restored.httpHeaders && current.httpHeaders) {
|
|
245
|
+
if (typeof restored.httpHeaders === 'object' && typeof current.httpHeaders === 'object') {
|
|
246
|
+
const mergedHeaders = { ...restored.httpHeaders };
|
|
247
|
+
for (const [k, v] of Object.entries(mergedHeaders)) {
|
|
248
|
+
if (v === REDACTED_SECRET && current.httpHeaders[k] !== undefined) {
|
|
249
|
+
mergedHeaders[k] = current.httpHeaders[k];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
restored.httpHeaders = mergedHeaders;
|
|
253
|
+
} else if (typeof restored.httpHeaders === 'string') {
|
|
254
|
+
try {
|
|
255
|
+
const parsedRestored = JSON.parse(restored.httpHeaders);
|
|
256
|
+
const parsedCurrent = typeof current.httpHeaders === 'object'
|
|
257
|
+
? current.httpHeaders
|
|
258
|
+
: JSON.parse(current.httpHeaders);
|
|
259
|
+
if (parsedRestored && typeof parsedRestored === 'object' && parsedCurrent && typeof parsedCurrent === 'object') {
|
|
260
|
+
for (const [k, v] of Object.entries(parsedRestored)) {
|
|
261
|
+
if (v === REDACTED_SECRET && parsedCurrent[k] !== undefined) {
|
|
262
|
+
parsedRestored[k] = parsedCurrent[k];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
restored.httpHeaders = JSON.stringify(parsedRestored);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
if (restored.httpHeaders === REDACTED_SECRET) {
|
|
269
|
+
restored.httpHeaders = current.httpHeaders;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Restore httpBody value
|
|
276
|
+
if (restored.httpBody === REDACTED_SECRET && current.httpBody !== undefined) {
|
|
277
|
+
restored.httpBody = current.httpBody;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return restored;
|
|
281
|
+
}
|