dodo-mcp 1.3.2 → 1.4.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.
Files changed (48) hide show
  1. package/dist/auth/interactions.js +8 -0
  2. package/dist/auth/interactions.js.map +1 -1
  3. package/dist/auth/verifier.js +4 -0
  4. package/dist/auth/verifier.js.map +1 -1
  5. package/dist/config/globalConfig.js +17 -0
  6. package/dist/config/globalConfig.js.map +1 -1
  7. package/dist/server/aiAdmin.js +20 -5
  8. package/dist/server/aiAdmin.js.map +1 -1
  9. package/dist/server/bootstrap.js +3 -1
  10. package/dist/server/bootstrap.js.map +1 -1
  11. package/dist/server/configUi/app.js +13 -1
  12. package/dist/server/configUi/index.html +4 -4
  13. package/dist/server/configUi/ui/recovery.js +415 -20
  14. package/dist/server/configUi/ui/recoveryMaintenance.js +2 -2
  15. package/dist/server/configUi/workbench.css +53 -1
  16. package/dist/server/configUi/workbench.js +268 -4
  17. package/dist/server/installationRuntime.js +7 -1
  18. package/dist/server/installationRuntime.js.map +1 -1
  19. package/dist/server/ipcDispatch.js +52 -3
  20. package/dist/server/ipcDispatch.js.map +1 -1
  21. package/dist/services/ai/contracts.js +1 -1
  22. package/dist/services/ai/contracts.js.map +1 -1
  23. package/dist/services/changes/planner.js +11 -2
  24. package/dist/services/changes/planner.js.map +1 -1
  25. package/dist/services/git/gitService.js +239 -0
  26. package/dist/services/git/gitService.js.map +1 -1
  27. package/dist/services/recovery/contracts.js +6 -0
  28. package/dist/services/recovery/contracts.js.map +1 -1
  29. package/dist/services/recovery/drift.js +37 -26
  30. package/dist/services/recovery/drift.js.map +1 -1
  31. package/dist/services/recovery/maintenance.js +8 -2
  32. package/dist/services/recovery/maintenance.js.map +1 -1
  33. package/dist/services/recovery/recoveryService.js +332 -54
  34. package/dist/services/recovery/recoveryService.js.map +1 -1
  35. package/dist/store/db.js +75 -0
  36. package/dist/store/db.js.map +1 -1
  37. package/dist/tools/context.js +11 -3
  38. package/dist/tools/context.js.map +1 -1
  39. package/dist/tools/directTools.js +1 -1
  40. package/dist/tools/directTools.js.map +1 -1
  41. package/dist/workspace/ignores.js +32 -6
  42. package/dist/workspace/ignores.js.map +1 -1
  43. package/docs/RELEASE_1.4.0.md +25 -0
  44. package/docs/RELEASE_1.4.1.md +14 -0
  45. package/docs/RELEASE_NOTES.md +14 -0
  46. package/package.json +3 -1
  47. package/schemas/global-config.schema.json +20 -0
  48. package/scripts/setup-multimodal.mjs +0 -0
@@ -17,7 +17,7 @@ import { digestOf } from '../../util/hash.js';
17
17
  import { loadGlobalConfig } from '../../config/globalConfig.js';
18
18
  import { RecoveryPolicySchema } from './contracts.js';
19
19
  import { RecoveryStorage, rootIdentity } from './storage.js';
20
- const OMIT_DIR = new Set(['.git', 'node_modules', 'dist', 'build', 'out', '.cache', 'coverage', 'target', '.venv', 'venv', '__pycache__', 'uploads', 'logs', 'volumes']);
20
+ const OMIT_DIR = new Set(['.git', 'node_modules', '.next', '.nuxt', '.svelte-kit', '.turbo', '.output', '.vercel', '.parcel-cache', 'dist', 'build', 'out', '.cache', 'coverage', 'target', '.venv', 'venv', '__pycache__', 'uploads', 'logs', 'volumes', 'tmp', 'temp']);
21
21
  const DATA_EXTENSION = /\.(?:db|sqlite|sqlite3|db-wal|db-shm|log|dump|bak)$/i;
22
22
  // Bounded high-confidence screening, NOT a guarantee that every credential is detectable.
23
23
  const SECRET = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\b(?:gh[pousr]_[a-zA-Z0-9]{30,}|github_pat_[a-zA-Z0-9_]{40,}|sk-[a-zA-Z0-9_-]{32,}|AKIA[0-9A-Z]{16})\b/;
