Validate recovered trust and config; refresh readers after graph commits

This commit is contained in:
Forge
2026-09-22 03:47:21 +00:00
parent e7f89a43e6
commit 4c13016561
4 changed files with 82 additions and 9 deletions
+24 -1
View File
@@ -143,7 +143,7 @@ if (process.argv[2] === '--child') {
await run(repeated); assert.deepEqual(inspect(repeated), before); clean(repeated);
receipt({ action: 'kill recovery itself', boundary: 'recovery_config', decision: 'idempotent rollback', passed: true });
const live = await project(true), c = child(live, 'update', 'config_written');
await c.reached;
await Promise.race([c.reached, c.done.then(r => { throw new Error('Live-owner fixture exited early: ' + r.stderr); })]);
const lock = path.join(live, '.codegraph/plugins/operation.lock'); fs.utimesSync(lock, 1, 1);
for (const action of ['install', 'inspect', 'index']) {
const result = await run(live, action, 1); assert.match(result.stderr, /operation is active/);
@@ -183,6 +183,29 @@ if (process.argv[2] === '--child') {
await run(conflict); clean(conflict); assert.equal(JSON.parse(config(conflict)).resolvedByUser, true);
receipt({ action: 'conflicting plugin edit', boundary, decision: 'no overwrite; actionable repair then recovery', passed: true });
}
const settings = await project(true); await killAt(settings, 'update', 'config_written');
const changedSettings = JSON.parse(config(settings)); changedSettings.exclude = ['handlers.py'];
const changedRaw = JSON.stringify(changedSettings); fs.writeFileSync(path.join(settings, 'codegraph.json'), changedRaw);
assert.match((await run(settings, 'inspect', 1)).stderr, /exclude was edited externally/);
assert.equal(config(settings), changedRaw);
delete changedSettings.exclude; fs.writeFileSync(path.join(settings, 'codegraph.json'), JSON.stringify(changedSettings));
await run(settings); clean(settings);
receipt({ action: 'graph-affecting external config edit', decision: 'preserved and blocked with repair instructions', passed: true });
const tamper = await project(true); await killAt(tamper, 'update', 'graph_committed');
const entry = JSON.parse(config(tamper)).plugins[0];
const entryFile = path.join(tamper, '.codegraph/plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile);
fs.appendFileSync(entryFile, '\n// external change');
assert.match((await run(tamper, 'inspect', 1)).stderr, /installed package managed:recovery-demo is missing or changed/);
assert.ok(fs.existsSync(path.join(tamper, '.codegraph/plugins/transaction.json')));
fs.writeFileSync(entryFile, originalEntry); await run(tamper); clean(tamper);
receipt({ action: 'changed installed package', decision: 'trust revalidated; blocked until bytes restored', passed: true });
const cached = await project(true), reader = await CodeGraph.open(cached);
try {
const node = reader.getNodesByKind('route')[0]; assert.equal(reader.getNode(node.id).name, '/1.0.0');
await killAt(cached, 'update', 'graph_committed'); await run(cached); clean(cached);
assert.equal(reader.getNode(node.id).name, '/2.0.0');
} finally { reader.close(); }
receipt({ action: 'long-lived reader after fresh-process recovery', decision: 'old cached node invalidated by commit marker', passed: true });
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, node: process.version, rows, commands: commands.length }, null, 2));
} finally {
for (const c of processes) c.kill('SIGKILL');
+22 -4
View File
@@ -8,7 +8,7 @@
import * as path from 'path';
import * as fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { recoverExtensions, withExtensionGuard, extensionTransition } from './plugins/recovery';
import { recoverExtensions, withExtensionGuard, withExtensionGuardSync, extensionTransition } from './plugins/recovery';
import { loadPlugins, pluginFingerprint } from './plugins/loader';
import { withPlugins, currentPlugins, type PluginRegistry } from './plugins/registry';
import { loadPluginEntries } from './project-config';
@@ -136,6 +136,8 @@ export interface OpenOptions {
export interface IndexOptions {
/** @internal Managed lifecycle transaction marker, committed with graph data. */
extensionTransactionId?: string;
/** @internal Revalidate the staged config/trust before the atomic graph commit. */
beforeExtensionCommit?: () => void;
/** Progress callback */
onProgress?: (progress: IndexProgress) => void;
@@ -157,6 +159,7 @@ export class CodeGraph {
private db: DatabaseConnection;
private queries: QueryBuilder;
private projectRoot: string;
private extensionCommit: string | null = null;
// Assigned via wireLayers() from the constructor (and again on reopen) — the
// `!` tells TS these are definitely set even though the assignment is one
// method call away from the constructor body.
@@ -248,8 +251,11 @@ export class CodeGraph {
}
private ensureExtensionState(): void {
if (recoverExtensions(this.projectRoot)) {
const recovered = recoverExtensions(this.projectRoot);
const commit = this.queries.getMetadata('extension_transaction');
if (recovered || commit !== this.extensionCommit) {
this.queries.clearCache(); this.resolver.clearCaches();
this.extensionCommit = commit;
}
}
@@ -299,6 +305,11 @@ export class CodeGraph {
* @returns A new CodeGraph instance
*/
static async init(projectRoot: string, options: InitOptions = {}): Promise<CodeGraph> {
fs.mkdirSync(path.resolve(projectRoot), { recursive: true });
return withExtensionGuard(projectRoot, () => CodeGraph.initGuarded(projectRoot, options));
}
private static async initGuarded(projectRoot: string, options: InitOptions): Promise<CodeGraph> {
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);
recoverExtensions(resolvedRoot);
@@ -330,6 +341,11 @@ export class CodeGraph {
* Initialize synchronously (without indexing)
*/
static initSync(projectRoot: string): CodeGraph {
fs.mkdirSync(path.resolve(projectRoot), { recursive: true });
return withExtensionGuardSync(projectRoot, () => CodeGraph.initSyncGuarded(projectRoot));
}
private static initSyncGuarded(projectRoot: string): CodeGraph {
const resolvedRoot = path.resolve(projectRoot);
recoverExtensions(resolvedRoot);
@@ -545,6 +561,7 @@ export class CodeGraph {
const connection = this.db.getDb();
connection.prepare('ATTACH DATABASE ? AS extension_candidate').run(stagingPath);
attached = true;
options.beforeExtensionCommit?.();
connection.transaction(() => {
this.queries.clear();
for (const table of ['files', 'nodes', 'edges', 'unresolved_refs']) {
@@ -2406,6 +2423,7 @@ export class CodeGraph {
input: TaskInput,
options?: BuildContextOptions
): Promise<TaskContext | string> {
this.ensureExtensionState();
return this.contextBuilder.buildContext(input, options);
}
@@ -2417,14 +2435,14 @@ export class CodeGraph {
* Optimize the database (vacuum and analyze)
*/
optimize(): void {
this.db.optimize();
withExtensionGuardSync(this.projectRoot, () => this.db.optimize());
}
/**
* Clear all data from the graph
*/
clear(): void {
this.queries.clear();
withExtensionGuardSync(this.projectRoot, () => this.queries.clear());
}
/**
+1 -1
View File
@@ -120,7 +120,7 @@ export class ExtensionManager {
this.onProgress({ state: 'indexing', message: 'Building and checking the updated graph' });
const { CodeGraph } = await import('../index');
const graph = CodeGraph.isInitialized(this.root) ? await CodeGraph.open(this.root) : await CodeGraph.init(this.root);
try { await graph.refreshPluginIndex({ extensionTransactionId: transaction.record.id }); } finally { graph.close(); }
try { await graph.refreshPluginIndex({ extensionTransactionId: transaction.record.id, beforeExtensionCommit: () => transaction.assertStaged() }); } finally { graph.close(); }
transaction.reconcile();
} catch (error) {
// The durable graph marker decides rollback vs completion, including
+35 -3
View File
@@ -13,7 +13,9 @@ const transitions = channel('codegraph.extension.transaction');
const dir = (root: string) => path.join(root, '.codegraph', 'plugins');
const recordPath = (root: string) => path.join(dir(root), 'transaction.json');
const ownerPath = (root: string) => path.join(dir(root), 'operation.lock');
const uuid = /^[a-f0-9-]{36}$/;
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const indexSettings = ['extensions', 'includeIgnored', 'exclude', 'include'];
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
interface RecordV1 {
format: 1; id: string; pid: number; graphExisted: boolean; previousConfig: string | null; nextConfig: string;
previousTrust: string | null; nextTrust: string | null;
@@ -88,7 +90,7 @@ function checkOwner(root: string, journal?: RecordV1): void {
const owner = JSON.parse(raw);
// For current writers, the acquired SQLite lock is authoritative even if
// this PID is alive/reused. It cannot be acquired during a live operation.
if (owner?.format === 'codegraph-operation-lock-1' && uuid.test(owner.id) && Number.isSafeInteger(owner.pid)) return;
if (owner?.format === 'codegraph-operation-lock-1' && uuid.test(owner.id) && Number.isSafeInteger(owner.pid) && owner.pid > 0) return;
} catch { /* legacy/torn */ }
if (journal) return; // durable v1 journal + acquired SQLite lock prove orphan
if (/^[1-9]\d*$/.test(raw.trim())) {
@@ -132,6 +134,21 @@ function cleanup(root: string, r: RecordV1, committed: boolean): void {
const graphLock = path.join(root, '.codegraph', 'codegraph.lock');
if (read(graphLock)?.trim() === String(r.pid)) remove(graphLock);
}
/** Validate the version/trust side before removing the only recovery record. */
function verifyPackages(root: string, config: string | null, trust: string | null): void {
const entries = object(config).plugins, trusted = object(trust);
if (!Array.isArray(entries)) return;
for (const entry of entries) {
if (!entry || typeof entry.name !== 'string' || !entry.name.startsWith('managed:')) continue;
if (!/^[a-f0-9]{64}$/.test(entry.integrity)) fail(root, 'invalid installed package integrity');
const packageRoot = path.join(dir(root), 'packages', entry.integrity);
try {
const pkg = object(read(path.join(packageRoot, 'package.json')));
if (pkg.version !== entry.version || (pkg.codegraph as { id?: string })?.id !== entry.name.slice(8) ||
packageDigest(packageRoot) !== trusted[fs.realpathSync(packageRoot)]) throw new Error('version/identity/trust mismatch');
} catch (error) { fail(root, `installed package ${entry.name} is missing or changed (${String(error)}); restore the recorded package bytes/trust from backup before recovery`); }
}
}
/** Reconcile synchronously under the coordinator; never evaluates extension code. */
function reconcile(root: string): boolean {
const r = loadRecord(root); checkOwner(root, r);
@@ -142,7 +159,8 @@ function reconcile(root: string): boolean {
let targetConfig: string | null, targetTrust: string | null;
try {
const previous = object(r.previousConfig), next = object(r.nextConfig), current = object(configRaw);
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
for (const key of indexSettings) if (!same(current[key], next[key]))
fail(root, `${key} was edited externally and may change the graph; save that edit separately, restore the recorded ${key}, recover, then reapply it and run codegraph index`);
const wanted = committed ? next.plugins : previous.plugins;
if (!same(current.plugins, wanted) && (committed || !same(current.plugins, next.plugins)))
fail(root, `plugins were edited externally; graph ${committed ? 'committed the new version (restore nextConfig.plugins)' : 'retains the old version (restore previousConfig.plugins)'}`);
@@ -160,6 +178,7 @@ function reconcile(root: string): boolean {
}
targetTrust = trustRaw === r.nextTrust || trustRaw === r.previousTrust ? (committed ? r.nextTrust : r.previousTrust) : JSON.stringify(currentTrust);
} catch (error) { return fail(root, String(error)); }
verifyPackages(root, targetConfig, targetTrust);
replaceObserved(root, configFile, configRaw, targetConfig);
extensionTransition(root, r.id, 'recovery_config');
replaceObserved(root, trustFile, trustRaw, targetTrust);
@@ -179,6 +198,13 @@ export function recoverExtensions(root: string): boolean {
const release = acquire(root);
try { return context.run(root, () => reconcile(root)); } finally { release(); }
}
export function withExtensionGuardSync<T>(root: string, work: () => T): T {
root = fs.realpathSync(root);
if (context.getStore() === root) return work();
const release = acquire(root);
try { return context.run(root, () => { reconcile(root); return work(); }); }
finally { release(); }
}
/** Indexers and lifecycle writers share this OS-released lock for their full run. */
export async function withExtensionGuard<T>(root: string, work: () => Promise<T>): Promise<T> {
root = fs.realpathSync(root);
@@ -222,5 +248,11 @@ export class ExtensionTransaction {
replaceObserved(this.root, path.join(this.root, 'codegraph.json'), r.previousConfig, r.nextConfig);
clearProjectConfigCache(); extensionTransition(this.root, r.id, 'config_written');
}
assertStaged(): void {
const expected = object(this.record.nextConfig), current = object(read(path.join(this.root, 'codegraph.json')));
for (const key of ['plugins', ...indexSettings]) if (!same(current[key], expected[key]))
fail(this.root, `${key} changed while building the candidate; preserve the edit and reconcile it with the recorded config`);
verifyPackages(this.root, this.record.nextConfig, read(path.join(dir(this.root), 'trust.json')));
}
reconcile(): void { reconcile(this.root); }
}