Skip to content

Commit bf22a8d

Browse files
committed
Make project-trust saves atomic and serialized
Persist project trust via temp-file + rename and serialize load-mutate-save behind a per-path mutation queue, mirroring path-trust.ts. Prevents concurrent plugin/MCP trust grants from interleaving and dropping a grant or leaving truncated JSON on disk.
1 parent 2b523c2 commit bf22a8d

2 files changed

Lines changed: 143 additions & 14 deletions

File tree

src/trust/project-trust.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { mkdtemp, readFile, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import {
7+
loadProjectTrust,
8+
projectTrustPath,
9+
trustMcpServer,
10+
trustPlugin,
11+
type ProjectTrustStore,
12+
} from "./project-trust.js";
13+
import type { MCPServerConfig } from "../config/settings.js";
14+
15+
async function withTempHome(fn: (home: string, cwd: string) => Promise<void>): Promise<void> {
16+
const home = await mkdtemp(join(tmpdir(), "project-trust-test-"));
17+
try {
18+
await fn(home, "/repo/under/test");
19+
} finally {
20+
await rm(home, { recursive: true, force: true });
21+
}
22+
}
23+
24+
const mcpServer = (name: string): MCPServerConfig => ({ name, command: "node", args: [name] });
25+
26+
describe("project trust store", () => {
27+
test("concurrent plugin trust grants both survive without a corrupt file", async () => {
28+
await withTempHome(async (home, cwd) => {
29+
await Promise.all([
30+
trustPlugin(cwd, "/plugins/a", home),
31+
trustPlugin(cwd, "/plugins/b", home),
32+
]);
33+
34+
const store = await loadProjectTrust(cwd, home);
35+
expect(store.trustedPluginPaths.sort()).toEqual(["/plugins/a", "/plugins/b"]);
36+
37+
// File on disk must be complete, valid JSON — not truncated by an
38+
// interleaved write.
39+
const raw = await readFile(projectTrustPath(cwd, home), "utf8");
40+
expect(() => JSON.parse(raw)).not.toThrow();
41+
});
42+
});
43+
44+
test("concurrent plugin-trust and MCP-trust updates both survive", async () => {
45+
await withTempHome(async (home, cwd) => {
46+
const server1 = mcpServer("server-one");
47+
const server2 = mcpServer("server-two");
48+
49+
await Promise.all([
50+
trustPlugin(cwd, "/plugins/a", home),
51+
trustMcpServer(cwd, server1, home),
52+
trustPlugin(cwd, "/plugins/b", home),
53+
trustMcpServer(cwd, server2, home),
54+
]);
55+
56+
const store: ProjectTrustStore = await loadProjectTrust(cwd, home);
57+
expect(store.trustedPluginPaths.sort()).toEqual(["/plugins/a", "/plugins/b"]);
58+
expect(store.trustedMcpFingerprints).toHaveLength(2);
59+
60+
const raw = await readFile(projectTrustPath(cwd, home), "utf8");
61+
expect(() => JSON.parse(raw)).not.toThrow();
62+
});
63+
});
64+
65+
test("many concurrent writers never drop a grant", async () => {
66+
await withTempHome(async (home, cwd) => {
67+
const pluginPaths = Array.from({ length: 20 }, (_, i) => `/plugins/p${i}`);
68+
const servers = Array.from({ length: 20 }, (_, i) => mcpServer(`server-${i}`));
69+
70+
await Promise.all([
71+
...pluginPaths.map((p) => trustPlugin(cwd, p, home)),
72+
...servers.map((s) => trustMcpServer(cwd, s, home)),
73+
]);
74+
75+
const store = await loadProjectTrust(cwd, home);
76+
expect(store.trustedPluginPaths).toHaveLength(pluginPaths.length);
77+
expect(store.trustedMcpFingerprints).toHaveLength(servers.length);
78+
79+
const raw = await readFile(projectTrustPath(cwd, home), "utf8");
80+
const parsed = JSON.parse(raw);
81+
expect(Array.isArray(parsed.trustedPluginPaths)).toBe(true);
82+
expect(Array.isArray(parsed.trustedMcpFingerprints)).toBe(true);
83+
});
84+
});
85+
86+
test("no leftover .tmp file remains after concurrent writes settle", async () => {
87+
await withTempHome(async (home, cwd) => {
88+
await Promise.all([
89+
trustPlugin(cwd, "/plugins/a", home),
90+
trustPlugin(cwd, "/plugins/b", home),
91+
trustMcpServer(cwd, mcpServer("s"), home),
92+
]);
93+
const path = projectTrustPath(cwd, home);
94+
const tmp = `${path}.${process.pid}.tmp`;
95+
const exists = await readFile(tmp, "utf8").then(
96+
() => true,
97+
() => false,
98+
);
99+
expect(exists).toBe(false);
100+
});
101+
});
102+
});

src/trust/project-trust.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, writeFile } from "node:fs/promises";
1+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
22
import { homedir } from "node:os";
33
import { dirname, join, resolve } from "node:path";
44
import { createHash } from "node:crypto";
@@ -148,11 +148,34 @@ export async function loadProjectTrust(cwd: string, home: string = homedir()): P
148148
return (await readProjectTrustStore(cwd, home)).store;
149149
}
150150

