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.
- package/dist/auth/interactions.js +8 -0
- package/dist/auth/interactions.js.map +1 -1
- package/dist/auth/verifier.js +4 -0
- package/dist/auth/verifier.js.map +1 -1
- package/dist/config/globalConfig.js +17 -0
- package/dist/config/globalConfig.js.map +1 -1
- package/dist/server/aiAdmin.js +20 -5
- package/dist/server/aiAdmin.js.map +1 -1
- package/dist/server/bootstrap.js +3 -1
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/server/configUi/app.js +13 -1
- package/dist/server/configUi/index.html +4 -4
- package/dist/server/configUi/ui/recovery.js +415 -20
- package/dist/server/configUi/ui/recoveryMaintenance.js +2 -2
- package/dist/server/configUi/workbench.css +53 -1
- package/dist/server/configUi/workbench.js +268 -4
- package/dist/server/installationRuntime.js +7 -1
- package/dist/server/installationRuntime.js.map +1 -1
- package/dist/server/ipcDispatch.js +52 -3
- package/dist/server/ipcDispatch.js.map +1 -1
- package/dist/services/ai/contracts.js +1 -1
- package/dist/services/ai/contracts.js.map +1 -1
- package/dist/services/changes/planner.js +11 -2
- package/dist/services/changes/planner.js.map +1 -1
- package/dist/services/git/gitService.js +239 -0
- package/dist/services/git/gitService.js.map +1 -1
- package/dist/services/recovery/contracts.js +6 -0
- package/dist/services/recovery/contracts.js.map +1 -1
- package/dist/services/recovery/drift.js +37 -26
- package/dist/services/recovery/drift.js.map +1 -1
- package/dist/services/recovery/maintenance.js +8 -2
- package/dist/services/recovery/maintenance.js.map +1 -1
- package/dist/services/recovery/recoveryService.js +332 -54
- package/dist/services/recovery/recoveryService.js.map +1 -1
- package/dist/store/db.js +75 -0
- package/dist/store/db.js.map +1 -1
- package/dist/tools/context.js +11 -3
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/directTools.js +1 -1
- package/dist/tools/directTools.js.map +1 -1
- package/dist/workspace/ignores.js +32 -6
- package/dist/workspace/ignores.js.map +1 -1
- package/docs/RELEASE_1.4.0.md +25 -0
- package/docs/RELEASE_1.4.1.md +14 -0
- package/docs/RELEASE_NOTES.md +14 -0
- package/package.json +3 -1
- package/schemas/global-config.schema.json +20 -0
- 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
|
-
|
|
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(
|
|
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(
|
|
172
|
+
inventory.files.push(...(await this.inventory(policy, [p])).files);
|
|
153
173
|
}
|
|
154
|
-
if (inventory.files.length >
|
|
174
|
+
if (inventory.files.length > policy.maxEntries)
|
|
155
175
|
throw new DodoError('RESOURCE_LIMIT', 'source scan exceeds entry budget');
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
239
|
+
const parsed = RecoveryPolicySchema.parse(input);
|
|
176
240
|
if (!this.project())
|
|
177
241
|
throw new DodoError('NOT_FOUND', 'register this project first');
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
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 (!
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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() +
|
|
517
|
+
await this.deadline.run(Date.now() + policy.scanMs, () => this.drift.guard(targets));
|
|
297
518
|
}
|
|
298
519
|
catch (e) {
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
416
|
-
if (
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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 {
|