@goodandready/dsh-cron 0.2.39 → 0.2.40

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.
@@ -15,9 +15,18 @@ import {
15
15
  } from './http-utils.js';
16
16
  import { MANAGED_BY_CONFIG } from './config-jobs.js';
17
17
 
18
- export function handleTaskExport({ store, res }) {
19
- const doc = buildTaskExport(store.list({ status: 'all' }));
20
- sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
18
+ export function handleTaskExport({ store, req, res }) {
19
+ let sanitize = false;
20
+ if (req && req.url) {
21
+ try {
22
+ const parsed = new URL(req.url, 'http://127.0.0.1');
23
+ sanitize = parsed.searchParams.get('sanitize') === 'true' || parsed.searchParams.get('redact') === 'true';
24
+ } catch (urlErr) {
25
+ logger.debug('[dsh-cron] export url parse fallback:', urlErr.message);
26
+ }
27
+ }
28
+ const doc = buildTaskExport(store.list({ status: 'all' }), { sanitize });
29
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length, sanitized: sanitize });
21
30
  }
22
31
 
23
32
  export async function handleTaskImport({ store, scheduler, req, res }) {
package/lib/api.js CHANGED
@@ -101,7 +101,7 @@ export function createCronApiHandler(store, scheduler, collection) {
101
101
  sendJson(res, 405, NOT_ALLOWED);
102
102
  return;
103
103
  }
104
- handleTaskExport({ store, res });
104
+ handleTaskExport({ store, req, res });
105
105
  return;
106
106
  }
