@goodandready/dsh-cron 0.2.3 → 0.2.4
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 +74 -14
- package/docs/README.ru.md +73 -13
- package/docs/README.zh.md +78 -13
- package/docs/design/DESIGN.md +13 -7
- package/lib/channels.js +422 -0
- package/lib/client.js +594 -23
- package/lib/index.js +210 -16
- package/lib/integrations.js +3 -1
- package/lib/runner.js +185 -28
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +66 -60
- package/lib/secrets.js +123 -0
- package/lib/store.js +91 -21
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
package/lib/runtimes.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
|
|
3
|
+
*
|
|
4
|
+
* Every builder is a pure function returning the process invocation
|
|
5
|
+
* ({ file, args, env }) or the HTTP request description, so the whole matrix
|
|
6
|
+
* is unit-testable without spawning anything.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
|
|
13
|
+
export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
|
|
14
|
+
export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
|
|
15
|
+
|
|
16
|
+
/** Types that execute code and therefore need the HTTP confirm header. */
|
|
17
|
+
export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
18
|
+
|
|
19
|
+
export function normalizeTaskType(type) {
|
|
20
|
+
return TASK_TYPES.includes(type) ? type : 'llm';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a leading script path from trailing CLI arguments.
|
|
25
|
+
* Quoted segments are preserved as single arguments.
|
|
26
|
+
*/
|
|
27
|
+
export function splitCommandLine(text) {
|
|
28
|
+
const trimmed = String(text || '').trim();
|
|
29
|
+
if (!trimmed) return { path: '', args: [] };
|
|
30
|
+
const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
31
|
+
const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
32
|
+
return { path: clean[0] || '', args: clean.slice(1) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function looksLikeScriptFile(token, extensions) {
|
|
36
|
+
if (!token) return false;
|
|
37
|
+
const ext = path.extname(token).toLowerCase();
|
|
38
|
+
if (extensions.includes(ext)) return true;
|
|
39
|
+
try {
|
|
40
|
+
return fs.existsSync(token) && fs.statSync(token).isFile();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
|
|
47
|
+
export function buildNodeInvocation(task, cwd) {
|
|
48
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
49
|
+
if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
|
|
50
|
+
return { file: process.execPath, args: [filePath, ...args] };
|
|
51
|
+
}
|
|
52
|
+
const code = String(task.prompt || '');
|
|
53
|
+
const isEsm = /\bimport\s|\bexport\s/.test(code);
|
|
54
|
+
const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
|
|
55
|
+
return { file: task.nodePath || process.execPath, args: nodeArgs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
|
|
59
|
+
export function detectPython(task, cwd) {
|
|
60
|
+
if (task.pythonPath) return task.pythonPath;
|
|
61
|
+
const candidates = [];
|
|
62
|
+
if (process.env.VIRTUAL_ENV) {
|
|
63
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
|
|
64
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
|
|
65
|
+
}
|
|
66
|
+
const base = cwd || process.cwd();
|
|
67
|
+
candidates.push(path.join(base, '.venv', 'bin', 'python'));
|
|
68
|
+
candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
|
|
69
|
+
candidates.push(path.join(base, 'venv', 'bin', 'python'));
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** #4: resolve a Python invocation (inline code or a .py file). */
|
|
79
|
+
export function buildPythonInvocation(task, cwd) {
|
|
80
|
+
const interpreter = detectPython(task, cwd);
|
|
81
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
82
|
+
if (looksLikeScriptFile(filePath, ['.py'])) {
|
|
83
|
+
return { file: interpreter, args: [filePath, ...args] };
|
|
84
|
+
}
|
|
85
|
+
return { file: interpreter, args: ['-c', String(task.prompt || '')] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** #7: build the HTTP trigger request description. */
|
|
89
|
+
export function buildHttpRequest(task) {
|
|
90
|
+
const raw = String(task.httpUrl || task.prompt || '').trim();
|
|
91
|
+
if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error(`Invalid HTTP URL: ${raw}`);
|
|
97
|
+
}
|
|
98
|
+
let headers = {};
|
|
99
|
+
if (task.httpHeaders) {
|
|
100
|
+
if (typeof task.httpHeaders === 'object') {
|
|
101
|
+
headers = { ...task.httpHeaders };
|
|
102
|
+
} else {
|
|
103
|
+
try {
|
|
104
|
+
headers = JSON.parse(String(task.httpHeaders));
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error('httpHeaders must be a JSON object');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const method = String(task.httpMethod || 'GET').toUpperCase();
|
|
111
|
+
const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
|
|
112
|
+
return { url: url.toString(), method, headers, body };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** #9: build a remote SSH invocation. */
|
|
116
|
+
export function buildSshInvocation(task) {
|
|
117
|
+
const target = String(task.sshTarget || '').trim();
|
|
118
|
+
if (!target) throw new Error('SSH task requires sshTarget (user@host)');
|
|
119
|
+
const args = [];
|
|
120
|
+
if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
|
|
121
|
+
if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
|
|
122
|
+
args.push(target, String(task.prompt || 'true'));
|
|
123
|
+
return { file: 'ssh', args };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** #8: build a Docker run invocation. */
|
|
127
|
+
export function buildDockerInvocation(task, env) {
|
|
128
|
+
const image = String(task.dockerImage || '').trim();
|
|
129
|
+
if (!image) throw new Error('Docker task requires dockerImage');
|
|
130
|
+
const args = ['run', '--rm'];
|
|
131
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
132
|
+
if (key === 'PATH' || key === 'HOME') continue; // container owns those
|
|
133
|
+
args.push('-e', `${key}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
|
|
136
|
+
args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
|
|
137
|
+
return { file: 'docker', args };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** #38: merge task env vars over the base environment. */
|
|
141
|
+
export function buildEnv(task, baseEnv) {
|
|
142
|
+
const env = { ...(baseEnv || process.env) };
|
|
143
|
+
if (task && task.env && typeof task.env === 'object') {
|
|
144
|
+
for (const [key, value] of Object.entries(task.env)) {
|
|
145
|
+
if (value === null || value === undefined) delete env[key];
|
|
146
|
+
else env[key] = String(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return env;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch an external (non-LLM) runtime to its invocation.
|
|
154
|
+
* Returns { file, args } plus an optional `http` descriptor for the fetch path.
|
|
155
|
+
*/
|
|
156
|
+
export function buildInvocation(task, cwd, baseEnv) {
|
|
157
|
+
const type = normalizeTaskType(task.type);
|
|
158
|
+
const env = buildEnv(task, baseEnv);
|
|
159
|
+
switch (type) {
|
|
160
|
+
case 'node':
|
|
161
|
+
return { type, env, ...buildNodeInvocation(task, cwd) };
|
|
162
|
+
case 'python':
|
|
163
|
+
return { type, env, ...buildPythonInvocation(task, cwd) };
|
|
164
|
+
case 'http':
|
|
165
|
+
return { type, env, http: buildHttpRequest(task) };
|
|
166
|
+
case 'ssh':
|
|
167
|
+
return { type, env, ...buildSshInvocation(task) };
|
|
168
|
+
case 'docker':
|
|
169
|
+
return { type, env, ...buildDockerInvocation(task, env) };
|
|
170
|
+
case 'script':
|
|
171
|
+
default:
|
|
172
|
+
return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
|
|
173
|
+
}
|
|
174
|
+
}
|
package/lib/scheduler.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
3
|
-
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
4
2
|
|
|
5
3
|
function pad(v) {
|
|
6
4
|
return String(v).padStart(2, '0');
|
|
@@ -142,6 +140,8 @@ export class TaskScheduler {
|
|
|
142
140
|
this.executeFn = executeFn;
|
|
143
141
|
this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
|
|
144
142
|
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
143
|
+
this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
|
|
144
|
+
this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
|
|
145
145
|
this.jobs = new Map(); // taskId -> Cron instance
|
|
146
146
|
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
147
147
|
this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
|
|
@@ -266,9 +266,11 @@ export class TaskScheduler {
|
|
|
266
266
|
this.store.set(task);
|
|
267
267
|
|
|
268
268
|
// If target is in the past, or when delay triggers:
|
|
269
|
-
const timer = setTimeout(
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
270
|
this.timers.delete(task.id);
|
|
271
|
-
|
|
271
|
+
this.runTask(task.id).catch((err) => {
|
|
272
|
+
console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
|
|
273
|
+
});
|
|
272
274
|
}, delay);
|
|
273
275
|
|
|
274
276
|
this.timers.set(task.id, timer);
|
|
@@ -276,7 +278,11 @@ export class TaskScheduler {
|
|
|
276
278
|
}
|
|
277
279
|
|
|
278
280
|
// Handle Recurring Cron job
|
|
279
|
-
const job = new Cron(parsed.cronPattern, {
|
|
281
|
+
const job = new Cron(parsed.cronPattern, {
|
|
282
|
+
protect: true,
|
|
283
|
+
catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
|
|
284
|
+
...(timezone ? { timezone } : {}),
|
|
285
|
+
}, async () => {
|
|
280
286
|
await this.runTask(task.id);
|
|
281
287
|
});
|
|
282
288
|
|
|
@@ -388,7 +394,11 @@ export class TaskScheduler {
|
|
|
388
394
|
costUsd,
|
|
389
395
|
sessionId,
|
|
390
396
|
};
|
|
391
|
-
|
|
397
|
+
try {
|
|
398
|
+
this.store.recordRun(taskId, runInfo);
|
|
399
|
+
} catch (recordErr) {
|
|
400
|
+
console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
|
|
401
|
+
}
|
|
392
402
|
|
|
393
403
|
// Clean up running map
|
|
394
404
|
const finishedRun = this.running.get(taskId);
|
|
@@ -398,58 +408,48 @@ export class TaskScheduler {
|
|
|
398
408
|
|
|
399
409
|
const queuedCount = finishedRun?.queueCount || 0;
|
|
400
410
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const botToken = settings.botToken;
|
|
406
|
-
const chatId = settings.chatId;
|
|
407
|
-
if (botToken && chatId) {
|
|
408
|
-
const text = formatTaskTelegramMessage(task, runInfo);
|
|
409
|
-
sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
|
|
410
|
-
console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
} catch (notifyErr) {
|
|
415
|
-
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
416
|
-
}
|
|
411
|
+
await this.deliverNotifications(task, runInfo);
|
|
412
|
+
this.finishRun(task, taskId, status, queuedCount);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
417
415
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
createKanbanCard({
|
|
434
|
-
title: cardTitle,
|
|
435
|
-
body: cardBody,
|
|
436
|
-
board: 'main',
|
|
437
|
-
column: isErr ? 'backlog' : 'done',
|
|
438
|
-
labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
|
|
439
|
-
kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
|
|
440
|
-
}).then((res) => {
|
|
441
|
-
if (res.success) {
|
|
442
|
-
console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
|
|
443
|
-
}
|
|
444
|
-
}).catch((kErr) => {
|
|
445
|
-
console.error('[dsh-cron] kanban card error:', kErr.message);
|
|
446
|
-
});
|
|
416
|
+
/**
|
|
417
|
+
* Deliver a finished run to every configured channel (#26).
|
|
418
|
+
* Never throws: a broken or slow channel is reported, not propagated, so the
|
|
419
|
+
* cron callback (and therefore the next tick) is never blocked by delivery.
|
|
420
|
+
*/
|
|
421
|
+
async deliverNotifications(task, runInfo) {
|
|
422
|
+
try {
|
|
423
|
+
let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
424
|
+
// #51: resolve delivery credentials through the credentials service
|
|
425
|
+
// (credential reference → legacy token → gateway file → env).
|
|
426
|
+
if (this.resolveSecrets) {
|
|
427
|
+
try {
|
|
428
|
+
settings = { ...settings, ...(await this.resolveSecrets(settings)) };
|
|
429
|
+
} catch (secErr) {
|
|
430
|
+
console.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
447
431
|
}
|
|
448
|
-
} catch (kanbanErr) {
|
|
449
|
-
console.error('[dsh-cron] kanban error:', kanbanErr.message);
|
|
450
432
|
}
|
|
433
|
+
if (typeof this.deliver !== 'function') return;
|
|
434
|
+
const result = await this.deliver(task, runInfo, settings);
|
|
435
|
+
if (result && Array.isArray(result.delivered) && result.delivered.length) {
|
|
436
|
+
console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
|
|
437
|
+
}
|
|
438
|
+
if (result && Array.isArray(result.failures) && result.failures.length) {
|
|
439
|
+
console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
|
|
440
|
+
}
|
|
441
|
+
} catch (notifyErr) {
|
|
442
|
+
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
451
445
|
|
|
452
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Post-run bookkeeping: one-shot completion, next-run timestamp, retry
|
|
448
|
+
* backoff and draining a queued run. Each part is guarded so a store error
|
|
449
|
+
* cannot reject the cron callback.
|
|
450
|
+
*/
|
|
451
|
+
finishRun(task, taskId, status, queuedCount) {
|
|
452
|
+
try {
|
|
453
453
|
if (task.oneShot) {
|
|
454
454
|
task.status = 'completed';
|
|
455
455
|
task.nextRunAt = null;
|
|
@@ -462,8 +462,12 @@ export class TaskScheduler {
|
|
|
462
462
|
this.store.set(task);
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
|
|
467
|
+
}
|
|
465
468
|
|
|
466
|
-
|
|
469
|
+
try {
|
|
470
|
+
// Retry with exponential backoff on failure (#18)
|
|
467
471
|
const failed = status === 'error' || status === 'timeout';
|
|
468
472
|
const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
|
|
469
473
|
if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
|
|
@@ -481,12 +485,14 @@ export class TaskScheduler {
|
|
|
481
485
|
task.attempts = 0;
|
|
482
486
|
this.store.set(task);
|
|
483
487
|
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
|
|
490
|
+
}
|
|
484
491
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
492
|
+
if (queuedCount > 0) {
|
|
493
|
+
setImmediate(() => {
|
|
494
|
+
this.runTask(taskId);
|
|
495
|
+
});
|
|
490
496
|
}
|
|
491
497
|
}
|
|
492
498
|
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential handling for dsh-cron (#51).
|
|
3
|
+
*
|
|
4
|
+
* Settings store only the NAME of a credential (an environment-variable-style
|
|
5
|
+
* reference); the value is resolved at send time through the DSH credentials
|
|
6
|
+
* service, with an environment fallback. This mirrors the pattern already
|
|
7
|
+
* used by dsh-clinebot and dsh-agentrouter.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import { getDshSettingsPath } from './telegram.js';
|
|
12
|
+
|
|
13
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
const TOKEN_LIKE = /^[A-Za-z0-9:_-]{24,}$/;
|
|
15
|
+
|
|
16
|
+
export function isCredentialRefName(value) {
|
|
17
|
+
return REF_PATTERN.test(String(value || '').trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Heuristic: a pasted secret instead of a credential name. */
|
|
21
|
+
export function looksLikeSecret(value) {
|
|
22
|
+
const s = String(value || '').trim();
|
|
23
|
+
if (!s || isCredentialRefName(s)) return false;
|
|
24
|
+
return TOKEN_LIKE.test(s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a credential-reference field. Returns { ok, name } or
|
|
29
|
+
* { ok: false, error } with actionable text for the settings card.
|
|
30
|
+
*/
|
|
31
|
+
export function credentialRefStatus(value) {
|
|
32
|
+
const name = String(value || '').trim();
|
|
33
|
+
if (!name) return { ok: true, name: '' };
|
|
34
|
+
if (looksLikeSecret(name)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: 'That looks like a secret value. Store it in DSH credentials and enter only the credential name (for example CRON_TELEGRAM_BOT_TOKEN).',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!isCredentialRefName(name)) {
|
|
41
|
+
return { ok: false, error: 'Credential name must look like an environment variable name, e.g. CRON_TELEGRAM_BOT_TOKEN.' };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, name };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a credential through the harness service, then the environment. */
|
|
47
|
+
export async function resolveCredentialValue(ctx, refName) {
|
|
48
|
+
const name = String(refName || '').trim();
|
|
49
|
+
if (!name) return null;
|
|
50
|
+
try {
|
|
51
|
+
const creds = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : null)
|
|
52
|
+
|| (ctx && ctx.credentials);
|
|
53
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
54
|
+
// Prefer the harness ref factory; fall back to the plain name when the
|
|
55
|
+
// package is not installed (keeps tests and trimmed builds working).
|
|
56
|
+
let ref = name;
|
|
57
|
+
try {
|
|
58
|
+
const mod = await import('@deepseek-ai/dsh-credentials');
|
|
59
|
+
if (mod && typeof mod.credentialRef === 'function') ref = mod.credentialRef(name);
|
|
60
|
+
} catch {}
|
|
61
|
+
const hit = await creds.resolve(ref);
|
|
62
|
+
if (hit && hit.value) return String(hit.value);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// credentials service unavailable — fall back to the environment
|
|
66
|
+
}
|
|
67
|
+
const fromEnv = process.env[name];
|
|
68
|
+
return fromEnv ? String(fromEnv) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read the best-effort Telegram defaults from the messenger-gateway file. */
|
|
72
|
+
function readGatewayToken() {
|
|
73
|
+
try {
|
|
74
|
+
const p = getDshSettingsPath();
|
|
75
|
+
if (!fs.existsSync(p)) return null;
|
|
76
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
let inside = false;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (/^[^\s#]/.test(line)) inside = line.startsWith('dsh-messenger-gateway:');
|
|
81
|
+
if (!inside) continue;
|
|
82
|
+
const hit = /^[ \t]+botToken:[ \t]*(.+?)[ \t]*$/.exec(line);
|
|
83
|
+
if (hit) return hit[1].replace(/^['"]|['"]$/g, '').trim() || null;
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the Telegram delivery secrets for a run.
|
|
91
|
+
* Precedence: credential reference → legacy stored token → messenger-gateway
|
|
92
|
+
* settings file → environment variable.
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveTelegramSecrets(ctx, settings = {}) {
|
|
95
|
+
const out = { botToken: '', chatId: '', tokenSource: 'none' };
|
|
96
|
+
|
|
97
|
+
const fromRef = await resolveCredentialValue(ctx, settings.botTokenRef);
|
|
98
|
+
if (fromRef) {
|
|
99
|
+
out.botToken = fromRef;
|
|
100
|
+
out.tokenSource = 'credential';
|
|
101
|
+
}
|
|
102
|
+
if (!out.botToken && settings.botToken) {
|
|
103
|
+
out.botToken = String(settings.botToken);
|
|
104
|
+
out.tokenSource = 'legacy-settings';
|
|
105
|
+
}
|
|
106
|
+
if (!out.botToken) {
|
|
107
|
+
const gateway = readGatewayToken();
|
|
108
|
+
if (gateway) {
|
|
109
|
+
out.botToken = gateway;
|
|
110
|
+
out.tokenSource = 'messenger-gateway';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!out.botToken) {
|
|
114
|
+
const fromEnv = await resolveCredentialValue(ctx, 'CRON_TELEGRAM_BOT_TOKEN');
|
|
115
|
+
if (fromEnv) {
|
|
116
|
+
out.botToken = fromEnv;
|
|
117
|
+
out.tokenSource = 'env';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out.chatId = String(settings.chatId || '').trim();
|
|
122
|
+
return out;
|
|
123
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -2,11 +2,31 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getDshDefaultTelegramCredentials } from './telegram.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Where plugin data lives.
|
|
7
|
+
*
|
|
8
|
+
* DSH_DATA_DIR wins when the harness sets it. Otherwise the profile home
|
|
9
|
+
* (DSH_HOME) is authoritative: falling back to ~/.dsh would make an isolated
|
|
10
|
+
* profile — a test contour, a second profile — write its tasks into another
|
|
11
|
+
* home's data directory, which is how the MiniPC test cycle leaked a task into
|
|
12
|
+
* the non-test profile.
|
|
13
|
+
*/
|
|
5
14
|
export function getDefaultStorePath() {
|
|
6
|
-
const base = process.env.DSH_DATA_DIR
|
|
15
|
+
const base = process.env.DSH_DATA_DIR
|
|
16
|
+
|| (process.env.DSH_HOME
|
|
17
|
+
? path.join(process.env.DSH_HOME, 'data')
|
|
18
|
+
: path.join(process.env.HOME || '.', '.dsh', 'data'));
|
|
7
19
|
return path.join(base, 'cron', 'tasks.json');
|
|
8
20
|
}
|
|
9
21
|
|
|
22
|
+
/** Mask a secret for transport to the browser, keeping a readable hint. */
|
|
23
|
+
function maskSecretValue(value) {
|
|
24
|
+
const s = String(value == null ? '' : value);
|
|
25
|
+
if (!s) return '';
|
|
26
|
+
if (s.length <= 8) return '••••••••';
|
|
27
|
+
return s.slice(0, 4) + '••••••••' + s.slice(-3);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
export class TaskStore {
|
|
11
31
|
constructor(filePath) {
|
|
12
32
|
this.filePath = filePath || getDefaultStorePath();
|
|
@@ -70,14 +90,15 @@ export class TaskStore {
|
|
|
70
90
|
|
|
71
91
|
getSettings() {
|
|
72
92
|
const defaults = getDshDefaultTelegramCredentials();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
const base = { ...this.settings };
|
|
94
|
+
base.botToken = base.botToken !== undefined ? base.botToken : (defaults.botToken || '');
|
|
95
|
+
base.botTokenRef = base.botTokenRef || '';
|
|
96
|
+
base.chatId = base.chatId !== undefined ? base.chatId : (defaults.chatId || '');
|
|
97
|
+
base.notifyTelegram = Boolean(base.notifyTelegram);
|
|
98
|
+
base.onlyOnFailure = Boolean(base.onlyOnFailure);
|
|
99
|
+
base.kanbanBaseUrl = base.kanbanBaseUrl || 'http://127.0.0.1:3000';
|
|
100
|
+
base.hasDefaultCredentials = Boolean(defaults.botToken && defaults.chatId);
|
|
101
|
+
return base;
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
getClientSettings() {
|
|
@@ -85,38 +106,87 @@ export class TaskStore {
|
|
|
85
106
|
const isCustom = Boolean(this.settings.botToken);
|
|
86
107
|
const hasToken = Boolean(raw.botToken);
|
|
87
108
|
const masked = hasToken ? (raw.botToken.length > 8 ? raw.botToken.slice(0, 4) + '••••••••' + raw.botToken.slice(-3) : '••••••••') : '';
|
|
88
|
-
|
|
109
|
+
const { botToken, ...rest } = raw;
|
|
110
|
+
const out = {
|
|
111
|
+
...rest,
|
|
89
112
|
botToken: masked,
|
|
90
113
|
botTokenMasked: masked,
|
|
91
114
|
hasBotToken: hasToken,
|
|
92
115
|
hasCustomBotToken: isCustom,
|
|
93
|
-
chatId: raw.chatId,
|
|
94
|
-
notifyTelegram: raw.notifyTelegram,
|
|
95
|
-
onlyOnFailure: raw.onlyOnFailure,
|
|
96
|
-
hasDefaultCredentials: raw.hasDefaultCredentials,
|
|
97
|
-
kanbanBaseUrl: raw.kanbanBaseUrl,
|
|
98
116
|
};
|
|
117
|
+
// Webhook URLs and the Bark device key carry an embedded secret, so they
|
|
118
|
+
// are never sent to the browser in clear text either.
|
|
119
|
+
for (const key of TaskStore.MASKED_SETTING_KEYS) {
|
|
120
|
+
if (key === 'botToken') continue;
|
|
121
|
+
out[key] = maskSecretValue(raw[key]);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Raw secret values that must not be stored in plugin settings; the
|
|
128
|
+
* corresponding *Ref credential names are the supported way to configure
|
|
129
|
+
* these channels.
|
|
130
|
+
*/
|
|
131
|
+
static get FORBIDDEN_SETTING_KEYS() {
|
|
132
|
+
return ['smtpPassword', 'giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Settings that hold a secret and are therefore masked on read: tokens,
|
|
137
|
+
* passwords, webhook URLs (the URL embeds the auth token) and the Bark key.
|
|
138
|
+
* These are stored in the plugin settings file; the credential-reference
|
|
139
|
+
* fields (botTokenRef, smtpPasswordRef, …) take precedence over them.
|
|
140
|
+
*/
|
|
141
|
+
static get MASKED_SETTING_KEYS() {
|
|
142
|
+
return ['botToken', 'smtpPassword', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
|
|
99
143
|
}
|
|
100
144
|
|
|
101
145
|
saveSettings(newSettings = {}) {
|
|
146
|
+
const patch = { ...newSettings };
|
|
147
|
+
|
|
148
|
+
for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
|
|
149
|
+
if (key in patch) {
|
|
150
|
+
console.warn(`[dsh-cron] refusing to store raw secret "${key}" in settings — use a credential reference instead`);
|
|
151
|
+
delete patch[key];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
let botTokenToSave = this.settings.botToken || '';
|
|
103
|
-
if (
|
|
104
|
-
const val = String(
|
|
156
|
+
if (patch.botToken !== undefined) {
|
|
157
|
+
const val = String(patch.botToken).trim();
|
|
105
158
|
// If client sends masked token or empty string while custom token existed, don't overwrite with dots
|
|
106
159
|
if (val && !val.includes('••') && !val.includes('****')) {
|
|
107
160
|
botTokenToSave = val;
|
|
108
161
|
} else if (val === '') {
|
|
109
162
|
botTokenToSave = '';
|
|
110
163
|
}
|
|
164
|
+
delete patch.botToken;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A masked value echoed back by the UI must never overwrite the real one.
|
|
168
|
+
for (const key of TaskStore.MASKED_SETTING_KEYS) {
|
|
169
|
+
if (key === 'botToken') continue;
|
|
170
|
+
const val = patch[key];
|
|
171
|
+
if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
|
|
172
|
+
delete patch[key];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
177
|
+
if (value === undefined) delete patch[key];
|
|
178
|
+
else if (typeof value === 'string') patch[key] = value.trim();
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
this.settings = {
|
|
114
182
|
...this.settings,
|
|
183
|
+
...patch,
|
|
115
184
|
botToken: botTokenToSave,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
185
|
+
kanbanBaseUrl: patch.kanbanBaseUrl !== undefined
|
|
186
|
+
? (patch.kanbanBaseUrl || 'http://127.0.0.1:3000')
|
|
187
|
+
: (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
|
|
188
|
+
notifyTelegram: patch.notifyTelegram !== undefined ? Boolean(patch.notifyTelegram) : Boolean(this.settings.notifyTelegram),
|
|
189
|
+
onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
|
|
120
190
|
};
|
|
121
191
|
this.save();
|
|
122
192
|
return this.getSettings();
|