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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 16:46:52 -05:00
parent 3e2bb16f5e
commit bfa0ed6b60
3 changed files with 139 additions and 11 deletions

View File

@ -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<string, unknown>,
secretManifest: Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0]["secretManifest"],
workspacePatch: Record<string, unknown> = {}) {
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<string, unknown>)[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<string, unknown>).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<string, unknown>) => ({ ...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<string, unknown>).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<void> {
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",

View File

@ -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,
};

View File

@ -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,