151+
// Written via temp-file + rename (same pattern as path-trust.ts / saveGlobalSettings)
152+
// so a concurrent reader never sees a truncated or half-written store — a torn
153+
// read would be indistinguishable from a corrupt file and wipe consent.
151154
async function saveProjectTrust(cwd: string, store: ProjectTrustStore, home: string = homedir()): Promise<void> {
152155
const path = projectTrustPath(cwd, home);
153156
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
154157
const record = { repo: resolve(cwd), ...store };
155-
await writeFile(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
158+
const tmp = `${path}.${process.pid}.tmp`;
159+
await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
160+
await rename(tmp, path);
161+
}
162+
163+
// The grant helpers re-read the store immediately before writing, but two
164+
// in-process mutations interleaving between that read and the write would
165+
// still drop grants (e.g. a plugin-trust and an MCP-trust update landing at
166+
// the same time). Chain them per trust-store path so each mutation sees the
167+
// previous one's result. (Cross-process writers remain last-writer-wins of a
168+
// complete file, same as path-trust.ts.)
169+
const mutationQueues = new Map<string, Promise<unknown>>();
170+
171+
function enqueueMutation<T>(key: string, run: () => Promise<T>): Promise<T> {
172+
const prior = mutationQueues.get(key) ?? Promise.resolve();
173+
const next = prior.then(run, run);
174+
mutationQueues.set(
175+
key,
176+
next.catch(() => undefined),
177+
);
178+
return next;
156179
}
157180

158181
export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string): boolean {
@@ -165,13 +188,15 @@ export async function trustPlugin(
165188
pluginPath: string,
166189
home: string = homedir(),
167190
): Promise<ProjectTrustStore> {
168-
const store = await loadProjectTrust(cwd, home);
169191
const abs = resolve(pluginPath);
170-
if (!store.trustedPluginPaths.includes(abs)) {
171-
store.trustedPluginPaths = [...store.trustedPluginPaths, abs];
172-
await saveProjectTrust(cwd, store, home);
173-
}
174-
return store;
192+
return enqueueMutation(projectTrustPath(cwd, home), async () => {
193+
const store = await loadProjectTrust(cwd, home);
194+
if (!store.trustedPluginPaths.includes(abs)) {
195+
store.trustedPluginPaths = [...store.trustedPluginPaths, abs];
196+
await saveProjectTrust(cwd, store, home);
197+
}
198+
return store;
199+
});
175200
}
176201

177202
/**
@@ -200,13 +225,15 @@ export async function trustMcpServer(
200225
server: MCPServerConfig,
201226
home: string = homedir(),
202227
): Promise<ProjectTrustStore> {
203-
const store = await loadProjectTrust(cwd, home);
204228
const fp = mcpServerFingerprint(server);
205-
if (!store.trustedMcpFingerprints.includes(fp)) {
206-
store.trustedMcpFingerprints = [...store.trustedMcpFingerprints, fp];
207-
await saveProjectTrust(cwd, store, home);
208-
}
209-
return store;
229+
return enqueueMutation(projectTrustPath(cwd, home), async () => {
230+
const store = await loadProjectTrust(cwd, home);
231+
if (!store.trustedMcpFingerprints.includes(fp)) {
232+
store.trustedMcpFingerprints = [...store.trustedMcpFingerprints, fp];
233+
await saveProjectTrust(cwd, store, home);
234+
}
235+
return store;
236+
});
210237
}
211238

212239
/**

0 commit comments

Comments
 (0)