107
107
  if (id === 'import') {
package/lib/client.js CHANGED
@@ -88,7 +88,8 @@ window.__ModuleLoader__.load({
88
88
  'transfer.exported': 'Exported {count} task(s).',
89
89
  'transfer.exportFailed': 'Export failed: {error}',
90
90
  'transfer.importTitleModal': 'Import tasks',
91
- 'transfer.importHint': 'Exported task configuration only: no run history, no counters. Secrets are not included — channels reference credentials by name, so make sure those credential names exist on the target harness.',
91
+ 'transfer.importHint': 'Exported task configuration only: no run history, no counters. Channel configurations reference credentials by name; note that raw exports retain custom env variables and HTTP request headers.',
92
+ 'transfer.exportPrompt': 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export',
92
93
  'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
93
94
  'transfer.strategyReplace': 'Replace tasks with the same id',
94
95
  'transfer.strategySkip': 'Skip tasks whose id already exists',
@@ -390,7 +391,8 @@ window.__ModuleLoader__.load({
390
391
  'transfer.exported': '已成功导出 {count} 个任务。',
391
392
  'transfer.exportFailed': '导出失败: {error}',
392
393
  'transfer.importTitleModal': '导入任务',
393
- 'transfer.importHint': '仅导出任务静态配置:不包含历史记录与计数器。不包含密钥——渠道通过凭据名称引用。',
394
+ 'transfer.importHint': '仅导出任务静态配置:不包含历史记录与计数器。渠道通过凭据名称引用;注意完整导出保留自定义环境变量和 HTTP 请求头。',
395
+ 'transfer.exportPrompt': '请选择导出模式:\n确定 = 净化导出(移除自定义环境变量与请求头)\n取消 = 完整原始导出',
394
396
  'transfer.strategyAdd': '作为新任务导入(分配新 ID)',
395
397
  'transfer.strategyReplace': '覆盖同 ID 任务',
396
398
  'transfer.strategySkip': '跳过已存在的同 ID 任务',
@@ -1383,12 +1385,16 @@ window.__ModuleLoader__.load({
1383
1385
  setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (err) { /* node unmounted */ } }, 2500);
1384
1386
  }, [tasks]);
1385
1387
 
1388
+ const fetchGenRef = React.useRef(0);
1389
+
1386
1390
  const fetchTasks = async () => {
1391
+ const gen = ++fetchGenRef.current;
1387
1392
  setLoading(true);
1388
1393
  setFetchError(null);
1389
1394
  try {
1390
1395
  const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
1391
1396
  const data = await res.json();
1397
+ if (gen !== fetchGenRef.current) return;
1392
1398
  if (data && data.ok) {
1393
1399
  setTasks(data.tasks || []);
1394
1400
  setRecs(data.recommendations || []);
@@ -1397,10 +1403,13 @@ window.__ModuleLoader__.load({
1397
1403
  setFetchError(data && data.error ? data.error : T_KEY(t, 'list.loadErrorPrefix'));
1398
1404
  }
1399
1405
  } catch (err) {
1406
+ if (gen !== fetchGenRef.current) return;
1400
1407
  console.error('[dsh-cron] fetch tasks error:', err);
1401
1408
  setFetchError(err.message || T_KEY(t, 'list.loadErrorPrefix'));
1402
1409
  } finally {
1403
- setLoading(false);
1410
+ if (gen === fetchGenRef.current) {
1411
+ setLoading(false);
1412
+ }
1404
1413
  }
1405
1414
  };
1406
1415
 
@@ -1488,22 +1497,50 @@ window.__ModuleLoader__.load({
1488
1497
  }, [tab, query]);
1489
1498
  React.useEffect(() => { loadModels(); }, []);
1490
1499
 
1500
+ const actionInProgressRef = React.useRef(new Set());
1501
+
1491
1502
  const handleToggleTask = async (id) => {
1503
+ if (actionInProgressRef.current.has(id)) return;
1504
+ actionInProgressRef.current.add(id);
1492
1505
  try {
1493
- await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
1494
- fetchTasks();
1506
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
1507
+ const data = await res.json().catch(() => ({}));
1508
+ if (!res.ok || (data && data.ok === false)) {
1509
+ const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
1510
+ alert(msg);
1511
+ return;
1512
+ }
1513
+ await fetchTasks();
1495
1514
  } catch (err) {
1496
- console.debug('[dsh-cron] toggle failed', err);
1515
+ console.error('[dsh-cron] toggle failed', err);
1516
+ alert('Toggle request failed: ' + err.message);
1517
+ } finally {
1518
+ actionInProgressRef.current.delete(id);
1497
1519
  }
1498
1520
  };
1499
1521
 
1500
1522
  const handleRunNow = async (id) => {
1523
+ if (actionInProgressRef.current.has(id)) return;
1524
+ actionInProgressRef.current.add(id);
1525
+ const prevTask = tasks.find(t => t.id === id);
1526
+ const prevStatus = prevTask ? prevTask.lastStatus : null;
1527
+ setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1501
1528
  try {
1502
- setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1503
- await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
1529
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
1530
+ const data = await res.json().catch(() => ({}));
1531
+ if (!res.ok || (data && data.ok === false)) {
1532
+ setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
1533
+ const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
1534
+ alert(msg);
1535
+ return;
1536
+ }
1504
1537
  setTimeout(() => fetchTasks(), 1500);
1505
1538
  } catch (err) {
1506
1539
  console.error('[dsh-cron] run now error:', err);
1540
+ setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
1541
+ alert('Run request failed: ' + err.message);
1542
+ } finally {
1543
+ actionInProgressRef.current.delete(id);
1507
1544
  }
1508
1545
  };
1509
1546
 
@@ -1527,7 +1564,7 @@ window.__ModuleLoader__.load({
1527
1564
  const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
1528
1565
  const data = await res.json();
1529
1566
  if (data.ok) {
1530
- setArchiveRuns(data.runs || []);
1567
+ setArchiveRuns(prev => offset === 0 ? (data.runs || []) : [...prev, ...(data.runs || [])]);
1531
1568
  setArchiveTotal(data.total || 0);
1532
1569
  }
1533
1570
  } catch (err) {
@@ -1592,11 +1629,14 @@ window.__ModuleLoader__.load({
1592
1629
  return headers;
1593
1630
  };
1594
1631
 
1595
- /** #42: download the task configuration as a JSON file. */
1632
+ /** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
1596
1633
  const handleExport = async () => {
1597
1634
  setTransferBusy(true);
1598
1635
  try {
1599
- const res = await fetch('/dsh-cron/tasks/export');
1636
+ const promptMsg = T_KEY(t, 'transfer.exportPrompt') || 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export';
1637
+ const wantSanitized = confirm(promptMsg);
1638
+ const exportUrl = '/dsh-cron/tasks/export' + (wantSanitized ? '?sanitize=true' : '');
1639
+ const res = await fetch(exportUrl);
1600
1640
  const data = await res.json();
1601
1641
  if (!data.ok) {
1602
1642
  alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
@@ -1607,7 +1647,7 @@ window.__ModuleLoader__.load({
1607
1647
  const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
1608
1648
  const anchor = document.createElement('a');
1609
1649
  anchor.href = url;
1610
- anchor.download = `dsh-cron-tasks-${stamp}.json`;
1650
+ anchor.download = `dsh-cron-tasks-${stamp}${data.sanitized ? '-sanitized' : ''}.json`;
1611
1651
  document.body.appendChild(anchor);
1612
1652
  anchor.click();
1613
1653
  document.body.removeChild(anchor);
@@ -3145,9 +3185,9 @@ window.__ModuleLoader__.load({
3145
3185
  }, '✕')
3146
3186
  ),
3147
3187
  React.createElement('div', { style: { marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '13px' } },
3148
- React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? (dryRunResult.result && dryRunResult.result.status || 'success') : 'failed')),
3149
- dryRunResult.result && dryRunResult.result.preflight && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, dryRunResult.result.preflight.passed ? '✓ Passed' : '✗ Failed (' + (dryRunResult.result.preflight.reason || '') + ')')),
3150
- dryRunResult.result && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.duration') + ': '), React.createElement('span', null, (dryRunResult.result.durationMs || 0) + 'ms')),
3188
+ React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? ((dryRunResult.result && dryRunResult.result.status) || dryRunResult.status || 'success') : 'failed')),
3189
+ ((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight) && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).passed) ? '✓ Passed' : '✗ Failed (' + ((((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).reason) || '') + ')')),
3190
+ React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.duration') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.durationMs) !== undefined ? dryRunResult.result.durationMs : dryRunResult.durationMs) || 0) + 'ms')),
3151
3191
  React.createElement('div', { style: { marginTop: '6px' } }, React.createElement('strong', null, T_KEY(t, 'dryRun.output') + ':')),
3152
3192
  React.createElement('pre', {
3153
3193
  style: {
@@ -3162,7 +3202,7 @@ window.__ModuleLoader__.load({
3162
3202
  wordBreak: 'break-word',
3163
3203
  color: 'var(--dsw-alias-label-primary)'
3164
3204
  }
3165
- }, (dryRunResult.result && dryRunResult.result.output) || (dryRunResult.error) || '(no output)')
3205
+ }, ((dryRunResult.result && dryRunResult.result.output) !== undefined ? dryRunResult.result.output : (dryRunResult.output !== undefined ? dryRunResult.output : ((dryRunResult.result && dryRunResult.result.error) || dryRunResult.error || '(no output)'))))
3166
3206
  ),
3167
3207
  React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '16px' } },
3168
3208
  React.createElement('button', {
@@ -3229,7 +3269,7 @@ window.__ModuleLoader__.load({
3229
3269
  type: 'button',
3230
3270
  className: 'dsh-cron-btn-secondary',
3231
3271
  onClick: () => {
3232
- const nextOffset = archiveOffset + 15;
3272
+ const nextOffset = archiveRuns.length;
3233
3273
  if (archiveTask) openArchiveModal(archiveTask, nextOffset);
3234
3274
  }
3235
3275
  }, T_KEY(t, 'archive.loadMore', { shown: archiveRuns.length, total: archiveTotal })) : React.createElement('div', null),
package/lib/store.js CHANGED
@@ -38,14 +38,17 @@ export class TaskStore {
38
38
  this._lastBackupTime = 0;
39
39
  this._saveTimer = null;
40
40
  this._savePromise = Promise.resolve();
41
+ this._saveVersion = 0;
42
+ this._lastWrittenVersion = 0;
41
43
  this.init();
42
44
  }
43
45
 
44
46
  init() {
45
47
  const dir = path.dirname(this.filePath);
46
48
  if (!fs.existsSync(dir)) {
47
- fs.mkdirSync(dir, { recursive: true });
49
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
50
  }
51
+ bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
49
52
  const bakPath = `${this.filePath}.bak`;
50
53
 
51
54
  const loadData = (raw) => {
@@ -84,22 +87,30 @@ export class TaskStore {
84
87
 
85
88
  if (fs.existsSync(this.filePath)) {
86
89
  try {
90
+ bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
87
91
  const raw = fs.readFileSync(this.filePath, 'utf-8');
88
92
  loadData(raw);
89
93
  if (!fs.existsSync(bakPath)) {
90
- bestEffort('backup-initial', () => fs.copyFileSync(this.filePath, bakPath));
94
+ bestEffort('backup-initial', () => {
95
+ fs.copyFileSync(this.filePath, bakPath);
96
+ fs.chmodSync(bakPath, 0o600);
97
+ });
98
+ } else {
99
+ bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
91
100
  }
92
101
  } catch (err) {
93
102
  logger.error('[dsh-cron] store read error (corrupted file):', err.message);
94
103
  try {
95
104
  const corruptedPath = `${this.filePath}.corrupted.${Date.now()}`;
96
105
  fs.copyFileSync(this.filePath, corruptedPath);
106
+ bestEffort('chmod-corrupted', () => fs.chmodSync(corruptedPath, 0o600));
97
107
  logger.warn(`[dsh-cron] corrupted store backed up to ${corruptedPath}`);
98
108
  } catch (snapErr) {
99
109
  logger.error('[dsh-cron] failed to snapshot corrupted store:', snapErr.message);
100
110
  }
101
111
  if (fs.existsSync(bakPath)) {
102
112
  try {
113
+ bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
103
114
  const rawBak = fs.readFileSync(bakPath, 'utf-8');
104
115
  loadData(rawBak);
105
116
  logger.warn(`[dsh-cron] successfully recovered tasks store from ${bakPath}`);
@@ -110,9 +121,13 @@ export class TaskStore {
110
121
  }
111
122
  } else if (fs.existsSync(bakPath)) {
112
123
  try {
124
+ bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
113
125
  const rawBak = fs.readFileSync(bakPath, 'utf-8');
114
126
  loadData(rawBak);
115
- bestEffort('restore-from-bak', () => fs.copyFileSync(bakPath, this.filePath));
127
+ bestEffort('restore-from-bak', () => {
128
+ fs.copyFileSync(bakPath, this.filePath);
129
+ fs.chmodSync(this.filePath, 0o600);
130
+ });
116
131
  logger.warn(`[dsh-cron] restored missing tasks store from ${bakPath}`);
117
132
  } catch (bakErr) {
118
133
  logger.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
@@ -121,17 +136,16 @@ export class TaskStore {
121
136
  }
122
137
 
123
138
  /**
124
- * Schedule a debounced save to avoid synchronous event loop blocking on high-frequency run completions.
139
+ * Schedule a debounced save using the asynchronous write queue to avoid
140
+ * blocking the event loop on large task stores (#244).
125
141
  */
126
142
  scheduleSave(delay = 50) {
127
143
  if (this._saveTimer) return;
128
144
  this._saveTimer = setTimeout(() => {
129
145
  this._saveTimer = null;
130
- try {
131
- this.save();
132
- } catch (err) {
146
+ this.saveAsync().catch((err) => {
133
147
  logger.error('[dsh-cron] debounced save failed:', err.message);
134
- }
148
+ });
135
149
  }, delay);
136
150
  if (typeof this._saveTimer.unref === 'function') {
137
151
  this._saveTimer.unref();
@@ -149,16 +163,72 @@ export class TaskStore {
149
163
  this.save();
150
164
  }
151
165
 
166
+ /**
167
+ * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
168
+ * Safe mode 0600 on files and 0700 on dir (#222).
169
+ */
170
+ async saveAsync() {
171
+ const version = ++this._saveVersion;
172
+ const data = {
173
+ version: 1,
174
+ updatedAt: new Date().toISOString(),
175
+ tasks: Array.from(this.tasks.values()),
176
+ history: Object.fromEntries(this.history.entries()),
177
+ settings: this.settings || {},
178
+ };
179
+
180
+ const writeOp = async () => {
181
+ if (this._lastWrittenVersion >= version) {
182
+ return;
183
+ }
184
+ const dir = path.dirname(this.filePath);
185
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
186
+ bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
187
+
188
+ const rand = Math.random().toString(36).slice(2, 8);
189
+ const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
190
+ const payload = JSON.stringify(data, null, 2);
191
+ await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
192
+ bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
193
+
194
+ if (this._lastWrittenVersion >= version) {
195
+ await fs.promises.unlink(tmp).catch(() => {});
196
+ return;
197
+ }
198
+
199
+ await fs.promises.rename(tmp, this.filePath);
200
+ bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
201
+ this._lastWrittenVersion = version;
202
+
203
+ const now = Date.now();
204
+ if (now - this._lastBackupTime > 60000) {
205
+ this._lastBackupTime = now;
206
+ try {
207
+ const bakPath = `${this.filePath}.bak`;
208
+ await fs.promises.copyFile(this.filePath, bakPath);
209
+ bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
210
+ } catch (bakErr) {
211
+ logger.warn('[dsh-cron] async backup copy failed:', bakErr.message);
212
+ }
213
+ }
214
+ };
215
+
216
+ this._savePromise = this._savePromise.then(writeOp, writeOp);
217
+ return this._savePromise;
218
+ }
219
+
152
220
  save() {
153
221
  if (this._saveTimer) {
154
222
  clearTimeout(this._saveTimer);
155
223
  this._saveTimer = null;
156
224
  }
225
+ const version = ++this._saveVersion;
157
226
  try {
158
227
  const dir = path.dirname(this.filePath);
159
228
  if (!fs.existsSync(dir)) {
160
- fs.mkdirSync(dir, { recursive: true });
229
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
161
230
  }
231
+ bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
162
232
  const data = {
163
233
  version: 1,
164
234
  updatedAt: new Date().toISOString(),
@@ -169,13 +239,21 @@ export class TaskStore {
169
239
  // Unique tmp file per save call with process PID and random token to eliminate file collision
170
240
  const rand = Math.random().toString(36).slice(2, 8);
171
241
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
172
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
242
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
243
+ bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
173
244
  fs.renameSync(tmp, this.filePath);
245
+ bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
246
+ this._lastWrittenVersion = version;
247
+
174
248
  // Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
175
249
  const now = Date.now();
176
250
  if (now - this._lastBackupTime > 60000) {
177
251
  this._lastBackupTime = now;
178
- bestEffort('backup-on-save', () => fs.copyFileSync(this.filePath, `${this.filePath}.bak`));
252
+ bestEffort('backup-on-save', () => {
253
+ const bakPath = `${this.filePath}.bak`;
254
+ fs.copyFileSync(this.filePath, bakPath);
255
+ fs.chmodSync(bakPath, 0o600);
256
+ });
179
257
  }
180
258
  } catch (err) {
181
259
  logger.error('[dsh-cron] store save error:', err.message);
@@ -505,8 +583,10 @@ export class TaskStore {
505
583
  // Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
506
584
  const rand = Math.random().toString(36).slice(2, 8);
507
585
  const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
508
- fs.writeFileSync(tmp, JSON.stringify(archive), 'utf-8');
586
+ fs.writeFileSync(tmp, JSON.stringify(archive), { encoding: 'utf-8', mode: 0o600 });
587
+ bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
509
588
  fs.renameSync(tmp, archivePath);
589
+ bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
510
590
  } catch (archiveErr) {
511
591
  logger.error('[dsh-cron] history archive error:', archiveErr.message);
512
592
  }
@@ -162,16 +162,18 @@ export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
162
162
  * `env` and headers can contain hand-typed secrets, so the caller must not
163
163
  * claim the file is secret-free.
164
164
  */
165
- export function buildTaskExport(tasks, { now = Date.now() } = {}) {
165
+ export function buildTaskExport(tasks, { now = Date.now(), sanitize = false } = {}) {
166
166
  const list = Array.isArray(tasks) ? tasks : [];
167
167
  return {
168
168
  kind: TASK_EXPORT_KIND,
169
169
  version: TASK_EXPORT_VERSION,
170
170
  exportedAt: new Date(now).toISOString(),
171
+ sanitized: Boolean(sanitize),
171
172
  tasks: list.map((task) => {
172
173
  const out = {};
173
174
  for (const key of EXPORT_TASK_FIELDS) {
174
175
  if (task[key] === undefined || key === 'status') continue;
176
+ if (sanitize && EXPORT_SECRET_BEARING_FIELDS.includes(key)) continue;
175
177
  out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
176
178
  }
177
179
  return out;
@@ -215,6 +217,26 @@ export function validateImportDocument(doc) {
215
217
  for (const key of DUPLICATE_TASK_FIELDS) {
216
218
  if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
217
219
  }
220
+ if (clean.env && typeof clean.env === 'object') {
221
+ const filteredEnv = {};
222
+ for (const [k, v] of Object.entries(clean.env)) {
223
+ if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
224
+ continue; // Masked secret placeholder from UI/doc must never become literal secret (#240)
225
+ }
226
+ filteredEnv[k] = v;
227
+ }
228
+ clean.env = filteredEnv;
229
+ }
230
+ if (clean.httpHeaders && typeof clean.httpHeaders === 'object') {
231
+ const filteredHeaders = {};
232
+ for (const [k, v] of Object.entries(clean.httpHeaders)) {
233
+ if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
234
+ continue;
235
+ }
236
+ filteredHeaders[k] = v;
237
+ }
238
+ clean.httpHeaders = filteredHeaders;
239
+ }
218
240
  if (id) clean.id = id;
219
241
  if (channels) clean.channels = channels;
220
242
  tasks.push(clean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.39",
3
+ "version": "0.2.40",
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",