Compare sandbox Claude MCP identities independent of ordering

Canonicalize known external identity triples while preserving duplicate multiplicity and rejecting unknown fields. Keep local comparison behavior unchanged.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 22:21:49 -05:00
parent 355590536e
commit 2959fcb43d
2 changed files with 30 additions and 2 deletions

View File

@ -146,6 +146,7 @@ export function claudeSessionMcpServersMatch(input: {
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value) || !value.every((entry) => entry && typeof entry === "object"
&& Object.keys(entry).length === 3
&& typeof entry.name === "string" && typeof entry.url === "string" && typeof entry.connectionId === "string")) return null;
return value;
} catch { return null; }
@ -170,8 +171,11 @@ export function claudeSessionMcpServersMatch(input: {
}
const saved = parse(input.savedIdentity);
if (!saved || !current) return false;
return JSON.stringify(saved.filter((entry) => !builtin(entry)))
=== JSON.stringify(current.filter((entry) => !builtin(entry)));
// Database order and JSON property order are not tool identity. Keep every
// occurrence so adding or removing a duplicate still changes the identity.
const externalIdentities = (entries: typeof saved) => entries.filter((entry) => !builtin(entry))
.map((entry) => JSON.stringify([entry.name, entry.url, entry.connectionId])).sort();
return JSON.stringify(externalIdentities(saved)) === JSON.stringify(externalIdentities(current));
}
export function claudeSessionCwdMatchesExecutionTarget(input: {

View File

@ -61,6 +61,30 @@ describe("legacy sandbox conversation persistence", () => {
expect(claudeSessionMcpServersMatch({ ...input, savedIdentity: "malformed" })).toBe(false);
});
it("matches reordered sandbox external MCP identities without losing duplicate multiplicity", () => {
const first = { name: "First tool", url: "https://tools.test/first", connectionId: "external:first" };
const second = { name: "Second tool", url: "https://tools.test/second", connectionId: "external:second" };
const builtin = { name: "Paperclip projects", url: "https://paperclip.test/api/mcp/project-tools", connectionId: "paperclip-project-tools" };
const input = { savedIdentity: JSON.stringify([first, second]), currentIdentity: JSON.stringify([second, builtin, first]),
currentConnectionIds: [second.connectionId, builtin.connectionId, first.connectionId], legacyPlatformSession: false,
paperclipApiUrl: "https://paperclip.test", sandboxUpgrade: true };
expect(claudeSessionMcpServersMatch(input)).toBe(true);
expect(claudeSessionMcpServersMatch({ ...input, sandboxUpgrade: false })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify([
{ connectionId: second.connectionId, url: second.url, name: second.name }, builtin, first,
]) })).toBe(true);
for (const entries of [
[second, builtin],
[second, builtin, first, first],
[second, builtin, { ...first, url: "https://foreign.test/first" }],
[second, builtin, { ...first, name: "Changed tool" }],
[second, builtin, { ...first, connectionId: "external:replacement" }],
[second, builtin, { ...first, permissionScope: "unknown" }],
]) expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify(entries) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, savedIdentity: JSON.stringify([first, first, second]) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, savedIdentity: JSON.stringify([{ ...first, permissionScope: "unknown" }, second]) })).toBe(false);
});
for (const [name, codec] of [["codex_local", codex], ["claude_local", claude]] as const) {
it(`${name} recovers old metadata, persists it, and resumes across host leases`, () => {
const input = fixture(); input.adapterType = name;