From 2959fcb43df92977d0b29e6f890681f4697c0de2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 22:21:49 -0500 Subject: [PATCH] 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 --- .../claude-local/src/server/execute.ts | 8 +++++-- .../__tests__/legacy-sandbox-session.test.ts | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 1b84057faa..b801bfdce5 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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: { diff --git a/server/src/__tests__/legacy-sandbox-session.test.ts b/server/src/__tests__/legacy-sandbox-session.test.ts index 3fb3adc2d5..2b97aa0ed2 100644 --- a/server/src/__tests__/legacy-sandbox-session.test.ts +++ b/server/src/__tests__/legacy-sandbox-session.test.ts @@ -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;