dodo-mcp 1.3.1 → 1.4.0
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 +11 -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 +2 -1
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/server/configUi/index.html +1 -0
- package/dist/server/configUi/ui/recovery.js +415 -35
- package/dist/server/configUi/ui/recoveryMaintenance.js +61 -0
- package/dist/server/configUi/workbench.css +50 -1
- package/dist/server/configUi/workbench.js +193 -0
- package/dist/server/installationRuntime.js +1 -0
- package/dist/server/installationRuntime.js.map +1 -1
- package/dist/server/ipcDispatch.js +58 -5
- package/dist/server/ipcDispatch.js.map +1 -1
- package/dist/server/localConfig.js +1 -1
- package/dist/server/localConfig.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 +7 -0
- package/dist/services/recovery/contracts.js.map +1 -1
- package/dist/services/recovery/maintenance.js +226 -0
- package/dist/services/recovery/maintenance.js.map +1 -0
- package/dist/services/recovery/recoveryService.js +315 -51
- package/dist/services/recovery/recoveryService.js.map +1 -1
- package/dist/services/recovery/storage.js +46 -2
- package/dist/services/recovery/storage.js.map +1 -1
- package/dist/store/db.js +6 -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/recoveryTools.js +24 -3
- package/dist/tools/recoveryTools.js.map +1 -1
- package/dist/tools/surface.js +2 -2
- package/dist/tools/surface.js.map +1 -1
- package/dist/workspace/ignores.js +32 -6
- package/dist/workspace/ignores.js.map +1 -1
- package/docs/ARCHITECTURE.md +18 -2
- package/docs/COMPATIBILITY.md +9 -0
- package/docs/MANUAL_ACCEPTANCE.md +28 -0
- package/docs/RECOVERY.md +73 -8
- package/docs/RELEASE_1.3.2.md +50 -0
- package/docs/RELEASE_1.4.0.md +25 -0
- package/docs/RELEASE_NOTES.md +17 -0
- package/docs/SECURITY.md +17 -0
- package/docs/TEST_REPORT.md +28 -0
- package/package.json +3 -1
- package/schemas/global-config.schema.json +20 -0
- package/schemas/tools.compact.json +16 -8
- package/schemas/tools.hybrid.json +16 -8
- package/schemas/tools.json +682 -2
- package/scripts/setup-multimodal.mjs +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RecoveryMaintenance } from './maintenance.js';
|
|
1
2
|
import { RecoveryConfigVault } from './configVault.js';
|
|
2
3
|
import { RecoveryDatabases } from './databaseAwareness.js';
|
|
3
4
|
import { RecoveryEvidence } from './evidence.js';
|
|
@@ -16,7 +17,7 @@ import { digestOf } from '../../util/hash.js';
|
|
|
16
17
|
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
|
17
18
|
import { RecoveryPolicySchema } from './contracts.js';
|
|
18
19
|
import { RecoveryStorage, rootIdentity } from './storage.js';
|
|
19
|
-
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']);
|
|
20
21
|
const DATA_EXTENSION = /\.(?:db|sqlite|sqlite3|db-wal|db-shm|log|dump|bak)$/i;
|
|
21
22
|
// Bounded high-confidence screening, NOT a guarantee that every credential is detectable.
|
|
22
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/;
|
|
@@ -25,6 +26,7 @@ const tick = () => new Promise(r => setImmediate(r));
|
|
|
25
26
|
export class RecoveryService {
|
|
26
27
|
s;
|
|
27
28
|
configDir;
|
|
29
|
+
maintenance;
|
|
28
30
|
storage;
|
|
29
31
|
history;
|
|
30
32
|
drift;
|
|
@@ -88,6 +90,11 @@ export class RecoveryService {
|
|
|
88
90
|
identity;
|
|
89
91
|
state = 'INITIALIZING';
|
|
90
92
|
errorCode = null;
|
|
93
|
+
errorDetail = null;
|
|
94
|
+
blockedPath = null;
|
|
95
|
+
skippedSecretFiles = new Set();
|
|
96
|
+
lastWarning = null;
|
|
97
|
+
lastGitCheckpoint = null;
|
|
91
98
|
baseline;
|
|
92
99
|
latest;
|
|
93
100
|
lastTrigger = null;
|
|
@@ -110,6 +117,7 @@ export class RecoveryService {
|
|
|
110
117
|
this.deployments = new RecoveryDeployments(s, this);
|
|
111
118
|
this.databases = new RecoveryDatabases(s, this);
|
|
112
119
|
this.configVault = new RecoveryConfigVault(s, this);
|
|
120
|
+
this.maintenance = new RecoveryMaintenance(s, this);
|
|
113
121
|
}
|
|
114
122
|
project() {
|
|
115
123
|
const row = this.s.store.db.prepare('SELECT id FROM project_registry WHERE workspace_id=? AND removed_at IS NULL').get(this.s.workspaceId);
|
|
@@ -131,10 +139,25 @@ export class RecoveryService {
|
|
|
131
139
|
throw new DodoError('PATH_DENIED', 'path is outside allowed recovery source scope');
|
|
132
140
|
this.s.wfs.resolve(rel, { allowMissing: true });
|
|
133
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
|
+
}
|
|
134
156
|
async currentEntries(targets, extraPaths = []) {
|
|
135
|
-
|
|
157
|
+
const policy = this.policy();
|
|
158
|
+
return this.deadline.run(this.deadline.getStore() ?? Date.now() + policy.scanMs, async () => {
|
|
136
159
|
const read = async () => {
|
|
137
|
-
const inventory = await this.inventory(
|
|
160
|
+
const inventory = await this.inventory(policy, targets);
|
|
138
161
|
const seen = new Set(inventory.files.map(f => f.entry.path));
|
|
139
162
|
for (const p of extraPaths)
|
|
140
163
|
if (!seen.has(p)) {
|
|
@@ -146,14 +169,28 @@ export class RecoveryService {
|
|
|
146
169
|
}
|
|
147
170
|
const r = this.s.wfs.resolve(p, { allowMissing: true });
|
|
148
171
|
if (r.stat?.isFile())
|
|
149
|
-
inventory.files.push(...(await this.inventory(
|
|
172
|
+
inventory.files.push(...(await this.inventory(policy, [p])).files);
|
|
150
173
|
}
|
|
151
|
-
if (inventory.files.length >
|
|
174
|
+
if (inventory.files.length > policy.maxEntries)
|
|
152
175
|
throw new DodoError('RESOURCE_LIMIT', 'source scan exceeds entry budget');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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));
|
|
157
194
|
};
|
|
158
195
|
const first = await read(), second = await read();
|
|
159
196
|
if (digestOf(first) !== digestOf(second))
|
|
@@ -165,18 +202,44 @@ export class RecoveryService {
|
|
|
165
202
|
const r = this.s.store.db.prepare('SELECT payload FROM recovery_policies WHERE workspace_id=?').get(this.s.workspaceId);
|
|
166
203
|
return RecoveryPolicySchema.parse(r ? JSON.parse(r.payload) : {});
|
|
167
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
|
+
}
|
|
168
235
|
/** Private owner service only. Repository configuration never reaches this method. */
|
|
169
236
|
async configure(input, confirmed, revalidate) {
|
|
170
237
|
if (!confirmed)
|
|
171
238
|
throw new DodoError('INVALID_INPUT', 'confirm the Recovery policy change');
|
|
172
|
-
const
|
|
239
|
+
const parsed = RecoveryPolicySchema.parse(input);
|
|
173
240
|
if (!this.project())
|
|
174
241
|
throw new DodoError('NOT_FOUND', 'register this project first');
|
|
175
|
-
|
|
176
|
-
const rel = this.s.wfs.normalizeRel(p);
|
|
177
|
-
if (rel === '.' || rel !== p)
|
|
178
|
-
throw new DodoError('INVALID_INPUT', 'data roots must be canonical relative subdirectories');
|
|
179
|
-
}
|
|
242
|
+
const policy = this.normalizePolicyPaths(parsed);
|
|
180
243
|
if (this.s.jobs.runningCount() || this.s.mutations?.busy)
|
|
181
244
|
throw new DodoError('CONFLICT', 'wait for active mutations and jobs before changing Recovery policy');
|
|
182
245
|
await this.initialization;
|
|
@@ -193,20 +256,119 @@ export class RecoveryService {
|
|
|
193
256
|
this.activated = false;
|
|
194
257
|
this.state = policy.enabled ? 'INITIALIZING' : 'DISABLED_BY_OWNER';
|
|
195
258
|
this.errorCode = null;
|
|
259
|
+
this.errorDetail = null;
|
|
260
|
+
this.blockedPath = null;
|
|
261
|
+
this.lastWarning = null;
|
|
262
|
+
this.skippedSecretFiles.clear();
|
|
196
263
|
this.activate();
|
|
197
264
|
return this.status();
|
|
198
265
|
}
|
|
266
|
+
updateMaintenancePolicy(policy) {
|
|
267
|
+
this.assertProject();
|
|
268
|
+
this.s.mutations.assertCanStart();
|
|
269
|
+
if (this.s.jobs.runningCount())
|
|
270
|
+
throw new DodoError('CONFLICT', 'wait for running jobs before changing Recovery 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
|
+
});
|
|
358
|
+
}
|
|
359
|
+
protectedCheckpointIds() { return [this.baseline, this.latest]; }
|
|
199
360
|
summary() {
|
|
200
|
-
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();
|
|
201
362
|
const drift = this.drift.status();
|
|
202
|
-
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() };
|
|
203
364
|
}
|
|
204
365
|
status() {
|
|
205
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));
|
|
206
367
|
const policy = this.policy();
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 };
|
|
210
372
|
}
|
|
211
373
|
activate() {
|
|
212
374
|
if (this.closed || this.activated)
|
|
@@ -216,7 +378,7 @@ export class RecoveryService {
|
|
|
216
378
|
return;
|
|
217
379
|
}
|
|
218
380
|
catch (e) {
|
|
219
|
-
this.fail(e);
|
|
381
|
+
this.fail(e, true);
|
|
220
382
|
return;
|
|
221
383
|
}
|
|
222
384
|
this.activated = true;
|
|
@@ -227,13 +389,18 @@ export class RecoveryService {
|
|
|
227
389
|
this.configVault.reconcile();
|
|
228
390
|
this.reconciled = true;
|
|
229
391
|
}
|
|
230
|
-
|
|
392
|
+
const policy = this.policy();
|
|
393
|
+
if (!policy.enabled || policy.mode === 'off') {
|
|
231
394
|
this.state = 'DISABLED_BY_OWNER';
|
|
232
395
|
return;
|
|
233
396
|
}
|
|
234
397
|
this.initialization = (async () => {
|
|
235
398
|
// The same queue protects activation from direct, agent and scheduled writers.
|
|
236
399
|
await this.s.mutations.run(async () => {
|
|
400
|
+
if (policy.mode === 'git') {
|
|
401
|
+
await this.createGitCheckpoint('activation', 'system:activation');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
237
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);
|
|
238
405
|
if (prior) {
|
|
239
406
|
const m = await this.storage.readVerified(prior.id, this.s.workspaceId);
|
|
@@ -248,7 +415,22 @@ export class RecoveryService {
|
|
|
248
415
|
});
|
|
249
416
|
})().catch(e => { this.fail(e); });
|
|
250
417
|
}
|
|
251
|
-
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
|
+
}
|
|
252
434
|
async checkpoint(trigger, actor, targets) {
|
|
253
435
|
if (this.closed)
|
|
254
436
|
throw new DodoError('CONFLICT', 'Recovery runtime is closing');
|
|
@@ -261,10 +443,33 @@ export class RecoveryService {
|
|
|
261
443
|
this.configVault.reconcile();
|
|
262
444
|
this.reconciled = true;
|
|
263
445
|
}
|
|
264
|
-
|
|
446
|
+
const policy = this.policy();
|
|
447
|
+
if (!policy.enabled || policy.mode === 'off') {
|
|
265
448
|
this.state = 'DISABLED_BY_OWNER';
|
|
266
449
|
return undefined;
|
|
267
450
|
}
|
|
451
|
+
if (policy.mode === 'git') {
|
|
452
|
+
return this.s.mutations.run(async () => {
|
|
453
|
+
try {
|
|
454
|
+
const cp = await this.createGitCheckpoint(trigger, actor);
|
|
455
|
+
this.activated = true;
|
|
456
|
+
this.authority.getStore()?.();
|
|
457
|
+
await this.reviewedRestore.getStore()?.();
|
|
458
|
+
return cp.id;
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
if (e instanceof DodoError && ['FORBIDDEN', 'AUTH_REQUIRED'].includes(e.code))
|
|
462
|
+
throw e;
|
|
463
|
+
this.fail(e);
|
|
464
|
+
if (!policy.strictEnforcement && (trigger === 'before-write' || trigger === 'before-exec' || trigger === 'activation')) {
|
|
465
|
+
this.authority.getStore()?.();
|
|
466
|
+
await this.reviewedRestore.getStore()?.();
|
|
467
|
+
return this.lastGitCheckpoint?.id;
|
|
468
|
+
}
|
|
469
|
+
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' });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
268
473
|
// Initialization may itself be waiting behind this call's mutation ticket.
|
|
269
474
|
// Capture here within that ticket; never await a queued activation from a writer.
|
|
270
475
|
return this.s.mutations.run(async () => {
|
|
@@ -280,14 +485,18 @@ export class RecoveryService {
|
|
|
280
485
|
await this.capture('activation', 'system:activation');
|
|
281
486
|
}
|
|
282
487
|
await this.storage.readVerified(this.baseline, this.s.workspaceId);
|
|
283
|
-
if (!this.reviewedRestore.getStore() && (trigger === 'before-write' || trigger === 'before-exec'))
|
|
488
|
+
if (policy.checkDrift && !this.reviewedRestore.getStore() && (trigger === 'before-write' || trigger === 'before-exec')) {
|
|
284
489
|
try {
|
|
285
|
-
await this.deadline.run(Date.now() +
|
|
490
|
+
await this.deadline.run(Date.now() + policy.scanMs, () => this.drift.guard(targets));
|
|
286
491
|
}
|
|
287
492
|
catch (e) {
|
|
288
|
-
|
|
289
|
-
|
|
493
|
+
if (policy.strictEnforcement) {
|
|
494
|
+
guardRejected = e instanceof DodoError && ['FILE_CHANGED', 'CONFLICT'].includes(e.code);
|
|
495
|
+
throw e;
|
|
496
|
+
}
|
|
497
|
+
this.lastWarning = e instanceof Error ? e.message : String(e);
|
|
290
498
|
}
|
|
499
|
+
}
|
|
291
500
|
const id = await this.capture(trigger, actor, targets);
|
|
292
501
|
this.activated = true;
|
|
293
502
|
this.startScanner();
|
|
@@ -304,8 +513,15 @@ export class RecoveryService {
|
|
|
304
513
|
catch (e) {
|
|
305
514
|
if (guardRejected || e instanceof DodoError && (['FORBIDDEN', 'AUTH_REQUIRED'].includes(e.code)))
|
|
306
515
|
throw e;
|
|
307
|
-
|
|
308
|
-
|
|
516
|
+
const criticalIdentity = e instanceof DodoError && e.code === 'PATH_DENIED' && /original registered project|private-state overlap|previous root identity/.test(e.message);
|
|
517
|
+
const mustBlock = criticalIdentity || policy.strictEnforcement || policy.gitRequired || Boolean(policy.gitDirectory) || trigger === 'verified-checkpoint';
|
|
518
|
+
this.fail(e, mustBlock);
|
|
519
|
+
if (!mustBlock && (trigger === 'before-write' || trigger === 'before-exec' || trigger === 'activation')) {
|
|
520
|
+
this.authority.getStore()?.();
|
|
521
|
+
await this.reviewedRestore.getStore()?.();
|
|
522
|
+
return this.latest ?? this.lastGitCheckpoint?.id;
|
|
523
|
+
}
|
|
524
|
+
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' });
|
|
309
525
|
}
|
|
310
526
|
});
|
|
311
527
|
}
|
|
@@ -313,7 +529,7 @@ export class RecoveryService {
|
|
|
313
529
|
excluded(rel, dir, policy) {
|
|
314
530
|
if (rel === '.')
|
|
315
531
|
return false;
|
|
316
|
-
return rel.split('/').some(p => OMIT_DIR.has(p)) || DATA_EXTENSION.test(rel) || policy.dataRoots.some(r => r === rel || rel.startsWith(r + '/')) || this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || (dir && this.s.wfs.ignores.isSecret(rel + '/'));
|
|
532
|
+
return rel.split('/').some(p => OMIT_DIR.has(p)) || DATA_EXTENSION.test(rel) || [...policy.dataRoots, ...policy.excludePaths].some(r => r === rel || rel.startsWith(r + '/')) || this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || (dir && this.s.wfs.ignores.isSecret(rel + '/'));
|
|
317
533
|
}
|
|
318
534
|
async inventory(policy, targets) {
|
|
319
535
|
this.project();
|
|
@@ -329,8 +545,10 @@ export class RecoveryService {
|
|
|
329
545
|
if (visited % 100 === 0)
|
|
330
546
|
await tick();
|
|
331
547
|
if (this.excluded(rel, false, policy)) {
|
|
332
|
-
if (target)
|
|
333
|
-
|
|
548
|
+
if (target && (this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || policy.strictEnforcement)) {
|
|
549
|
+
this.blockedPath = rel;
|
|
550
|
+
throw new DodoError('PATH_DENIED', `mutation target is excluded from source Recovery: ${rel}`, { detail: { path: rel } });
|
|
551
|
+
}
|
|
334
552
|
excluded++;
|
|
335
553
|
return;
|
|
336
554
|
}
|
|
@@ -341,8 +559,10 @@ export class RecoveryService {
|
|
|
341
559
|
return;
|
|
342
560
|
}
|
|
343
561
|
if (this.excluded(rel, st.isDirectory(), policy)) {
|
|
344
|
-
if (target)
|
|
345
|
-
|
|
562
|
+
if (target && (this.s.wfs.ignores.isSecret(rel) || this.s.wfs.ignores.isProtected(rel) || policy.strictEnforcement)) {
|
|
563
|
+
this.blockedPath = rel;
|
|
564
|
+
throw new DodoError('PATH_DENIED', `mutation target is excluded from source Recovery: ${rel}`, { detail: { path: rel } });
|
|
565
|
+
}
|
|
346
566
|
excluded++;
|
|
347
567
|
return;
|
|
348
568
|
}
|
|
@@ -360,8 +580,15 @@ export class RecoveryService {
|
|
|
360
580
|
}
|
|
361
581
|
else {
|
|
362
582
|
this.s.wfs.assertRegularFileForDirectAccess(r);
|
|
363
|
-
if (st.size > policy.fileBytes)
|
|
364
|
-
|
|
583
|
+
if (st.size > policy.fileBytes) {
|
|
584
|
+
if (!policy.strictEnforcement && !target) {
|
|
585
|
+
this.lastWarning = `ข้ามไฟล์ขนาดใหญ่เกินขีดจำกัด (${rel})`;
|
|
586
|
+
excluded++;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.blockedPath = rel;
|
|
590
|
+
throw new DodoError('RESOURCE_LIMIT', `source file exceeds Recovery file budget: ${rel}`, { detail: { path: rel } });
|
|
591
|
+
}
|
|
365
592
|
files.push({ entry: { path: rel, kind: 'file', hash: null, bytes: st.size, mode: st.mode & 0o777 }, signature: this.signature(st) });
|
|
366
593
|
}
|
|
367
594
|
};
|
|
@@ -379,7 +606,7 @@ export class RecoveryService {
|
|
|
379
606
|
const distinct = new Map(files.map(f => [f.entry.path, f]));
|
|
380
607
|
return { files: [...distinct.values()].sort((a, b) => a.entry.path.localeCompare(b.entry.path)), excluded };
|
|
381
608
|
}
|
|
382
|
-
async readFile(file, stage) {
|
|
609
|
+
async readFile(file, stage, policy = this.policy()) {
|
|
383
610
|
const r = this.s.wfs.resolve(file.entry.path);
|
|
384
611
|
this.s.wfs.assertRegularFileForDirectAccess(r);
|
|
385
612
|
if (!r.stat || this.signature(r.stat) !== file.signature)
|
|
@@ -401,10 +628,15 @@ export class RecoveryService {
|
|
|
401
628
|
bytes += bytesRead;
|
|
402
629
|
if (bytes > file.entry.bytes)
|
|
403
630
|
throw new DodoError('FILE_CHANGED', 'source grew during backup');
|
|
404
|
-
const chunk = buf.subarray(0, bytesRead)
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
631
|
+
const chunk = buf.subarray(0, bytesRead);
|
|
632
|
+
if (policy.scanContentSecrets) {
|
|
633
|
+
const text = tail + chunk.toString('utf8');
|
|
634
|
+
if (SECRET.test(text)) {
|
|
635
|
+
this.blockedPath = file.entry.path;
|
|
636
|
+
throw new DodoError('SECRET_PATH_DENIED', `credential-like content found during source backup: ${file.entry.path}`, { detail: { path: file.entry.path } });
|
|
637
|
+
}
|
|
638
|
+
tail = text.slice(-1024);
|
|
639
|
+
}
|
|
408
640
|
hash.update(chunk);
|
|
409
641
|
if (output) {
|
|
410
642
|
let offset = 0;
|
|
@@ -440,13 +672,33 @@ export class RecoveryService {
|
|
|
440
672
|
const inventory = await this.inventory(policy, targets);
|
|
441
673
|
// Hash before reserving: unchanged objects cost logical references, not duplicate disk bytes.
|
|
442
674
|
const expectedHashes = new Map();
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
675
|
+
const keptFiles = [];
|
|
676
|
+
const skippedInPass = new Set();
|
|
677
|
+
for (const f of inventory.files) {
|
|
678
|
+
if (f.entry.kind === 'file') {
|
|
679
|
+
try {
|
|
680
|
+
expectedHashes.set(f.entry.path, await this.readFile(f, undefined, policy));
|
|
681
|
+
}
|
|
682
|
+
catch (e) {
|
|
683
|
+
if (e instanceof DodoError && e.code === 'SECRET_PATH_DENIED' && policy.skipSecretContentFiles) {
|
|
684
|
+
this.recordSkippedSecret(f.entry.path);
|
|
685
|
+
this.blockedPath = null;
|
|
686
|
+
skippedInPass.add(f.entry.path);
|
|
687
|
+
inventory.excluded++;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
throw e;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
keptFiles.push(f);
|
|
694
|
+
}
|
|
695
|
+
inventory.files = keptFiles;
|
|
447
696
|
this.storage.prune(this.s.workspaceId, policy);
|
|
448
697
|
this.storage.collectOrphans();
|
|
449
|
-
this.
|
|
698
|
+
this.s.store.db.transaction(() => {
|
|
699
|
+
const estimate = this.storage.estimate(this.s.workspaceId, inventory.files.filter(f => f.entry.kind === 'file').map(f => ({ hash: expectedHashes.get(f.entry.path), bytes: f.entry.bytes })));
|
|
700
|
+
this.storage.reserve(id, this.s.workspaceId, estimate.physical, policy, estimate.logical, estimate.staging);
|
|
701
|
+
}).immediate();
|
|
450
702
|
this.s.store.db.prepare("INSERT INTO recovery_snapshots (id,workspace_id,project_id,state,scope,created_at) VALUES (?,?,?,'PREPARING',?,?)").run(id, this.s.workspaceId, project.projectId, targets ? 'targets' : 'source', createdAt);
|
|
451
703
|
const entries = [], published = new Set();
|
|
452
704
|
for (const f of inventory.files) {
|
|
@@ -455,9 +707,9 @@ export class RecoveryService {
|
|
|
455
707
|
// Rehash every source file even for identical content. Within this
|
|
456
708
|
// capture only, one CAS publication suffices per hash; publish()
|
|
457
709
|
// freshly verifies every distinct object before recording READY.
|
|
458
|
-
const duplicate = published.has(expectedHashes.get(entry.path));
|
|
710
|
+
const duplicate = published.has(expectedHashes.get(entry.path)) || this.storage.hasObject(expectedHashes.get(entry.path));
|
|
459
711
|
staging = duplicate ? undefined : this.storage.stagePath();
|
|
460
|
-
entry.hash = await this.readFile(f, staging);
|
|
712
|
+
entry.hash = await this.readFile(f, staging, policy);
|
|
461
713
|
if (entry.hash !== expectedHashes.get(entry.path))
|
|
462
714
|
throw new DodoError('FILE_CHANGED', 'source changed after space reservation');
|
|
463
715
|
if (staging)
|
|
@@ -471,12 +723,21 @@ export class RecoveryService {
|
|
|
471
723
|
}
|
|
472
724
|
// Rescan paths AND reread hashes. A watcher or old checkpoint alone is not freshness proof.
|
|
473
725
|
const after = await this.inventory(policy, targets);
|
|
726
|
+
if (skippedInPass.size > 0) {
|
|
727
|
+
after.files = after.files.filter(af => {
|
|
728
|
+
if (skippedInPass.has(af.entry.path)) {
|
|
729
|
+
after.excluded++;
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
return true;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
474
735
|
if (digestOf(after) !== digestOf(inventory))
|
|
475
736
|
throw new DodoError('FILE_CHANGED', 'source inventory changed during backup');
|
|
476
737
|
const entryHashes = new Map(entries.map(e => [e.path, e.hash]));
|
|
477
738
|
for (const f of after.files)
|
|
478
739
|
if (f.entry.kind === 'file') {
|
|
479
|
-
const hash = await this.readFile(f);
|
|
740
|
+
const hash = await this.readFile(f, undefined, policy);
|
|
480
741
|
if (hash !== entryHashes.get(f.entry.path))
|
|
481
742
|
throw new DodoError('FILE_CHANGED', 'source content changed during backup');
|
|
482
743
|
}
|
|
@@ -499,6 +760,8 @@ export class RecoveryService {
|
|
|
499
760
|
this.lastCheckedAt = Date.now();
|
|
500
761
|
this.state = 'READY';
|
|
501
762
|
this.errorCode = null;
|
|
763
|
+
this.errorDetail = null;
|
|
764
|
+
this.blockedPath = null;
|
|
502
765
|
this.s.store.audit({ principal: actor, workspaceId: this.s.workspaceId, tool: 'recovery.checkpoint', result: 'ready', inputDigest: digestOf({ id, trigger }) });
|
|
503
766
|
this.storage.prune(this.s.workspaceId, policy);
|
|
504
767
|
this.git.prune();
|
|
@@ -506,7 +769,8 @@ export class RecoveryService {
|
|
|
506
769
|
}
|
|
507
770
|
catch (e) {
|
|
508
771
|
this.s.store.db.prepare("UPDATE recovery_snapshots SET state='INCOMPLETE' WHERE id=? AND state='PREPARING'").run(id);
|
|
509
|
-
|
|
772
|
+
const mustBlock = (e instanceof DodoError && e.code === 'PATH_DENIED' && /original registered project|private-state overlap|previous root identity/.test(e.message)) || policy.strictEnforcement || policy.gitRequired || Boolean(policy.gitDirectory) || trigger === 'verified-checkpoint';
|
|
773
|
+
this.fail(e, mustBlock);
|
|
510
774
|
throw e;
|
|
511
775
|
}
|
|
512
776
|
finally {
|