@@ -90,6 +90,11 @@ export class RecoveryService {
90
90
  identity;
91
91
  state = 'INITIALIZING';
92
92
  errorCode = null;
93
+ errorDetail = null;
94
+ blockedPath = null;
95
+ skippedSecretFiles = new Set();
96
+ lastWarning = null;
97
+ lastGitCheckpoint = null;
93
98
  baseline;
94
99
  latest;
95
100
  lastTrigger = null;
@@ -134,10 +139,25 @@ export class RecoveryService {
134
139
  throw new DodoError('PATH_DENIED', 'path is outside allowed recovery source scope');
135
140
  this.s.wfs.resolve(rel, { allowMissing: true });
136
141
  }
142
+ effectiveMode(policy = this.policy()) {
143
+ if (!policy.enabled || policy.mode === 'off')
144
+ return 'off';
145
+ if (policy.mode === 'git')
146
+ return 'git';
147
+ if (policy.mode === 'snapshot')
148
+ return 'snapshot';
149
+ return this.s.git.hasGitMarker() ? 'git' : 'snapshot';
150
+ }
151
+ recordSkippedSecret(rel) {
152
+ if (this.skippedSecretFiles.size < 50)
153
+ this.skippedSecretFiles.add(rel);
154
+ this.lastWarning = `ข้ามไฟล์ที่มีเนื้อหาคล้ายคีย์ความลับอัตโนมัติ (${this.skippedSecretFiles.size} ไฟล์): ${[...this.skippedSecretFiles].slice(0, 5).join(', ')}`;
155
+ }
137
156
  async currentEntries(targets, extraPaths = []) {
138
- return this.deadline.run(this.deadline.getStore() ?? Date.now() + this.policy().scanMs, async () => {
157
+ const policy = this.policy();
158
+ return this.deadline.run(this.deadline.getStore() ?? Date.now() + policy.scanMs, async () => {
139
159
  const read = async () => {
140
- const inventory = await this.inventory(this.policy(), targets);
160
+ const inventory = await this.inventory(policy, targets);
141
161
  const seen = new Set(inventory.files.map(f => f.entry.path));
142
162
  for (const p of extraPaths)
143
163
  if (!seen.has(p)) {
@@ -149,14 +169,28 @@ export class RecoveryService {
149
169
  }
150
170
  const r = this.s.wfs.resolve(p, { allowMissing: true });
151
171
  if (r.stat?.isFile())
152
- inventory.files.push(...(await this.inventory(this.policy(), [p])).files);
172
+ inventory.files.push(...(await this.inventory(policy, [p])).files);
153
173
  }
154
- if (inventory.files.length > this.policy().maxEntries)
174
+ if (inventory.files.length > policy.maxEntries)
155
175
  throw new DodoError('RESOURCE_LIMIT', 'source scan exceeds entry budget');
156
- for (const f of inventory.files)
157
- if (f.entry.kind === 'file')
158
- f.entry.hash = await this.readFile(f);
159
- return inventory.files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
176
+ const kept = [];
177
+ for (const f of inventory.files) {
178
+ if (f.entry.kind === 'file') {
179
+ try {
180
+ f.entry.hash = await this.readFile(f, undefined, policy);
181
+ }
182
+ catch (e) {
183
+ if (e instanceof DodoError && e.code === 'SECRET_PATH_DENIED' && policy.skipSecretContentFiles) {
184
+ this.recordSkippedSecret(f.entry.path);
185
+ this.blockedPath = null;
186
+ continue;
187
+ }
188
+ throw e;
189
+ }
190
+ }
191
+ kept.push(f);
192
+ }
193
+ return kept.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
160
194
  };
161
195
  const first = await read(), second = await read();
162
196
  if (digestOf(first) !== digestOf(second))
@@ -168,18 +202,44 @@ export class RecoveryService {
168
202
  const r = this.s.store.db.prepare('SELECT payload FROM recovery_policies WHERE workspace_id=?').get(this.s.workspaceId);
169
203
  return RecoveryPolicySchema.parse(r ? JSON.parse(r.payload) : {});
170
204
  }
205
+ normalizePolicyPaths(raw) {
206
+ const cleanList = (items, allowSecretNames) => {
207
+ const out = [];
208
+ for (const p of items) {
209
+ const candidate = p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
210
+ if (!candidate || candidate === '.')
211
+ throw new DodoError('INVALID_INPUT', 'data roots and exclusions must be canonical relative paths');
212
+ const rel = this.s.wfs.normalizeRel(candidate);
213
+ if (rel === '.' || rel !== candidate || /[?*\[\]]/.test(rel))
214
+ throw new DodoError('INVALID_INPUT', 'data roots and exclusions must be canonical relative paths without wildcards');
215
+ if (!allowSecretNames) {
216
+ try {
217
+ this.s.wfs.resolve(rel, { allowMissing: true });
218
+ }
219
+ catch (e) {
220
+ if (!(e instanceof DodoError && e.code === 'SECRET_PATH_DENIED'))
221
+ throw e;
222
+ }
223
+ }
224
+ out.push(rel);
225
+ }
226
+ return [...new Set(out)].sort();
227
+ };
228
+ return {
229
+ ...raw,
230
+ enabled: raw.mode === 'off' ? false : raw.enabled,
231
+ excludePaths: cleanList(raw.excludePaths, true),
232
+ dataRoots: cleanList(raw.dataRoots, false),
233
+ };
234
+ }
171
235
  /** Private owner service only. Repository configuration never reaches this method. */
172
236
  async configure(input, confirmed, revalidate) {
173
237
  if (!confirmed)
174
238
  throw new DodoError('INVALID_INPUT', 'confirm the Recovery policy change');
175
- const policy = RecoveryPolicySchema.parse(input);
239
+ const parsed = RecoveryPolicySchema.parse(input);
176
240
  if (!this.project())
177
241
  throw new DodoError('NOT_FOUND', 'register this project first');
178
- for (const p of [...policy.dataRoots, ...policy.excludePaths]) {
179
- const rel = this.s.wfs.normalizeRel(p);
180
- if (rel === '.' || rel !== p)
181
- throw new DodoError('INVALID_INPUT', 'data roots must be canonical relative subdirectories');
182
- }
242
+ const policy = this.normalizePolicyPaths(parsed);
183
243
  if (this.s.jobs.runningCount() || this.s.mutations?.busy)
184
244
  throw new DodoError('CONFLICT', 'wait for active mutations and jobs before changing Recovery policy');
185
245
  await this.initialization;
@@ -196,6 +256,10 @@ export class RecoveryService {
196
256
  this.activated = false;
197
257
  this.state = policy.enabled ? 'INITIALIZING' : 'DISABLED_BY_OWNER';
198
258
  this.errorCode = null;
259
+ this.errorDetail = null;
260
+ this.blockedPath = null;
261
+ this.lastWarning = null;
262
+ this.skippedSecretFiles.clear();
199
263
  this.activate();
200
264
  return this.status();
201
265
  }
@@ -204,20 +268,107 @@ export class RecoveryService {
204
268
  this.s.mutations.assertCanStart();
205
269
  if (this.s.jobs.runningCount())
206
270
  throw new DodoError('CONFLICT', 'wait for running jobs before changing Recovery policy');
207
- this.s.store.db.prepare('INSERT INTO recovery_policies VALUES (?,?) ON CONFLICT(workspace_id) DO UPDATE SET payload=excluded.payload').run(this.s.workspaceId, JSON.stringify(policy));
271
+ const normalized = this.normalizePolicyPaths(policy);
272
+ this.s.store.db.prepare('INSERT INTO recovery_policies VALUES (?,?) ON CONFLICT(workspace_id) DO UPDATE SET payload=excluded.payload').run(this.s.workspaceId, JSON.stringify(normalized));
273
+ if (this.state === 'BLOCKED' || this.errorCode) {
274
+ this.errorCode = null;
275
+ this.errorDetail = null;
276
+ this.blockedPath = null;
277
+ this.lastWarning = null;
278
+ this.skippedSecretFiles.clear();
279
+ this.baseline = undefined;
280
+ this.activated = false;
281
+ this.state = normalized.enabled ? 'INITIALIZING' : 'DISABLED_BY_OWNER';
282
+ this.activate();
283
+ }
284
+ }
285
+ async resetBlockedState(revalidate) {
286
+ revalidate?.();
287
+ this.assertProject();
288
+ this.s.store.db.prepare("UPDATE recovery_snapshots SET state='INCOMPLETE' WHERE workspace_id=? AND state='PREPARING'").run(this.s.workspaceId);
289
+ this.s.store.db.prepare('DELETE FROM recovery_reservations WHERE workspace_id=?').run(this.s.workspaceId);
290
+ this.errorCode = null;
291
+ this.errorDetail = null;
292
+ this.blockedPath = null;
293
+ this.lastWarning = null;
294
+ this.skippedSecretFiles.clear();
295
+ this.baseline = undefined;
296
+ this.activated = false;
297
+ const policy = this.policy();
298
+ if (!policy.enabled || policy.mode === 'off') {
299
+ this.state = 'DISABLED_BY_OWNER';
300
+ return this.status();
301
+ }
302
+ this.state = 'INITIALIZING';
303
+ try {
304
+ await this.checkpoint('owner-checkpoint', 'local-config-owner');
305
+ }
306
+ catch {
307
+ /* status reflects current state */
308
+ }
309
+ return this.status();
310
+ }
311
+ async gitRecoveryStatus(limit = 20) {
312
+ this.assertProject();
313
+ const git = await this.s.git.gitRecoveryStatus(limit);
314
+ return { ...git, effectiveMode: this.effectiveMode(), lastGitCheckpoint: this.lastGitCheckpoint };
315
+ }
316
+ async createGitCheckpoint(trigger = 'owner-checkpoint', actor = 'local-config-owner') {
317
+ this.assertProject();
318
+ const policy = this.policy();
319
+ const cp = await this.s.git.createRecoveryCheckpoint({
320
+ trigger,
321
+ actor,
322
+ indexStagingDir: path.join(this.storage.directory, 'staging'),
323
+ excludePaths: [...policy.excludePaths, ...policy.dataRoots],
324
+ });
325
+ this.lastGitCheckpoint = { id: cp.id, ref: cp.ref, commitSha: cp.commitSha, createdAt: cp.createdAt, trigger };
326
+ this.lastCheckedAt = cp.createdAt;
327
+ this.lastTrigger = trigger;
328
+ this.state = 'READY';
329
+ this.errorCode = null;
330
+ this.errorDetail = null;
331
+ this.blockedPath = null;
332
+ this.s.store.audit({ principal: actor, workspaceId: this.s.workspaceId, tool: 'recovery.git.checkpoint', result: 'ready', inputDigest: digestOf({ id: cp.id, trigger }) });
333
+ return cp;
334
+ }
335
+ async previewGitRestore(opts) {
336
+ this.assertProject();
337
+ return await this.s.git.previewRecoveryRestore(opts);
338
+ }
339
+ async applyGitRestore(opts, revalidate) {
340
+ return this.s.mutations.run(async () => {
341
+ revalidate?.();
342
+ this.assertProject();
343
+ const policy = this.policy();
344
+ const res = await this.s.git.applyRecoveryRestore({
345
+ ...opts,
346
+ indexStagingDir: path.join(this.storage.directory, 'staging'),
347
+ excludePaths: [...policy.excludePaths, ...policy.dataRoots],
348
+ });
349
+ this.s.store.db.prepare('DELETE FROM recovery_drift WHERE workspace_id=?').run(this.s.workspaceId);
350
+ this.baseline = undefined;
351
+ this.lastCheckedAt = Date.now();
352
+ this.errorCode = null;
353
+ this.errorDetail = null;
354
+ this.blockedPath = null;
355
+ this.s.store.audit({ principal: 'local-config-owner', workspaceId: this.s.workspaceId, tool: 'recovery.git.restore', result: 'restored', inputDigest: digestOf({ ref: opts.ref, paths: opts.paths ?? [] }) });
356
+ return res;
357
+ });
208
358
  }
209
359
  protectedCheckpointIds() { return [this.baseline, this.latest]; }
210
360
  summary() {
211
- const { enabled, state, registered, awaitingActivation, sourceOnly, lastCheckedAt, errorCode, progress, projectBytes } = this.status();
361
+ const { enabled, state, effectiveMode, hasGitRepo, registered, awaitingActivation, sourceOnly, lastCheckedAt, errorCode, errorDetail, blockedPath, skippedSecretFiles, lastWarning, lastGitCheckpoint, progress, projectBytes } = this.status();
212
362
  const drift = this.drift.status();
213
- return { enabled, state, registered, awaitingActivation, sourceOnly, lastCheckedAt, errorCode, progress, projectBytes, drift: { state: drift.state, changedCount: drift.changedCount, critical: drift.critical, errorCode: drift.errorCode }, git: this.git.status(), verification: this.evidence.summary() };
363
+ return { enabled, state, effectiveMode, hasGitRepo, registered, awaitingActivation, sourceOnly, lastCheckedAt, errorCode, errorDetail, blockedPath, skippedSecretFiles, lastWarning, lastGitCheckpoint, progress, projectBytes, drift: { state: drift.state, changedCount: drift.changedCount, critical: drift.critical, errorCode: drift.errorCode }, git: this.git.status(), verification: this.evidence.summary() };
214
364
  }
215
365
  status() {
216
366
  const registered = Boolean(this.s.store.db.prepare('SELECT 1 FROM project_registry WHERE workspace_id=? AND removed_at IS NULL').get(this.s.workspaceId));
217
367
  const policy = this.policy();
218
- return { enabled: policy.enabled, state: policy.enabled ? this.state : 'DISABLED_BY_OWNER', registered, awaitingActivation: registered && !this.activated,
219
- sourceOnly: true, lastTrigger: this.lastTrigger, counts: { ...this.counts }, lastCheckpointId: this.latest ?? null, baselineId: this.baseline ?? null, integrity: this.lastCheckedAt ? 'verified_at_capture' : 'not_verified', lastCheckedAt: this.lastCheckedAt,
220
- errorCode: this.errorCode, progress: { ...this.progress }, drift: this.drift.status(), git: this.git.status(), verification: this.evidence.summary(), ...this.storage.usageFor(this.s.workspaceId), policy };
368
+ const effectiveMode = this.effectiveMode(policy);
369
+ return { enabled: policy.enabled, state: policy.enabled && policy.mode !== 'off' ? this.state : 'DISABLED_BY_OWNER', effectiveMode, hasGitRepo: this.s.git.hasGitMarker(), registered, awaitingActivation: registered && !this.activated,
370
+ sourceOnly: true, lastTrigger: this.lastTrigger, counts: { ...this.counts }, lastCheckpointId: this.latest ?? this.lastGitCheckpoint?.id ?? null, baselineId: this.baseline ?? this.lastGitCheckpoint?.id ?? null, integrity: this.lastCheckedAt ? 'verified_at_capture' : 'not_verified', lastCheckedAt: this.lastCheckedAt,
371
+ errorCode: this.errorCode, errorDetail: this.errorDetail, blockedPath: this.blockedPath, skippedSecretFiles: [...this.skippedSecretFiles], lastWarning: this.lastWarning, lastGitCheckpoint: this.lastGitCheckpoint, progress: { ...this.progress }, drift: this.drift.status(), git: this.git.status(), verification: this.evidence.summary(), ...this.storage.usageFor(this.s.workspaceId), policy };
221
372
  }
222
373
  activate() {
223
374
  if (this.closed || this.activated)
@@ -227,7 +378,7 @@ export class RecoveryService {
227
378
  return;
228
379
  }
229
380
  catch (e) {
230
- this.fail(e);
381
+ this.fail(e, true);
231
382
  return;
232
383
  }
233
384
  this.activated = true;
@@ -238,13 +389,18 @@ export class RecoveryService {
238
389
  this.configVault.reconcile();
239
390
  this.reconciled = true;
240
391
  }
241
- if (!this.policy().enabled) {
392
+ const policy = this.policy();
393
+ if (!policy.enabled || policy.mode === 'off') {
242
394
  this.state = 'DISABLED_BY_OWNER';
243
395
  return;
244
396
  }
245
397
  this.initialization = (async () => {
246
398
  // The same queue protects activation from direct, agent and scheduled writers.
247
399
  await this.s.mutations.run(async () => {
400
+ if (policy.mode === 'git') {
401
+ await this.createGitCheckpoint('activation', 'system:activation');
402
+ return;
403
+ }
248
404
  const prior = this.s.store.db.prepare("SELECT id FROM recovery_snapshots WHERE workspace_id=? AND state='READY' ORDER BY created_at DESC LIMIT 1").get(this.s.workspaceId);
249
405
  if (prior) {
250
406
  const m = await this.storage.readVerified(prior.id, this.s.workspaceId);
@@ -259,12 +415,39 @@ export class RecoveryService {
259
415
  });
260
416
  })().catch(e => { this.fail(e); });
261
417
  }
262
- fail(error) { this.state = 'BLOCKED'; this.errorCode = error instanceof DodoError ? error.code : 'BACKUP_IO_ERROR'; }
418
+ fail(error, forceBlock = false) {
419
+ const policy = this.policy();
420
+ this.errorCode = error instanceof DodoError ? error.code : 'BACKUP_IO_ERROR';
421
+ this.errorDetail = error instanceof Error ? error.message : String(error);
422
+ if (error instanceof DodoError && error.detail && typeof error.detail === 'object' && 'path' in error.detail && typeof error.detail.path === 'string') {
423
+ this.blockedPath = error.detail.path;
424
+ }
425
+ const criticalIdentity = error instanceof DodoError && ((error.code === 'PATH_DENIED' && /original registered project|private-state overlap|previous root identity/.test(error.message)) || error.code === 'RECOVERY_REQUIRED');
426
+ if (forceBlock || criticalIdentity || policy.strictEnforcement || policy.gitRequired || Boolean(policy.gitDirectory)) {
427
+ this.state = 'BLOCKED';
428
+ }
429
+ else {
430
+ this.lastWarning = `${this.errorCode}: ${this.errorDetail}${this.blockedPath ? ` (${this.blockedPath})` : ''}`;
431
+ this.state = 'READY';
432
+ }
433
+ }
263
434
  async checkpoint(trigger, actor, targets) {
264
435
  if (this.closed)
265
436
  throw new DodoError('CONFLICT', 'Recovery runtime is closing');
266
- if (!this.project())
267
- return undefined; // launcher/unregistered CWD is never scanned
437
+ const policy = this.policy();
438
+ try {
439
+ if (!this.project())
440
+ return undefined; // launcher/unregistered CWD is never scanned
441
+ }
442
+ catch (e) {
443
+ if (!policy.strictEnforcement && (trigger === 'before-write' || trigger === 'before-exec' || trigger === 'activation')) {
444
+ this.fail(e, false);
445
+ this.authority.getStore()?.();
446
+ await this.reviewedRestore.getStore()?.();
447
+ return undefined;
448
+ }
449
+ throw e;
450
+ }
268
451
  if (!this.reconciled) {
269
452
  this.storage.reconcile(this.s.workspaceId);
270
453
  this.history.reconcile();
@@ -272,10 +455,32 @@ export class RecoveryService {
272
455
  this.configVault.reconcile();
273
456
  this.reconciled = true;
274
457
  }
275
- if (!this.policy().enabled) {
458
+ if (!policy.enabled || policy.mode === 'off') {
276
459
  this.state = 'DISABLED_BY_OWNER';
277
460
  return undefined;
278
461
  }
462
+ if (policy.mode === 'git') {
463
+ return this.s.mutations.run(async () => {
464
+ try {
465
+ const cp = await this.createGitCheckpoint(trigger, actor);
466
+ this.activated = true;
467
+ this.authority.getStore()?.();
468
+ await this.reviewedRestore.getStore()?.();
469
+ return cp.id;
470
+ }
471
+ catch (e) {
472
+ if (e instanceof DodoError && ['FORBIDDEN', 'AUTH_REQUIRED'].includes(e.code))
473
+ throw e;
474
+ this.fail(e);
475
+ if (!policy.strictEnforcement && (trigger === 'before-write' || trigger === 'before-exec' || trigger === 'activation')) {
476
+ this.authority.getStore()?.();
477
+ await this.reviewedRestore.getStore()?.();
478
+ return this.lastGitCheckpoint?.id;
479
+ }
480
+ throw new DodoError('RECOVERY_REQUIRED', 'Git recovery checkpoint is blocked; no source mutation was started', { detail: { reason: this.errorCode, path: this.blockedPath }, recovery: 'inspect Recovery in project settings or switch mode' });
481
+ }
482
+ });
483
+ }
279
484
  // Initialization may itself be waiting behind this call's mutation ticket.
280
485
  // Capture here within that ticket; never await a queued activation from a writer.
281
486
  return this.s.mutations.run(async () => {
@@ -284,21 +489,41 @@ export class RecoveryService {
284
489
  if (!this.baseline) {
285
490
  const prior = this.s.store.db.prepare("SELECT id FROM recovery_snapshots WHERE workspace_id=? AND state='READY' ORDER BY created_at DESC LIMIT 1").get(this.s.workspaceId);
286
491
  if (prior) {
287
- const m = await this.storage.readVerified(prior.id, this.s.workspaceId);
288
- if (m.rootIdentity !== this.identity)
289
- throw new DodoError('PATH_DENIED', 'backup belongs to a previous root identity; owner review is required');
492
+ try {
493
+ const m = await this.storage.readVerified(prior.id, this.s.workspaceId);
494
+ if (m.rootIdentity !== this.identity)
495
+ throw new DodoError('PATH_DENIED', 'backup belongs to a previous root identity; owner review is required');
496
+ }
497
+ catch (e) {
498
+ if (policy.strictEnforcement)
499
+ throw e;
500
+ this.lastWarning = e instanceof Error ? e.message : String(e);
501
+ }
290
502
  }
291
503
  await this.capture('activation', 'system:activation');
292
504
  }
293
- await this.storage.readVerified(this.baseline, this.s.workspaceId);
294
- if (!this.reviewedRestore.getStore() && (trigger === 'before-write' || trigger === 'before-exec'))
505
+ try {
506
+ await this.storage.readVerified(this.baseline, this.s.workspaceId);
507
+ }
508
+ catch (e) {
509
+ if (policy.strictEnforcement)
510
+ throw e;
511
+ this.lastWarning = e instanceof Error ? e.message : String(e);
512
+ this.baseline = undefined;
513
+ await this.capture('activation', 'system:activation');
514
+ }
515
+ if (policy.checkDrift && !this.reviewedRestore.getStore() && (trigger === 'before-write' || trigger === 'before-exec')) {
295
516
  try {
296
- await this.deadline.run(Date.now() + this.policy().scanMs, () => this.drift.guard(targets));
517
+ await this.deadline.run(Date.now() + policy.scanMs, () => this.drift.guard(targets));
297
518
  }
298
519
  catch (e) {
299
- guardRejected = e instanceof DodoError && ['FILE_CHANGED', 'CONFLICT'].includes(e.code);
300
- throw e;
520
+ if (policy.strictEnforcement) {
521
+ guardRejected = e instanceof DodoError && ['FILE_CHANGED', 'CONFLICT'].includes(e.code);
522
+ throw e;
523
+ }
524
+ this.lastWarning = e instanceof Error ? e.message : String(e);
301
525
  }
526
+ }
302
527
  const id = await this.capture(trigger, actor, targets);
303
528
  this.activated = true;
304
529
  this.startScanner();
@@ -315,8 +540,15 @@ export class RecoveryService {
315
540
  catch (e) {
316
541
  if (guardRejected || e instanceof DodoError && (['FORBIDDEN', 'AUTH_REQUIRED'].includes(e.code)))
317
542
  throw e;
318
- this.fail(e);
319
- throw new DodoError('RECOVERY_REQUIRED', 'source backup is blocked; no source mutation was started', { detail: { reason: this.errorCode }, recovery: 'inspect Recovery in project settings, fix storage/path/integrity problems and retry' });
543
+ const criticalIdentity = e instanceof DodoError && e.code === 'PATH_DENIED' && /original registered project|private-state overlap|previous root identity/.test(e.message);
544
+ const mustBlock = (criticalIdentity && policy.strictEnforcement) || policy.strictEnforcement || policy.gitRequired || Boolean(policy.gitDirectory) || trigger === 'verified-checkpoint' || trigger === 'owner-checkpoint';
545
+ this.fail(e, mustBlock);
546
+ if (!mustBlock && (trigger === 'before-write' || trigger === 'before-exec' || trigger === 'activation')) {
547
+ this.authority.getStore()?.();
548
+ await this.reviewedRestore.getStore()?.();
549
+ return this.latest ?? this.lastGitCheckpoint?.id;
550
+ }
551
+ throw new DodoError('RECOVERY_REQUIRED', 'source backup is blocked; no source mutation was started', { detail: { reason: this.errorCode, path: this.blockedPath }, recovery: 'inspect Recovery in project settings, fix storage/path/integrity problems and retry' });
320
552
  }
321
553
  });
322
554
  }
@@ -340,8 +572,10 @@ export class RecoveryService {
340
572
  if (visited % 100 === 0)
341
573
  await tick();
342
574
  if (this.excluded(rel, false, policy)) {
343
- if (target)
344
- throw new DodoError('PATH_DENIED', 'mutation target is excluded from source Recovery');
575
+ if (target && (this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || policy.strictEnforcement)) {
576
+ this.blockedPath = rel;
577
+ throw new DodoError('PATH_DENIED', `mutation target is excluded from source Recovery: ${rel}`, { detail: { path: rel } });
578
+ }
345
579
  excluded++;
346
580
  return;
347
581
  }
@@ -352,8 +586,10 @@ export class RecoveryService {
352
586
  return;
353
587
  }
354
588
  if (this.excluded(rel, st.isDirectory(), policy)) {
355
- if (target)
356
- throw new DodoError('PATH_DENIED', 'mutation target is excluded from source Recovery');
589
+ if (target && (this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || policy.strictEnforcement)) {
590
+ this.blockedPath = rel;
591
+ throw new DodoError('PATH_DENIED', `mutation target is excluded from source Recovery: ${rel}`, { detail: { path: rel } });
592
+ }
357
593
  excluded++;
358
594
  return;
359
595
  }
@@ -371,8 +607,15 @@ export class RecoveryService {
371
607
  }
372
608
  else {
373
609
  this.s.wfs.assertRegularFileForDirectAccess(r);
374
- if (st.size > policy.fileBytes)
375
- throw new DodoError('RESOURCE_LIMIT', 'source file exceeds Recovery file budget');
610
+ if (st.size > policy.fileBytes) {
611
+ if (!policy.strictEnforcement) {
612
+ this.lastWarning = `ข้ามไฟล์ขนาดใหญ่เกินขีดจำกัด (${rel})`;
613
+ excluded++;
614
+ return;
615
+ }
616
+ this.blockedPath = rel;
617
+ throw new DodoError('RESOURCE_LIMIT', `source file exceeds Recovery file budget: ${rel}`, { detail: { path: rel } });
618
+ }
376
619
  files.push({ entry: { path: rel, kind: 'file', hash: null, bytes: st.size, mode: st.mode & 0o777 }, signature: this.signature(st) });
377
620
  }
378
621
  };
@@ -390,7 +633,7 @@ export class RecoveryService {
390
633
  const distinct = new Map(files.map(f => [f.entry.path, f]));
391
634
  return { files: [...distinct.values()].sort((a, b) => a.entry.path.localeCompare(b.entry.path)), excluded };
392
635
  }
393
- async readFile(file, stage) {
636
+ async readFile(file, stage, policy = this.policy()) {
394
637
  const r = this.s.wfs.resolve(file.entry.path);
395
638
  this.s.wfs.assertRegularFileForDirectAccess(r);
396
639
  if (!r.stat || this.signature(r.stat) !== file.signature)
@@ -412,10 +655,15 @@ export class RecoveryService {
412
655
  bytes += bytesRead;
413
656
  if (bytes > file.entry.bytes)
414
657
  throw new DodoError('FILE_CHANGED', 'source grew during backup');
415
- const chunk = buf.subarray(0, bytesRead), text = tail + chunk.toString('utf8');
416
- if (SECRET.test(text))
417
- throw new DodoError('SECRET_PATH_DENIED', 'credential-like content found during source backup');
418
- tail = text.slice(-1024);
658
+ const chunk = buf.subarray(0, bytesRead);
659
+ if (policy.scanContentSecrets) {
660
+ const text = tail + chunk.toString('utf8');
661
+ if (SECRET.test(text)) {
662
+ this.blockedPath = file.entry.path;
663
+ throw new DodoError('SECRET_PATH_DENIED', `credential-like content found during source backup: ${file.entry.path}`, { detail: { path: file.entry.path } });
664
+ }
665
+ tail = text.slice(-1024);
666
+ }
419
667
  hash.update(chunk);
420
668
  if (output) {
421
669
  let offset = 0;
@@ -451,9 +699,27 @@ export class RecoveryService {
451
699
  const inventory = await this.inventory(policy, targets);
452
700
  // Hash before reserving: unchanged objects cost logical references, not duplicate disk bytes.
453
701
  const expectedHashes = new Map();
454
- for (const f of inventory.files)
455
- if (f.entry.kind === 'file')
456
- expectedHashes.set(f.entry.path, await this.readFile(f));
702
+ const keptFiles = [];
703
+ const skippedInPass = new Set();
704
+ for (const f of inventory.files) {
705
+ if (f.entry.kind === 'file') {
706
+ try {
707
+ expectedHashes.set(f.entry.path, await this.readFile(f, undefined, policy));
708
+ }
709
+ catch (e) {
710
+ if (e instanceof DodoError && e.code === 'SECRET_PATH_DENIED' && policy.skipSecretContentFiles) {
711
+ this.recordSkippedSecret(f.entry.path);
712
+ this.blockedPath = null;
713
+ skippedInPass.add(f.entry.path);
714
+ inventory.excluded++;
715
+ continue;
716
+ }
717
+ throw e;
718
+ }
719
+ }
720
+ keptFiles.push(f);
721
+ }
722
+ inventory.files = keptFiles;
457
723
  this.storage.prune(this.s.workspaceId, policy);
458
724
  this.storage.collectOrphans();
459
725
  this.s.store.db.transaction(() => {
@@ -470,7 +736,7 @@ export class RecoveryService {
470
736
  // freshly verifies every distinct object before recording READY.
471
737
  const duplicate = published.has(expectedHashes.get(entry.path)) || this.storage.hasObject(expectedHashes.get(entry.path));
472
738
  staging = duplicate ? undefined : this.storage.stagePath();
473
- entry.hash = await this.readFile(f, staging);
739
+ entry.hash = await this.readFile(f, staging, policy);
474
740
  if (entry.hash !== expectedHashes.get(entry.path))
475
741
  throw new DodoError('FILE_CHANGED', 'source changed after space reservation');
476
742
  if (staging)
@@ -484,12 +750,21 @@ export class RecoveryService {
484
750
  }
485
751
  // Rescan paths AND reread hashes. A watcher or old checkpoint alone is not freshness proof.
486
752
  const after = await this.inventory(policy, targets);
753
+ if (skippedInPass.size > 0) {
754
+ after.files = after.files.filter(af => {
755
+ if (skippedInPass.has(af.entry.path)) {
756
+ after.excluded++;
757
+ return false;
758
+ }
759
+ return true;
760
+ });
761
+ }
487
762
  if (digestOf(after) !== digestOf(inventory))
488
763
  throw new DodoError('FILE_CHANGED', 'source inventory changed during backup');
489
764
  const entryHashes = new Map(entries.map(e => [e.path, e.hash]));
490
765
  for (const f of after.files)
491
766
  if (f.entry.kind === 'file') {
492
- const hash = await this.readFile(f);
767
+ const hash = await this.readFile(f, undefined, policy);
493
768
  if (hash !== entryHashes.get(f.entry.path))
494
769
  throw new DodoError('FILE_CHANGED', 'source content changed during backup');
495
770
  }
@@ -512,6 +787,8 @@ export class RecoveryService {
512
787
  this.lastCheckedAt = Date.now();
513
788
  this.state = 'READY';
514
789
  this.errorCode = null;
790
+ this.errorDetail = null;
791
+ this.blockedPath = null;
515
792
  this.s.store.audit({ principal: actor, workspaceId: this.s.workspaceId, tool: 'recovery.checkpoint', result: 'ready', inputDigest: digestOf({ id, trigger }) });
516
793
  this.storage.prune(this.s.workspaceId, policy);
517
794
  this.git.prune();
@@ -519,7 +796,8 @@ export class RecoveryService {
519
796
  }
520
797
  catch (e) {
521
798
  this.s.store.db.prepare("UPDATE recovery_snapshots SET state='INCOMPLETE' WHERE id=? AND state='PREPARING'").run(id);
522
- this.fail(e);
799
+ const mustBlock = ((e instanceof DodoError && e.code === 'PATH_DENIED' && /original registered project|private-state overlap|previous root identity/.test(e.message)) && policy.strictEnforcement) || policy.strictEnforcement || policy.gitRequired || Boolean(policy.gitDirectory) || trigger === 'verified-checkpoint' || trigger === 'owner-checkpoint';
800
+ this.fail(e, mustBlock);
523
801
  throw e;
524
802
  }
525
803
  finally {