Skip to content

Commit aa323fa

Browse files
committed
feat(mcp): 支持本地 AI 安全配置规则
1 parent c40b860 commit aa323fa

20 files changed

Lines changed: 2126 additions & 28 deletions
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { parse } from "jsonc-parser";
3+
4+
interface RuleSnapshot {
5+
type: "xswitch-rules";
6+
version: number;
7+
exportedAt: string;
8+
items: Array<{ id: string; name: string; active: boolean }>;
9+
rules: Record<string, string>;
10+
}
11+
12+
function clone<T>(value: T): T {
13+
return structuredClone(value);
14+
}
15+
16+
async function createBridge() {
17+
let rules: RuleSnapshot = {
18+
type: "xswitch-rules",
19+
version: 1,
20+
exportedAt: "2026-07-20T00:00:00.000Z",
21+
items: [{ id: "0", name: "Current", active: true }],
22+
rules: { "0": '{"proxy":[],"cors":[]}' },
23+
};
24+
const originalRules = clone(rules);
25+
let extensionEnabled = true;
26+
let options = {
27+
clearCacheEnabled: "enabled",
28+
corsEnabled: "enabled",
29+
mcpEnabled: "enabled",
30+
};
31+
let backups: unknown[] = [];
32+
let corruptNextImport = false;
33+
34+
const exportRules = vi.fn(async () => clone(rules));
35+
const importRules = vi.fn(async (snapshot: RuleSnapshot) => {
36+
rules = clone(snapshot);
37+
if (corruptNextImport) {
38+
corruptNextImport = false;
39+
rules.items = rules.items.filter((item) => item.id !== "0");
40+
delete rules.rules["0"];
41+
}
42+
});
43+
const getChecked = vi.fn(async () =>
44+
extensionEnabled ? "enabled" : "disabled"
45+
);
46+
const setChecked = vi.fn(async (enabled: boolean) => {
47+
extensionEnabled = enabled;
48+
return {};
49+
});
50+
const getOptions = vi.fn(async () => ({ ...options }));
51+
const setOptions = vi.fn(async (next: Record<string, boolean>) => {
52+
options = {
53+
clearCacheEnabled: next.clearCacheEnabled ? "enabled" : "disabled",
54+
corsEnabled: next.corsEnabled ? "enabled" : "disabled",
55+
mcpEnabled: next.mcpEnabled ? "enabled" : "disabled",
56+
};
57+
return options;
58+
});
59+
60+
vi.doMock("../src/chrome-storage", () => ({
61+
exportRules,
62+
importRules,
63+
getChecked,
64+
setChecked,
65+
getOptions,
66+
setOptions,
67+
}));
68+
69+
let messageListener: ((message: unknown) => void) | undefined;
70+
const postedMessages: Array<Record<string, unknown>> = [];
71+
const port = {
72+
onMessage: {
73+
addListener(listener: (message: unknown) => void) {
74+
messageListener = listener;
75+
},
76+
},
77+
onDisconnect: { addListener: vi.fn() },
78+
postMessage(message: Record<string, unknown>) {
79+
postedMessages.push(message);
80+
},
81+
disconnect: vi.fn(),
82+
};
83+
84+
vi.stubGlobal("chrome", {
85+
permissions: {
86+
contains: (_permissions: unknown, callback: (granted: boolean) => void) =>
87+
callback(true),
88+
},
89+
runtime: {
90+
lastError: undefined,
91+
connectNative: () => port,
92+
getManifest: () => ({ version: "test" }),
93+
},
94+
storage: {
95+
local: {
96+
get(defaults: Record<string, unknown>, callback: (value: unknown) => void) {
97+
callback({
98+
mcpBackups:
99+
backups.length > 0 ? clone(backups) : clone(defaults.mcpBackups),
100+
});
101+
},
102+
set(value: { mcpBackups: unknown[] }, callback: () => void) {
103+
backups = clone(value.mcpBackups);
104+
callback();
105+
},
106+
},
107+
},
108+
});
109+
110+
const { setMcpBridgeEnabled } = await import("../src/mcp-bridge");
111+
setMcpBridgeEnabled(true);
112+
await vi.waitFor(() => expect(messageListener).toBeTypeOf("function"));
113+
114+
async function send(method: string, params: Record<string, unknown> = {}) {
115+
const id = crypto.randomUUID();
116+
messageListener?.({ type: "request", id, method, params });
117+
await vi.waitFor(() =>
118+
expect(postedMessages.some((message) => message.id === id)).toBe(true)
119+
);
120+
return postedMessages.find((message) => message.id === id);
121+
}
122+
123+
return {
124+
send,
125+
originalRules,
126+
getRules: () => clone(rules),
127+
getBackups: () => clone(backups) as Array<Record<string, unknown>>,
128+
corruptNextWrite: () => {
129+
corruptNextImport = true;
130+
},
131+
};
132+
}
133+
134+
beforeEach(() => {
135+
vi.resetModules();
136+
vi.clearAllMocks();
137+
vi.unstubAllGlobals();
138+
});
139+
140+
describe("MCP bridge rollback safety", () => {
141+
it("stores a committed snapshot before a successful write", async () => {
142+
const bridge = await createBridge();
143+
const response = await bridge.send("upsert_rule_group", {
144+
name: "AI Test",
145+
active: false,
146+
proxy: [["https://example.invalid/a.js", "http://127.0.0.1:3000/a.js"]],
147+
cors: [],
148+
});
149+
150+
expect(response?.error).toBeUndefined();
151+
expect(response?.result).toMatchObject({ rollback_available: true });
152+
const savedRules = bridge.getRules();
153+
expect(savedRules.items).toHaveLength(2);
154+
const createdId = savedRules.items.find((item) => item.id !== "0")?.id;
155+
const createdJsonc = createdId ? savedRules.rules[createdId] : "";
156+
expect(createdJsonc).toContain(
157+
"// Use IntelliSense to learn about possible links."
158+
);
159+
expect(createdJsonc).toContain("// `Command/Ctrl + click` to visit:");
160+
expect(createdJsonc).toContain("// urls that want CORS");
161+
expect(parse(createdJsonc, [], { allowTrailingComma: true })).toMatchObject({
162+
proxy: [
163+
["https://example.invalid/a.js", "http://127.0.0.1:3000/a.js"],
164+
],
165+
});
166+
expect(bridge.getBackups()).toHaveLength(1);
167+
expect(bridge.getBackups()[0]).toMatchObject({
168+
operation: "upsert_rule_group",
169+
status: "committed",
170+
state: { rules: bridge.originalRules },
171+
});
172+
});
173+
174+
it("preserves JSONC comments when updating an existing rule group", async () => {
175+
const bridge = await createBridge();
176+
await bridge.send("upsert_rule_group", {
177+
name: "Commented",
178+
active: false,
179+
proxy: [["https://example.invalid/old.js", "http://127.0.0.1/old.js"]],
180+
cors: [],
181+
});
182+
const created = bridge.getRules().items.find((item) => item.id !== "0");
183+
if (!created) throw new Error("Expected a created rule group");
184+
185+
await bridge.send("upsert_rule_group", {
186+
group_id: created.id,
187+
proxy: [["https://example.invalid/new.js", "http://127.0.0.1/new.js"]],
188+
});
189+
190+
const updatedJsonc = bridge.getRules().rules[created.id];
191+
expect(updatedJsonc).toContain(
192+
"// Use IntelliSense to learn about possible links."
193+
);
194+
expect(updatedJsonc).toContain("// `Command/Ctrl + click` to visit:");
195+
expect(updatedJsonc).toContain("// urls that want CORS");
196+
expect(parse(updatedJsonc, [], { allowTrailingComma: true })).toMatchObject({
197+
proxy: [
198+
["https://example.invalid/new.js", "http://127.0.0.1/new.js"],
199+
],
200+
});
201+
});
202+
203+
it("automatically restores the snapshot when post-write validation fails", async () => {
204+
const bridge = await createBridge();
205+
bridge.corruptNextWrite();
206+
const response = await bridge.send("upsert_rule_group", {
207+
name: "Broken Test",
208+
active: false,
209+
proxy: [],
210+
cors: [],
211+
});
212+
213+
expect(response?.error).toContain("已自动回滚");
214+
expect(bridge.getRules()).toEqual(bridge.originalRules);
215+
expect(bridge.getBackups()).toEqual([]);
216+
});
217+
218+
it("backs up the current state before restoring an earlier snapshot", async () => {
219+
const bridge = await createBridge();
220+
await bridge.send("upsert_rule_group", {
221+
name: "Undo Me",
222+
active: false,
223+
proxy: [],
224+
cors: [],
225+
});
226+
const changedRules = bridge.getRules();
227+
const originalBackupId = bridge.getBackups()[0].id;
228+
229+
const response = await bridge.send("restore_backup");
230+
231+
expect(response?.result).toMatchObject({
232+
restored_backup_id: originalBackupId,
233+
rollback_available: true,
234+
});
235+
expect(bridge.getRules()).toEqual(bridge.originalRules);
236+
expect(bridge.getBackups()).toHaveLength(2);
237+
expect(bridge.getBackups()[0]).toMatchObject({
238+
operation: "restore_backup",
239+
status: "committed",
240+
state: { rules: changedRules },
241+
});
242+
});
243+
});

