From bfa0ed6b60a37491a987bf0b98f42f657b6dd65e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 16:46:52 -0500 Subject: [PATCH] fix(runtime): preserve sessions through empty connector setup Keep unchanged skill preferences intact and normalize the empty connector digest. Accept exact prior default-shaped session hashes without exempting real configuration changes. Exercise actual connector preparation for legacy and native session compatibility. Co-Authored-By: Paperclip --- .../heartbeat-workspace-session.test.ts | 106 ++++++++++++++++++ server/src/services/connector-runtime.ts | 11 +- server/src/services/heartbeat.ts | 33 ++++-- 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 5838d75fda..4bf8e52b74 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,3 +1,8 @@ +import { createHash } from "node:crypto"; +import { applyConnectorSkills, prepareConnectorSkillDelivery, type ConnectorAssignment } from "../services/connector-runtime.js"; +import { canonicalizeEffectiveRunConfigCategory } from "../services/effective-run-config-fingerprints.js"; +import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import * as adapterServerUtils from "@paperclipai/adapter-utils/server-utils"; import { execFile as execFileCallback } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; @@ -2226,7 +2231,108 @@ function sessionParamsWithConfigMetadata( }; } +// Reconstruct historical session hashes independently of the normalization under +// test, using the canonical payload the old fingerprint version persisted. +function historicalConnectorSession(metadata: SessionConfigMetadata, adapterConfig: Record, + secretManifest: Parameters[0]["secretManifest"], + workspacePatch: Record = {}) { + const payload = JSON.parse(metadata.fingerprints.sessionFingerprint.canonicalJson); + payload.value.adapterConfig = canonicalizeEffectiveRunConfigCategory({ category: "session", value: adapterConfig, secretManifest }); + Object.assign(payload.value.workspaceConfig, workspacePatch); + function sorted(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sorted); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted((value as Record)[key])])); + return value; + } + const fingerprint = (value: unknown) => `v1:sha256:${createHash("sha256").update(JSON.stringify(sorted({ version: 1, category: "session", value }))).digest("hex")}`; + return { ...metadata, fingerprint: fingerprint(payload.value), categoryFingerprints: { + ...metadata.categoryFingerprints, + adapterConfig: fingerprint({ adapterConfig: payload.value.adapterConfig }), + workspaceConfig: fingerprint({ workspaceConfig: payload.value.workspaceConfig }), + } }; +} + describe("effective run session config freshness", () => { + it.each(["codex_local", "claude_local", "paperclip_runner"])("preserves historical %s sessions through actual empty connector preparation", async (adapterType) => { + const fixture = JSON.parse(await fs.readFile(new URL("./fixtures/pre-normalization-session-fingerprints.json", import.meta.url), "utf8")); + const input = { ...fixture.baseInput, ...fixture.cases[0].overrides, adapterType }; + const oldConfig = { ...input.effectiveAdapterConfig, engine: "cli", paperclipRuntimeSkills: input.runtimeSkills }; + const initial = await buildEffectiveRunSessionConfigMetadata({ ...input, effectiveAdapterConfig: oldConfig }); + const previous = historicalConnectorSession(initial, oldConfig, input.secretManifest, { + projectConfigRevisionAt: input.workspaceConfig.projectConfigRevisionAt, issueSettings: null, + }); + const delivered = await prepareConnectorSkillDelivery(await applyConnectorSkills(oldConfig, input.runtimeSkills, []), adapterType); + const next = await buildEffectiveRunSessionConfigMetadata({ ...input, effectiveAdapterConfig: delivered.config, + workspaceConfig: { ...input.workspaceConfig, projectConfigRevisionAt: "2026-06-03T00:00:00Z", issueSettings: { mode: "shared_workspace" } }, + }); + const decision = { hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(previous), configMetadata: next }; + expect(resolveTaskSessionConfigFreshness(decision).reset).toBe(true); + expect(resolveTaskSessionConfigFreshness({ ...decision, verifiedLegacyWorkspaceUnchanged: true })) + .toMatchObject({ reset: false, changedCategories: [] }); + expect((delivered.config as Record).paperclipSkillSync).toBeUndefined(); + const already08 = historicalConnectorSession(next, { + ...writePaperclipSkillSyncPreference(oldConfig, []), paperclipConnectorSkillDigest: null, + }, input.secretManifest); + expect(resolveTaskSessionConfigFreshness({ ...decision, taskSessionParams: sessionParamsWithConfigMetadata(already08) }).reset).toBe(false); + for (const change of [ + { effectiveAdapterConfig: { ...delivered.config, model: "different-model" } }, + { effectiveAdapterConfig: { ...delivered.config, dangerouslySkipPermissions: true } }, + { effectiveAdapterConfig: { ...delivered.config, paperclipSkillSync: { desiredSkills: ["another-skill"] } } }, + { secretManifest: input.secretManifest.map((entry: Record) => ({ ...entry, version: 8 })) }, + { runtimeSkills: [...input.runtimeSkills, { key: "new", runtimeName: "new", source: "/skills/new" }] }, + ]) { + const changed = await buildEffectiveRunSessionConfigMetadata({ ...input, effectiveAdapterConfig: delivered.config, ...change }); + expect(resolveTaskSessionConfigFreshness({ ...decision, configMetadata: changed, verifiedLegacyWorkspaceUnchanged: true }).reset).toBe(true); + } + }); + + it("keeps connector assignments, tools, resources and revocation session-invalidating", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-connector-session-")); + const previousHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_HOME = directory; + // A nested worktree must use its own bundled skill, not an ancestor checkout. + const skillRoot = vi.spyOn(adapterServerUtils, "resolvePaperclipSkillsDir") + .mockResolvedValue(path.resolve(import.meta.dirname, "../../../skills")); + const assignment: ConnectorAssignment = { + key: "agentmail", label: "AgentMail", skillKey: "paperclipai/paperclip/agentmail", + resources: [{ id: "inbox-one", label: "Inbox one", connectionId: "connection-one" }], + tools: [{ name: "agentmail_send", description: "Send mail", inputSchema: { type: "object" } }], + }; + try { + for (const adapterType of ["codex_local", "claude_local", "paperclip_runner", "opencode_local"]) { + const base = { engine: "cli", model: "gpt-5.4-mini" }; + const assigned = await prepareConnectorSkillDelivery(await applyConnectorSkills(base, [], [assignment]), adapterType); + expect(assigned.config.paperclipConnectorSkillDigest).toMatch(/^[a-f0-9]{64}$/); + const old = await buildSessionConfigMetadata({ adapterType, effectiveAdapterConfig: assigned.config }); + const same = await prepareConnectorSkillDelivery(await applyConnectorSkills(base, [], [assignment]), adapterType); + const unchanged = await buildSessionConfigMetadata({ adapterType, effectiveAdapterConfig: same.config }); + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(old), configMetadata: unchanged }).reset).toBe(false); + for (const assignments of [[], [{ ...assignment, resources: [{ ...assignment.resources[0]!, id: "inbox-two" }] }], + [{ ...assignment, tools: [{ ...assignment.tools[0]!, name: "agentmail_delete" }] }]]) { + const next = await prepareConnectorSkillDelivery(await applyConnectorSkills(base, [], assignments), adapterType); + const metadata = await buildSessionConfigMetadata({ adapterType, effectiveAdapterConfig: next.config }); + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(old), configMetadata: metadata, + verifiedLegacyWorkspaceUnchanged: true }).reset).toBe(true); + } + const revoked = await applyConnectorSkills({ ...base, paperclipSkillSync: { desiredSkills: [assignment.skillKey] } }, + [{ key: assignment.skillKey, runtimeName: "agentmail", source: "/untrusted/agentmail" }], []); + expect(revoked.paperclipRuntimeSkills).toEqual([]); + expect((revoked as Record).paperclipSkillSync).toEqual({ desiredSkills: [] }); + } + } finally { + skillRoot.mockRestore(); + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + async function writableDirectories(root: string): Promise { + await fs.chmod(root, 0o700); + for (const entry of await fs.readdir(root, { withFileTypes: true })) if (entry.isDirectory()) await writableDirectories(path.join(root, entry.name)); + } + await writableDirectories(directory); + await fs.rm(directory, { recursive: true, force: true }); + } + }); + it("upgrades actual pre-normalization native and legacy session fingerprints without resetting", async () => { const fixture = JSON.parse(await fs.readFile( new URL("./fixtures/pre-normalization-session-fingerprints.json", import.meta.url), "utf8", diff --git a/server/src/services/connector-runtime.ts b/server/src/services/connector-runtime.ts index 1029ca6534..db59289474 100644 --- a/server/src/services/connector-runtime.ts +++ b/server/src/services/connector-runtime.ts @@ -148,9 +148,8 @@ export async function applyConnectorSkills( connector.skillName, ]), ); - const desired = readPaperclipSkillSyncPreference( - config, - ).desiredSkillEntries.filter((entry) => !reserved.has(entry.key)); + const preference = readPaperclipSkillSyncPreference(config); + const desired = preference.desiredSkillEntries.filter((entry) => !reserved.has(entry.key)); const skills = entries.filter( (entry) => !reserved.has(entry.key) && !reserved.has(entry.runtimeName), ); @@ -193,7 +192,11 @@ export async function applyConnectorSkills( .digest("hex") : null; return { - ...writePaperclipSkillSyncPreference(config, desired), + // Empty connector preparation must not rewrite an existing agent's skill + // preference: its original representation is part of the saved session. + // Assignment changes and stripping unassigned reserved skills still apply. + ...(assignments.length === 0 && desired.length === preference.desiredSkillEntries.length + ? config : writePaperclipSkillSyncPreference(config, desired)), paperclipRuntimeSkills: skills, paperclipConnectorSkillDigest: connectorSkillDigest, }; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4ee0a0a9d4..194e576527 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6350,12 +6350,19 @@ function buildSessionConfigCategoryValues(input: { // boundary; the reusable row and its evolving generation are state. delete workspaceConfig.existingExecutionWorkspace; delete workspaceConfig.reusableExecutionWorkspaceConfig; + const adapterConfig = { ...input.effectiveAdapterConfig }; + // Connector preparation adds a null digest even without assignments. That + // generated absence is not a change to an older agent's execution contract. + // Nonempty digests remain fingerprinted, including shared-home delivery. + if (adapterConfig.paperclipConnectorSkillDigest == null) { + delete adapterConfig.paperclipConnectorSkillDigest; + } return { adapter: { adapterType: input.adapterType, agentConfigRevision: input.agentConfigRevision, }, - adapterConfig: input.effectiveAdapterConfig, + adapterConfig, agentRuntimeConfig: input.agentRuntimeConfig, instructions: input.instructions, issueOverrides: input.issueOverrides, @@ -6433,12 +6440,24 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { legacyWorkspaceVariants.push({ ...legacyWorkspace, issueSettings: null }); } } - const compatibleFingerprints = [...new Set(legacyWorkspaceVariants.map((workspaceConfig) => - createEffectiveRunConfigFingerprints({ - session: { ...categoryValues, workspaceConfig }, - secretManifest, - }).sessionFingerprint.fingerprint, - ))]; + const adapterVariants = [categoryValues.adapterConfig]; + if (input.effectiveAdapterConfig.paperclipConnectorSkillDigest == null) { + // The first connector-aware release always wrote these defaults. Accept + // only that exact old representation of today's skill selection; never + // excuse a changed assignment digest, credential, permission, or model. + adapterVariants.push({ + ...writePaperclipSkillSyncPreference(categoryValues.adapterConfig, + readPaperclipSkillSyncPreference(categoryValues.adapterConfig).desiredSkillEntries), + paperclipConnectorSkillDigest: null, + }); + } + const compatibleFingerprints = [...new Set( + [categoryValues.workspaceConfig, ...legacyWorkspaceVariants].flatMap((workspaceConfig) => + adapterVariants.map((adapterConfig) => createEffectiveRunConfigFingerprints({ + session: { ...categoryValues, workspaceConfig, adapterConfig }, + secretManifest, + }).sessionFingerprint.fingerprint)), + )]; return { version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, fingerprint: fingerprints.sessionFingerprint.fingerprint,