@goodandready/dsh-cron 0.3.0 → 0.3.1
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/lib/client.js +23 -16
- package/lib/prompt-interpolation.js +19 -0
- package/lib/runner.js +99 -1
- package/lib/scheduler-lifecycle.js +6 -0
- package/lib/scheduler.js +15 -0
- package/lib/secrets.js +114 -0
- package/lib/settings.js +3 -1
- package/lib/task-create.js +17 -0
- package/lib/task-patch.js +4 -0
- package/lib/task-transfer.js +1 -0
- package/lib/watcher.js +249 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -288,14 +288,12 @@ window.__ModuleLoader__.load({
|
|
|
288
288
|
'settings.kanbanUrlLabel': 'Kanban Base URL',
|
|
289
289
|
'settings.globalNotify': 'Deliver reports of all tasks to Telegram globally',
|
|
290
290
|
'settings.globalOnlyFailure': 'Global mode: only on failures (silent on success)',
|
|
291
|
-
'settings.
|
|
292
|
-
'settings.
|
|
293
|
-
'settings.
|
|
294
|
-
'settings.checkingKanban': 'Checking Kanban...',
|
|
295
|
-
'settings.testOk': '✅ Test message delivered to Telegram!',
|
|
296
|
-
'settings.
|
|
297
|
-
'settings.kanbanOk': '✅ Kanban card created',
|
|
298
|
-
'settings.kanbanFail': '❌ {error}',
|
|
291
|
+
'settings.dynamicCredentials': 'Dynamic Credentials Resolution (credential://...)',
|
|
292
|
+
'settings.dynamicCredentialsHint': 'Resolve credential://... in memory from harness credentials at runtime (#335)',
|
|
293
|
+
'settings.testTelegram': '🔔 Test Telegram delivery', 'settings.testKanban': '📋 Test Kanban',
|
|
294
|
+
'settings.sending': 'Sending...', 'settings.checkingKanban': 'Checking Kanban...',
|
|
295
|
+
'settings.testOk': '✅ Test message delivered to Telegram!', 'settings.testFail': '❌ {error}',
|
|
296
|
+
'settings.kanbanOk': '✅ Kanban card created', 'settings.kanbanFail': '❌ {error}',
|
|
299
297
|
'settings.save': 'Save settings',
|
|
300
298
|
'settings.saved': '✓ Saved',
|
|
301
299
|
'settings.saveError': 'Save failed: {error}',
|
|
@@ -590,14 +588,12 @@ window.__ModuleLoader__.load({
|
|
|
590
588
|
'settings.kanbanUrlLabel': 'Kanban 服务基础地址',
|
|
591
589
|
'settings.globalNotify': '全局开启所有任务结果推送至 Telegram',
|
|
592
590
|
'settings.globalOnlyFailure': '全局模式:仅在失败时推送 (成功静默)',
|
|
593
|
-
'settings.
|
|
594
|
-
'settings.
|
|
595
|
-
'settings.
|
|
596
|
-
'settings.checkingKanban': '正在连接 Kanban...',
|
|
597
|
-
'settings.testOk': '✅ 测试消息已成功发送至 Telegram!',
|
|
598
|
-
'settings.
|
|
599
|
-
'settings.kanbanOk': '✅ Kanban 卡片创建成功',
|
|
600
|
-
'settings.kanbanFail': '❌ {error}',
|
|
591
|
+
'settings.dynamicCredentials': '动态凭据解析 (credential://...)',
|
|
592
|
+
'settings.dynamicCredentialsHint': '运行时从凭据服务动态解析 credential://... 且不泄露到存储 (#335)',
|
|
593
|
+
'settings.testTelegram': '🔔 测试 Telegram 发送', 'settings.testKanban': '📋 测试 Kanban',
|
|
594
|
+
'settings.sending': '正在发送...', 'settings.checkingKanban': '正在连接 Kanban...',
|
|
595
|
+
'settings.testOk': '✅ 测试消息已成功发送至 Telegram!', 'settings.testFail': '❌ 发送失败: {error}',
|
|
596
|
+
'settings.kanbanOk': '✅ Kanban 卡片创建成功', 'settings.kanbanFail': '❌ {error}',
|
|
601
597
|
'settings.save': '保存设置',
|
|
602
598
|
'settings.saved': '✓ 已保存',
|
|
603
599
|
'settings.saveError': '保存失败: {error}',
|
|
@@ -3798,6 +3794,7 @@ window.__ModuleLoader__.load({
|
|
|
3798
3794
|
const [notify, setNotify] = React.useState(false);
|
|
3799
3795
|
const [onlyFailure, setOnlyFailure] = React.useState(false);
|
|
3800
3796
|
const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
|
|
3797
|
+
const [enableDynamicCredentials, setEnableDynamicCredentials] = React.useState(false);
|
|
3801
3798
|
const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
|
|
3802
3799
|
const [delivery, setDelivery] = React.useState({});
|
|
3803
3800
|
const [sections, setSections] = React.useState({ channels: true });
|
|
@@ -3878,6 +3875,7 @@ window.__ModuleLoader__.load({
|
|
|
3878
3875
|
setNotify(Boolean(data.settings.notifyTelegram));
|
|
3879
3876
|
setOnlyFailure(Boolean(data.settings.onlyOnFailure));
|
|
3880
3877
|
setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
|
|
3878
|
+
setEnableDynamicCredentials(Boolean(data.settings.enableDynamicCredentials));
|
|
3881
3879
|
setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
|
|
3882
3880
|
setDelivery(pickDeliverySettings(data.settings));
|
|
3883
3881
|
setHasDefault(Boolean(data.settings.hasDefaultCredentials));
|
|
@@ -3905,6 +3903,7 @@ window.__ModuleLoader__.load({
|
|
|
3905
3903
|
notifyTelegram: notify,
|
|
3906
3904
|
onlyOnFailure: onlyFailure,
|
|
3907
3905
|
llmActionsEnabled: llmActionsEnabled,
|
|
3906
|
+
enableDynamicCredentials: enableDynamicCredentials,
|
|
3908
3907
|
kanbanBaseUrl: kanbanUrl
|
|
3909
3908
|
}, delivery))
|
|
3910
3909
|
});
|
|
@@ -4046,6 +4045,14 @@ window.__ModuleLoader__.load({
|
|
|
4046
4045
|
onChange: (e) => setLlmActionsEnabled(e.target.checked)
|
|
4047
4046
|
}),
|
|
4048
4047
|
React.createElement('span', null, 'Enable Structured LLM Actions (trigger tasks, notify, create issues)')
|
|
4048
|
+
),
|
|
4049
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row' },
|
|
4050
|
+
React.createElement('input', {
|
|
4051
|
+
type: 'checkbox',
|
|
4052
|
+
checked: enableDynamicCredentials,
|
|
4053
|
+
onChange: (e) => setEnableDynamicCredentials(e.target.checked)
|
|
4054
|
+
}),
|
|
4055
|
+
React.createElement('span', null, T_KEY(t, 'settings.dynamicCredentials'))
|
|
4049
4056
|
)
|
|
4050
4057
|
),
|
|
4051
4058
|
...renderDeliverySettings({
|
|
@@ -25,6 +25,11 @@ export function buildPromptContext(task = {}, options = {}) {
|
|
|
25
25
|
const prevDuration = formatDuration(options.prevDurationMs);
|
|
26
26
|
const prevCost = options.prevCostUsd != null ? `$${Number(options.prevCostUsd || 0).toFixed(4)}` : '';
|
|
27
27
|
|
|
28
|
+
const filePath = options.filePath != null ? String(options.filePath) : '';
|
|
29
|
+
const fileName = options.fileName != null ? String(options.fileName) : '';
|
|
30
|
+
const fileSize = options.fileSize != null ? String(options.fileSize) : '';
|
|
31
|
+
const fileEvent = options.fileEvent != null ? String(options.fileEvent) : '';
|
|
32
|
+
|
|
28
33
|
return {
|
|
29
34
|
// Chaining context
|
|
30
35
|
'prev.output': prevOutput,
|
|
@@ -39,6 +44,20 @@ export function buildPromptContext(task = {}, options = {}) {
|
|
|
39
44
|
'prev.cost': prevCost,
|
|
40
45
|
'prev_cost': prevCost,
|
|
41
46
|
|
|
47
|
+
// File watcher context (#327)
|
|
48
|
+
'filePath': filePath,
|
|
49
|
+
'file.path': filePath,
|
|
50
|
+
'file_path': filePath,
|
|
51
|
+
'fileName': fileName,
|
|
52
|
+
'file.name': fileName,
|
|
53
|
+
'file_name': fileName,
|
|
54
|
+
'fileSize': fileSize,
|
|
55
|
+
'file.size': fileSize,
|
|
56
|
+
'file_size': fileSize,
|
|
57
|
+
'fileEvent': fileEvent,
|
|
58
|
+
'file.event': fileEvent,
|
|
59
|
+
'file_event': fileEvent,
|
|
60
|
+
|
|
42
61
|
// Time context
|
|
43
62
|
'date': formatDate(now),
|
|
44
63
|
'time': formatTime(now),
|
package/lib/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
2
|
import { bestEffort } from './best-effort.js';
|
|
3
3
|
import { estimateTokenCost } from './integrations.js';
|
|
4
|
+
import { resolveDynamicCredentialsInText, resolveDynamicCredentialsInObject } from './secrets.js';
|
|
4
5
|
import {
|
|
5
6
|
EXTERNAL_RUNTIMES,
|
|
6
7
|
normalizeTaskType,
|
|
@@ -144,7 +145,104 @@ export class SessionRunner {
|
|
|
144
145
|
async _run(task, signal, options = {}) {
|
|
145
146
|
logger.info(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
146
147
|
|
|
147
|
-
//
|
|
148
|
+
// Dynamic credentials resolution in-memory (#335)
|
|
149
|
+
let effectiveTask = task;
|
|
150
|
+
const resolvedSecrets = [];
|
|
151
|
+
const settings = (this.store && typeof this.store.getSettings === 'function')
|
|
152
|
+
? this.store.getSettings()
|
|
153
|
+
: {};
|
|
154
|
+
|
|
155
|
+
if (settings.enableDynamicCredentials) {
|
|
156
|
+
effectiveTask = { ...task };
|
|
157
|
+
if (typeof effectiveTask.prompt === 'string') {
|
|
158
|
+
const res = await resolveDynamicCredentialsInText(effectiveTask.prompt, this.ctx);
|
|
159
|
+
effectiveTask.prompt = res.text;
|
|
160
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
161
|
+
}
|
|
162
|
+
if (typeof effectiveTask.script === 'string') {
|
|
163
|
+
const res = await resolveDynamicCredentialsInText(effectiveTask.script, this.ctx);
|
|
164
|
+
effectiveTask.script = res.text;
|
|
165
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
166
|
+
}
|
|
167
|
+
if (typeof effectiveTask.command === 'string') {
|
|
168
|
+
const res = await resolveDynamicCredentialsInText(effectiveTask.command, this.ctx);
|
|
169
|
+
effectiveTask.command = res.text;
|
|
170
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
171
|
+
}
|
|
172
|
+
if (typeof effectiveTask.httpUrl === 'string') {
|
|
173
|
+
const res = await resolveDynamicCredentialsInText(effectiveTask.httpUrl, this.ctx);
|
|
174
|
+
effectiveTask.httpUrl = res.text;
|
|
175
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
176
|
+
}
|
|
177
|
+
if (typeof effectiveTask.httpBody === 'string') {
|
|
178
|
+
const res = await resolveDynamicCredentialsInText(effectiveTask.httpBody, this.ctx);
|
|
179
|
+
effectiveTask.httpBody = res.text;
|
|
180
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
181
|
+
}
|
|
182
|
+
if (effectiveTask.httpHeaders) {
|
|
183
|
+
const res = await resolveDynamicCredentialsInObject(effectiveTask.httpHeaders, this.ctx);
|
|
184
|
+
effectiveTask.httpHeaders = res.data;
|
|
185
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
186
|
+
}
|
|
187
|
+
if (effectiveTask.env) {
|
|
188
|
+
const res = await resolveDynamicCredentialsInObject(effectiveTask.env, this.ctx);
|
|
189
|
+
effectiveTask.env = res.data;
|
|
190
|
+
resolvedSecrets.push(...res.resolvedSecrets);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const maskSecrets = (result) => {
|
|
195
|
+
if (!result || typeof result !== 'object') return result;
|
|
196
|
+
if (resolvedSecrets.length > 0 && typeof result.output === 'string') {
|
|
197
|
+
let out = result.output;
|
|
198
|
+
for (const secret of resolvedSecrets) {
|
|
199
|
+
if (secret && String(secret).length >= 4 && out.includes(secret)) {
|
|
200
|
+
out = out.replaceAll(secret, '[REDACTED_CREDENTIAL]');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
result.output = out;
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const runInternal = async () => {
|
|
209
|
+
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
210
|
+
const runtime = normalizeTaskType(effectiveTask.type);
|
|
211
|
+
if (EXTERNAL_RUNTIMES.includes(runtime) || effectiveTask.mode === 'no-llm') {
|
|
212
|
+
return this._runExternal(effectiveTask, runtime === 'llm' ? 'script' : runtime, signal, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 2. Agent session, when the harness exposes one
|
|
216
|
+
if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
|
|
217
|
+
return this._runAgentSession(effectiveTask, runtime, signal, options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Legacy session service
|
|
221
|
+
if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
|
|
222
|
+
return this._runLegacySession(effectiveTask, options);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 4. Nothing to run through: report the prompt as triggered
|
|
226
|
+
return this._runBare(effectiveTask, options);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const outcome = await runInternal();
|
|
231
|
+
return maskSecrets(outcome);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (resolvedSecrets.length > 0 && err && err.message) {
|
|
234
|
+
for (const secret of resolvedSecrets) {
|
|
235
|
+
if (secret && String(secret).length >= 4 && err.message.includes(secret)) {
|
|
236
|
+
err.message = err.message.replaceAll(secret, '[REDACTED_CREDENTIAL]');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Legacy dispatch replaced above; original runner helpers remain unchanged below:
|
|
245
|
+
async _runOriginalDispatch(task, signal, options = {}) {
|
|
148
246
|
const runtime = normalizeTaskType(task.type);
|
|
149
247
|
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
150
248
|
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
|
|
@@ -92,6 +92,9 @@ export function pauseTask(scheduler, taskId, reason = null) {
|
|
|
92
92
|
clearTimeout(scheduler.timers.get(taskId));
|
|
93
93
|
scheduler.timers.delete(taskId);
|
|
94
94
|
}
|
|
95
|
+
if (scheduler.watcherManager) {
|
|
96
|
+
scheduler.watcherManager.unwatchTask(taskId);
|
|
97
|
+
}
|
|
95
98
|
const running = scheduler.running.get(taskId);
|
|
96
99
|
if (running) {
|
|
97
100
|
bestEffort('abort-paused-task', () => running.controller.abort(new Error('Task paused')), scheduler.logger);
|
|
@@ -115,6 +118,9 @@ export function removeTask(scheduler, taskId) {
|
|
|
115
118
|
clearTimeout(scheduler.timers.get(taskId));
|
|
116
119
|
scheduler.timers.delete(taskId);
|
|
117
120
|
}
|
|
121
|
+
if (scheduler.watcherManager) {
|
|
122
|
+
scheduler.watcherManager.unwatchTask(taskId);
|
|
123
|
+
}
|
|
118
124
|
const running = scheduler.running.get(taskId);
|
|
119
125
|
if (running) {
|
|
120
126
|
bestEffort('abort-deleted-task', () => running.controller.abort(new Error('Task deleted')), scheduler.logger);
|
package/lib/scheduler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
2
|
import { bestEffort } from './best-effort.js';
|
|
3
3
|
import { Cron } from 'croner';
|
|
4
|
+
import { FileSystemWatcherManager } from './watcher.js';
|
|
4
5
|
|
|
5
6
|
const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
|
|
6
7
|
import {
|
|
@@ -51,6 +52,7 @@ export {
|
|
|
51
52
|
removeTask,
|
|
52
53
|
resumeTask,
|
|
53
54
|
toggleTask,
|
|
55
|
+
FileSystemWatcherManager,
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
function pad(v) {
|
|
@@ -320,6 +322,7 @@ export class TaskScheduler {
|
|
|
320
322
|
this.queue = [];
|
|
321
323
|
this.groupLimits = opts.groupLimits instanceof Map ? opts.groupLimits : new Map(Object.entries(opts.groupLimits || {}));
|
|
322
324
|
this.heartbeatTimer = null;
|
|
325
|
+
this.watcherManager = new FileSystemWatcherManager(this);
|
|
323
326
|
this.logger = opts.logger || null;
|
|
324
327
|
this.isStopped = false;
|
|
325
328
|
this.generation = 0;
|
|
@@ -416,12 +419,18 @@ export class TaskScheduler {
|
|
|
416
419
|
this.scheduleTask(task);
|
|
417
420
|
}
|
|
418
421
|
this.startHeartbeatWatcher();
|
|
422
|
+
if (this.watcherManager) {
|
|
423
|
+
this.watcherManager.syncAll(tasks);
|
|
424
|
+
}
|
|
419
425
|
}
|
|
420
426
|
|
|
421
427
|
stopAll() {
|
|
422
428
|
this.isStopped = true;
|
|
423
429
|
this.generation = (this.generation || 0) + 1;
|
|
424
430
|
this.stopHeartbeatWatcher();
|
|
431
|
+
if (this.watcherManager) {
|
|
432
|
+
this.watcherManager.stopAll();
|
|
433
|
+
}
|
|
425
434
|
|
|
426
435
|
// Clear concurrency queue (#217)
|
|
427
436
|
this.queue = [];
|
|
@@ -452,6 +461,9 @@ export class TaskScheduler {
|
|
|
452
461
|
}
|
|
453
462
|
|
|
454
463
|
clearScheduled(taskId) {
|
|
464
|
+
if (this.watcherManager) {
|
|
465
|
+
this.watcherManager.unwatchTask(taskId);
|
|
466
|
+
}
|
|
455
467
|
if (this.jobs.has(taskId)) {
|
|
456
468
|
this.jobs.get(taskId).stop();
|
|
457
469
|
this.jobs.delete(taskId);
|
|
@@ -516,6 +528,9 @@ export class TaskScheduler {
|
|
|
516
528
|
this.clearScheduled(task.id);
|
|
517
529
|
if (task.status !== 'active') return;
|
|
518
530
|
this.clearRetryTimer(task.id);
|
|
531
|
+
if (this.watcherManager) {
|
|
532
|
+
this.watcherManager.watchTask(task);
|
|
533
|
+
}
|
|
519
534
|
|
|
520
535
|
try {
|
|
521
536
|
const parsed = parseScheduleExpression(task.schedule);
|
package/lib/secrets.js
CHANGED
|
@@ -120,3 +120,117 @@ export async function resolveTelegramSecrets(ctx, settings = {}) {
|
|
|
120
120
|
out.chatId = String(settings.chatId || '').trim();
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// --- Dynamic credential resolution (#335) ---
|
|
126
|
+
export const CREDENTIAL_URI_REGEX = /^credential:\/\/(?:([a-zA-Z0-9_-]+)\/)?([a-zA-Z0-9_.-]+)$/;
|
|
127
|
+
export const CREDENTIAL_URI_GLOBAL_REGEX = /credential:\/\/(?:[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_.-]+/g;
|
|
128
|
+
|
|
129
|
+
/** Check if a string is a credential:// reference URI. */
|
|
130
|
+
export function isCredentialUri(value) {
|
|
131
|
+
return typeof value === 'string' && CREDENTIAL_URI_REGEX.test(value.trim());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse a credential://[domain/]<key> URI into domain and key components. */
|
|
135
|
+
export function parseCredentialUri(value) {
|
|
136
|
+
const match = String(value || '').trim().match(CREDENTIAL_URI_REGEX);
|
|
137
|
+
if (!match) return null;
|
|
138
|
+
return {
|
|
139
|
+
domain: match[1] || null,
|
|
140
|
+
key: match[2],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a single credential:// URI using harness credentials service or environment.
|
|
146
|
+
* If not resolvable, returns the original URI.
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveCredentialReference(uri, ctx) {
|
|
149
|
+
if (typeof uri !== 'string') return uri;
|
|
150
|
+
const parsed = parseCredentialUri(uri);
|
|
151
|
+
if (!parsed) return uri;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const creds = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : null)
|
|
155
|
+
|| (ctx && ctx.credentials);
|
|
156
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
157
|
+
const hit = await creds.resolve(parsed.domain ? { domain: parsed.domain, name: parsed.key } : parsed.key);
|
|
158
|
+
if (hit && hit.value) return String(hit.value);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// credentials service unavailable
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fallback: resolve by key name
|
|
165
|
+
const fallback = await resolveCredentialValue(ctx, parsed.key);
|
|
166
|
+
if (fallback) return fallback;
|
|
167
|
+
|
|
168
|
+
// Fallback: check environment variable with DOMAIN_KEY
|
|
169
|
+
if (parsed.domain) {
|
|
170
|
+
const prefixedEnv = process.env[`${parsed.domain.toUpperCase()}_${parsed.key.toUpperCase()}`];
|
|
171
|
+
if (prefixedEnv) return String(prefixedEnv);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return uri;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Replace all credential://... references in a string with resolved secret values.
|
|
179
|
+
* Returns { text: string, resolvedSecrets: string[] }.
|
|
180
|
+
*/
|
|
181
|
+
export async function resolveDynamicCredentialsInText(text, ctx) {
|
|
182
|
+
if (typeof text !== 'string' || !text.includes('credential://')) {
|
|
183
|
+
return { text, resolvedSecrets: [] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const matches = text.match(CREDENTIAL_URI_GLOBAL_REGEX);
|
|
187
|
+
if (!matches || !matches.length) {
|
|
188
|
+
return { text, resolvedSecrets: [] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let result = text;
|
|
192
|
+
const resolvedSecrets = [];
|
|
193
|
+
|
|
194
|
+
for (const match of matches) {
|
|
195
|
+
const resolved = await resolveCredentialReference(match, ctx);
|
|
196
|
+
if (resolved && resolved !== match) {
|
|
197
|
+
result = result.replaceAll(match, resolved);
|
|
198
|
+
resolvedSecrets.push(resolved);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { text: result, resolvedSecrets };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Recursively resolve credential://... references in strings, arrays, or objects.
|
|
207
|
+
* Returns { data: any, resolvedSecrets: string[] }.
|
|
208
|
+
*/
|
|
209
|
+
export async function resolveDynamicCredentialsInObject(obj, ctx) {
|
|
210
|
+
if (obj === null || obj === undefined) return { data: obj, resolvedSecrets: [] };
|
|
211
|
+
if (typeof obj === 'string') {
|
|
212
|
+
const textRes = await resolveDynamicCredentialsInText(obj, ctx);
|
|
213
|
+
return { data: textRes.text, resolvedSecrets: textRes.resolvedSecrets };
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(obj)) {
|
|
216
|
+
const outArr = [];
|
|
217
|
+
const allSecrets = [];
|
|
218
|
+
for (const item of obj) {
|
|
219
|
+
const res = await resolveDynamicCredentialsInObject(item, ctx);
|
|
220
|
+
outArr.push(res.data);
|
|
221
|
+
allSecrets.push(...res.resolvedSecrets);
|
|
222
|
+
}
|
|
223
|
+
return { data: outArr, resolvedSecrets: allSecrets };
|
|
224
|
+
}
|
|
225
|
+
if (typeof obj === 'object') {
|
|
226
|
+
const outObj = {};
|
|
227
|
+
const allSecrets = [];
|
|
228
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
229
|
+
const res = await resolveDynamicCredentialsInObject(v, ctx);
|
|
230
|
+
outObj[k] = res.data;
|
|
231
|
+
allSecrets.push(...res.resolvedSecrets);
|
|
232
|
+
}
|
|
233
|
+
return { data: outObj, resolvedSecrets: allSecrets };
|
|
234
|
+
}
|
|
235
|
+
return { data: obj, resolvedSecrets: [] };
|
|
236
|
+
}
|
package/lib/settings.js
CHANGED
|
@@ -47,6 +47,8 @@ export const Config = z.object({
|
|
|
47
47
|
giteaLabels: z.any().default([]).description('Gitea label names or IDs for alert issues').volatile(),
|
|
48
48
|
giteaLabelIds: z.any().default({}).description('Mapping of label name to integer ID for Gitea issues').volatile(),
|
|
49
49
|
prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)').volatile(),
|
|
50
|
+
// --- Dynamic credentials resolution (#335) ---
|
|
51
|
+
enableDynamicCredentials: z.boolean().default(false).description('Enable dynamic credential://... reference resolution at runtime (#335)').volatile(),
|
|
50
52
|
// --- Global Budgets (#320) ---
|
|
51
53
|
globalDailyCostLimitUsd: z.number().default(0).description('Global daily expenditure limit in USD across all tasks (0 = unlimited)').volatile(),
|
|
52
54
|
providerHourlyRunLimits: z.object({}).default({}).description('Max runs per hour keyed by provider').volatile(),
|
|
@@ -85,7 +87,7 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
85
87
|
'pushplusTokenRef',
|
|
86
88
|
'pushplusUrl',
|
|
87
89
|
'ttsBaseUrl',
|
|
88
|
-
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled',
|
|
90
|
+
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled', 'enableDynamicCredentials',
|
|
89
91
|
'apiToken',
|
|
90
92
|
'globalDailyCostLimitUsd', 'providerHourlyRunLimits', 'modelHourlyRunLimits',
|
|
91
93
|
];
|
package/lib/task-create.js
CHANGED
|
@@ -5,6 +5,22 @@ import { parseScheduleExpression } from './scheduler.js';
|
|
|
5
5
|
import { CHANNEL_IDS } from './channels.js';
|
|
6
6
|
import { redactTaskSecrets } from './http-utils.js';
|
|
7
7
|
|
|
8
|
+
export function sanitizeFileWatch(fw) {
|
|
9
|
+
if (!fw || typeof fw !== 'object') return undefined;
|
|
10
|
+
const p = String(fw.path || '').trim();
|
|
11
|
+
if (!p) return undefined;
|
|
12
|
+
return {
|
|
13
|
+
enabled: Boolean(fw.enabled !== false),
|
|
14
|
+
path: p,
|
|
15
|
+
pattern: fw.pattern ? String(fw.pattern).trim() : '*',
|
|
16
|
+
events: Array.isArray(fw.events) && fw.events.length > 0
|
|
17
|
+
? fw.events.map(String).filter((e) => ['create', 'modify', 'delete'].includes(e))
|
|
18
|
+
: ['create', 'modify'],
|
|
19
|
+
debounceMs: Math.max(50, Number(fw.debounceMs) || 500),
|
|
20
|
+
recursive: Boolean(fw.recursive),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
export function executeCreateTask(store, scheduler, args) {
|
|
9
25
|
const taskType = normalizeTaskType(args.type);
|
|
10
26
|
const typeError = validateTaskType(taskType, args);
|
|
@@ -75,6 +91,7 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
75
91
|
tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
|
|
76
92
|
validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
|
|
77
93
|
requireApproval: Boolean(args.requireApproval),
|
|
94
|
+
fileWatch: args.fileWatch && typeof args.fileWatch === 'object' ? sanitizeFileWatch(args.fileWatch) : undefined,
|
|
78
95
|
};
|
|
79
96
|
// Issue #310 / GH #5: strip any undefined properties before store.set()
|
|
80
97
|
for (const k of Object.keys(taskData)) {
|
package/lib/task-patch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeValidators } from './validators.js';
|
|
2
|
+
import { sanitizeFileWatch } from './task-create.js';
|
|
2
3
|
/**
|
|
3
4
|
* Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
|
|
4
5
|
* tool (#49).
|
|
@@ -90,6 +91,9 @@ export function buildTaskPatch(current, body) {
|
|
|
90
91
|
if (patch.requireApproval !== undefined) {
|
|
91
92
|
patch.requireApproval = Boolean(patch.requireApproval);
|
|
92
93
|
}
|
|
94
|
+
if (patch.fileWatch !== undefined) {
|
|
95
|
+
patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
|
|
96
|
+
}
|
|
93
97
|
if (patch.validators !== undefined) {
|
|
94
98
|
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
95
99
|
}
|
package/lib/task-transfer.js
CHANGED
package/lib/watcher.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { bestEffort } from './best-effort.js';
|
|
5
|
+
|
|
6
|
+
const DISALLOWED_ROOTS = [
|
|
7
|
+
'/',
|
|
8
|
+
'/proc',
|
|
9
|
+
'/sys',
|
|
10
|
+
'/dev',
|
|
11
|
+
'/etc',
|
|
12
|
+
'/boot',
|
|
13
|
+
'/run',
|
|
14
|
+
'/var/run',
|
|
15
|
+
'c:\\',
|
|
16
|
+
'c:\\windows',
|
|
17
|
+
'c:\\windows\\system32',
|
|
18
|
+
'c:\\program files',
|
|
19
|
+
'c:\\program files (x86)',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* FileSystemWatcherManager handles fs.watch instances for tasks configured with fileWatch (#327).
|
|
24
|
+
* Provides safe path validation, debounce event filtering, pattern glob matching, and task triggers.
|
|
25
|
+
*/
|
|
26
|
+
export class FileSystemWatcherManager {
|
|
27
|
+
constructor(scheduler) {
|
|
28
|
+
this.scheduler = scheduler;
|
|
29
|
+
this.watchers = new Map(); // taskId -> { watcher, config, dir }
|
|
30
|
+
this.debounceTimers = new Map(); // `${taskId}:${filename}` -> timer
|
|
31
|
+
this.knownFiles = new Map(); // taskId -> Set(filename)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isSafePath(targetPath) {
|
|
35
|
+
if (!targetPath || typeof targetPath !== 'string') {
|
|
36
|
+
return { safe: false, error: 'Path must be a non-empty string' };
|
|
37
|
+
}
|
|
38
|
+
const resolved = path.resolve(targetPath.trim());
|
|
39
|
+
const lower = resolved.toLowerCase();
|
|
40
|
+
|
|
41
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
42
|
+
if (lower === root || lower === root.replace(/\\/g, '/')) {
|
|
43
|
+
return { safe: false, error: `Watched path "${resolved}" is a disallowed system root directory` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (resolved === path.parse(resolved).root) {
|
|
48
|
+
return { safe: false, error: `Watched path "${resolved}" is a filesystem root` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { safe: true, resolved };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
matchPattern(filename, pattern) {
|
|
55
|
+
if (!pattern || pattern.trim() === '' || pattern === '*') return true;
|
|
56
|
+
const parts = pattern.split(',').map((p) => p.trim()).filter(Boolean);
|
|
57
|
+
if (!parts.length) return true;
|
|
58
|
+
|
|
59
|
+
return parts.some((p) => {
|
|
60
|
+
const regexStr = '^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
|
|
61
|
+
try {
|
|
62
|
+
return new RegExp(regexStr, 'i').test(filename);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
watchTask(task) {
|
|
70
|
+
if (!task || !task.id) return;
|
|
71
|
+
const fw = task.fileWatch;
|
|
72
|
+
|
|
73
|
+
if (!fw || !fw.enabled || task.status !== 'active') {
|
|
74
|
+
this.unwatchTask(task.id);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const check = this.isSafePath(fw.path);
|
|
79
|
+
if (!check.safe) {
|
|
80
|
+
logger.warn(`[dsh-cron] Unsafe fileWatch path for task "${task.title}" (${task.id}): ${check.error}`);
|
|
81
|
+
this.unwatchTask(task.id);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const watchDir = check.resolved;
|
|
86
|
+
if (!fs.existsSync(watchDir)) {
|
|
87
|
+
logger.warn(`[dsh-cron] Watched directory does not exist for task "${task.title}" (${task.id}): ${watchDir}`);
|
|
88
|
+
this.unwatchTask(task.id);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const current = this.watchers.get(task.id);
|
|
93
|
+
const configSig = JSON.stringify({
|
|
94
|
+
dir: watchDir,
|
|
95
|
+
pattern: fw.pattern || '*',
|
|
96
|
+
events: fw.events || ['create', 'modify'],
|
|
97
|
+
debounceMs: fw.debounceMs || 500,
|
|
98
|
+
recursive: Boolean(fw.recursive),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (current && current.sig === configSig) {
|
|
102
|
+
return; // Already watching with exact configuration
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.unwatchTask(task.id);
|
|
106
|
+
|
|
107
|
+
// Initial snapshot of known files in directory
|
|
108
|
+
const known = new Set();
|
|
109
|
+
try {
|
|
110
|
+
const entries = fs.readdirSync(watchDir);
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
known.add(entry);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore readdir errors
|
|
116
|
+
}
|
|
117
|
+
this.knownFiles.set(task.id, known);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const watcher = fs.watch(watchDir, { recursive: Boolean(fw.recursive) }, (rawEvent, filename) => {
|
|
121
|
+
if (!filename) return;
|
|
122
|
+
// Ignore dotfiles / temp files unless pattern specifically targets them
|
|
123
|
+
if (filename.startsWith('.') && (!fw.pattern || !fw.pattern.startsWith('.'))) return;
|
|
124
|
+
|
|
125
|
+
const debounceMs = Math.max(50, Number(fw.debounceMs) || 500);
|
|
126
|
+
const timerKey = `${task.id}:${filename}`;
|
|
127
|
+
|
|
128
|
+
if (this.debounceTimers.has(timerKey)) {
|
|
129
|
+
clearTimeout(this.debounceTimers.get(timerKey));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
this.debounceTimers.delete(timerKey);
|
|
134
|
+
this._handleEvent(task, watchDir, filename, rawEvent);
|
|
135
|
+
}, debounceMs);
|
|
136
|
+
|
|
137
|
+
if (timer.unref) timer.unref();
|
|
138
|
+
this.debounceTimers.set(timerKey, timer);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
watcher.on('error', (err) => {
|
|
142
|
+
logger.error(`[dsh-cron] Watcher error for task "${task.title}" (${task.id}): ${err.message}`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.watchers.set(task.id, { watcher, sig: configSig, dir: watchDir });
|
|
146
|
+
logger.info(`[dsh-cron] File watcher active on "${watchDir}" for task "${task.title}" (${task.id})`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.error(`[dsh-cron] Failed to start file watcher for task "${task.title}" (${task.id}): ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_handleEvent(task, watchDir, filename, rawEvent) {
|
|
153
|
+
if (!this.scheduler || this.scheduler.isStopped) return;
|
|
154
|
+
const fw = task.fileWatch || {};
|
|
155
|
+
if (!fw.enabled) return;
|
|
156
|
+
|
|
157
|
+
if (!this.matchPattern(filename, fw.pattern)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const fullFilePath = path.join(watchDir, filename);
|
|
162
|
+
const exists = fs.existsSync(fullFilePath);
|
|
163
|
+
let event = 'modify';
|
|
164
|
+
let fileSize = 0;
|
|
165
|
+
|
|
166
|
+
const taskKnown = this.knownFiles.get(task.id) || new Set();
|
|
167
|
+
|
|
168
|
+
if (!exists) {
|
|
169
|
+
event = 'delete';
|
|
170
|
+
fileSize = 0;
|
|
171
|
+
taskKnown.delete(filename);
|
|
172
|
+
} else {
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.statSync(fullFilePath);
|
|
175
|
+
if (stat.isDirectory()) return; // skip directories
|
|
176
|
+
fileSize = stat.size;
|
|
177
|
+
if (taskKnown.has(filename)) {
|
|
178
|
+
event = 'modify';
|
|
179
|
+
} else {
|
|
180
|
+
event = 'create';
|
|
181
|
+
taskKnown.add(filename);
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return; // Stat failed (e.g. temporary file disappeared)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const allowedEvents = Array.isArray(fw.events) && fw.events.length > 0
|
|
189
|
+
? fw.events
|
|
190
|
+
: ['create', 'modify'];
|
|
191
|
+
|
|
192
|
+
if (!allowedEvents.includes(event)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
logger.info(`[dsh-cron] fileWatch triggered: "${event}" on "${filename}" for task "${task.title}" (${task.id})`);
|
|
197
|
+
this.scheduler.runTask(task.id, {
|
|
198
|
+
filePath: fullFilePath,
|
|
199
|
+
fileName: filename,
|
|
200
|
+
fileSize,
|
|
201
|
+
fileEvent: event,
|
|
202
|
+
trigger: 'fileWatch',
|
|
203
|
+
}).catch((err) => {
|
|
204
|
+
logger.error(`[dsh-cron] File watcher triggered execution error for task ${task.id}: ${err.message}`);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
unwatchTask(taskId) {
|
|
209
|
+
const entry = this.watchers.get(taskId);
|
|
210
|
+
if (entry) {
|
|
211
|
+
bestEffort('watcher-close', () => entry.watcher.close());
|
|
212
|
+
this.watchers.delete(taskId);
|
|
213
|
+
}
|
|
214
|
+
this.knownFiles.delete(taskId);
|
|
215
|
+
for (const [key, timer] of this.debounceTimers.entries()) {
|
|
216
|
+
if (key.startsWith(`${taskId}:`)) {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
this.debounceTimers.delete(key);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
stopAll() {
|
|
224
|
+
for (const [taskId] of this.watchers.entries()) {
|
|
225
|
+
this.unwatchTask(taskId);
|
|
226
|
+
}
|
|
227
|
+
this.watchers.clear();
|
|
228
|
+
for (const [, timer] of this.debounceTimers.entries()) {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
}
|
|
231
|
+
this.debounceTimers.clear();
|
|
232
|
+
this.knownFiles.clear();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
syncAll(tasks = []) {
|
|
236
|
+
const activeTaskIds = new Set();
|
|
237
|
+
for (const task of tasks) {
|
|
238
|
+
if (task && task.id && task.status === 'active' && task.fileWatch?.enabled) {
|
|
239
|
+
activeTaskIds.add(task.id);
|
|
240
|
+
this.watchTask(task);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const [taskId] of this.watchers.entries()) {
|
|
244
|
+
if (!activeTaskIds.has(taskId)) {
|
|
245
|
+
this.unwatchTask(taskId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|