__tests__/mcp-protocol.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
encodeJsonLine,
4+
encodeNativeMessage,
5+
JsonLineDecoder,
6+
NativeMessageDecoder,
7+
} from "../mcp/protocol.js";
8+
9+
describe("MCP bridge protocol", () => {
10+
it("decodes fragmented Chrome Native Messaging frames", () => {
11+
const decoder = new NativeMessageDecoder();
12+
const first = encodeNativeMessage({ type: "hello", value: "中文" });
13+
const second = encodeNativeMessage({ type: "response", id: "2" });
14+
const combined = Buffer.concat([first, second]);
15+
16+
expect(decoder.push(combined.subarray(0, 3))).toEqual([]);
17+
expect(decoder.push(combined.subarray(3, 11))).toEqual([]);
18+
expect(decoder.push(combined.subarray(11))).toEqual([
19+
{ type: "hello", value: "中文" },
20+
{ type: "response", id: "2" },
21+
]);
22+
});
23+
24+
it("decodes fragmented and batched JSON lines", () => {
25+
const decoder = new JsonLineDecoder();
26+
const input = `${encodeJsonLine({ id: "1" })}${encodeJsonLine({ id: "2" })}`;
27+
expect(decoder.push(Buffer.from(input.slice(0, 5)))).toEqual([]);
28+
expect(decoder.push(Buffer.from(input.slice(5)))).toEqual([
29+
{ id: "1" },
30+
{ id: "2" },
31+
]);
32+
});
33+
34+
it("preserves UTF-8 characters split across JSON-line chunks", () => {
35+
const decoder = new JsonLineDecoder();
36+
const input = Buffer.from(encodeJsonLine({ name: "中文规则" }), "utf8");
37+
const splitAt = input.indexOf(Buffer.from("中", "utf8")) + 1;
38+
expect(decoder.push(input.subarray(0, splitAt))).toEqual([]);
39+
expect(decoder.push(input.subarray(splitAt))).toEqual([{ name: "中文规则" }]);
40+
});
41+
});

0 commit comments

Comments
 (0)