Preserve sandbox Claude sessions across compatible app upgrades

Keep shipped-skill and built-in tool updates separate from external connection and agent-instruction changes. Recover historical identity only from verified host evidence, and explain native tool-contract transitions without weakening checkpoint compatibility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 22:12:01 -05:00
parent 26164f1d90
commit 355590536e
12 changed files with 531 additions and 14 deletions

View File

@ -27,6 +27,24 @@ change. They are not a migration plan for existing adapters.
10. Recovery may finish an already persisted native run while fresh native
starts remain blocked.
## Native session compatibility across upgrades
A retained sandbox and a retained provider conversation are separate resources.
Codex persists the dynamic tool declarations when a conversation starts; the
qualified Codex 0.153.4 `thread/resume` contract cannot replace those declarations.
When the native tool contract changes, or an older checkpoint has no verifiable
tool contract, Paperclip starts a fresh native/provider conversation with the
full current task context. It keeps the task and its sandbox workspace, files,
repositories, and retained conversation files. Sandbox runs record
`native.session.transition` with the reason and full-context mode; this does not
claim that the agent's configuration or owner changed.
An ordinary next turn with the same compatible contract resumes its provider
conversation. Upgrade verification must distinguish that continuity check from
a deliberate tool-contract transition, verify retained work and old conversation
files, and prove the upgraded task remains usable. A new conversation identifier
alone is neither lost work nor proof that upgrade acceptance passed.
## Runtime selection
The server resolves and persists the runtime once, before provider launch.

View File

@ -657,3 +657,27 @@ Late callbacks from warm-session inspection or maintenance cannot rewrite a
completed run's process identity. Remote native process timestamps come from
the validated remote marker; an unrelated host process with the same PID must
not replace them. Active local execution retains its host process lookup.
### Claude conversations during sandbox upgrades
A sandbox Claude conversation can resume after an app upgrade refreshes shipped
Paperclip skill files or adds a built-in Paperclip MCP server. The generated agent
instructions must remain identical. Skill assignments and all non-shipped skill
contents remain part of the session compatibility fingerprint. Built-in MCP
comparison validates the exact Paperclip origin, endpoint path, name, and reserved
connection ID; assigned/external server identities must still match.
Older Claude codecs omitted remote and MCP identity fields. Migration uses the
previous successful run's company, agent, responsible user, task, workspace, and
same physical sandbox. Reconstructing an omitted assignment gateway additionally
requires the historical host invocation and its run-scoped gateway evidence.
Missing or ambiguous evidence does not authorize a builtin-only fallback. An old
prompt bundle without its newer compatibility fingerprint is eligible only when
its preserved instructions and skill symlinks prove the same shipped sources;
unknown historical third-party contents are not assumed unchanged.
Changes to the responsible identity, external grants, agent instructions, or
non-shipped skills retain the existing reset behavior. Local and SSH execution
keep their previous exact prompt-bundle and MCP comparisons. These compatibility
rules do not themselves constitute live upgrade acceptance; staging must verify
that the original provider conversation and saved work survive the transition.

View File

@ -34,6 +34,7 @@ import {
buildPaperclipEnv,
isPaperclipSkillSourceMissing,
readPaperclipRuntimeSkillEntries,
listPaperclipSkillEntries,
readPaperclipIssueWorkModeFromContext,
joinPromptSections,
buildInvocationEnvForLogs,
@ -91,7 +92,7 @@ import {
} from "./cli-capabilities.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
import { prepareClaudePromptBundle, claudePromptBundleCanResume } from "./prompt-cache.js";
import { buildClaudeExecutionPermissionArgs } from "./permissions.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
@ -132,12 +133,45 @@ export function claudeSessionMcpServersMatch(input: {
currentIdentity: string;
currentConnectionIds: readonly (string | null | undefined)[];
legacyPlatformSession: boolean;
paperclipApiUrl?: string;
sandboxUpgrade?: boolean;
}): boolean {
if (input.savedIdentity.length > 0) return input.savedIdentity === input.currentIdentity;
return input.currentConnectionIds.length === 0 || (
input.legacyPlatformSession
&& input.currentConnectionIds.every((id) => id === "paperclip-runtime-tools")
);
if (!input.sandboxUpgrade) {
if (input.savedIdentity.length > 0) return input.savedIdentity === input.currentIdentity;
return input.currentConnectionIds.length === 0 || (input.legacyPlatformSession
&& input.currentConnectionIds.every((id) => id === "paperclip-runtime-tools"));
}
if (input.savedIdentity === input.currentIdentity) return true;
const parse = (raw: string): Array<{ name: string; url: string; connectionId: string }> | null => {
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value) || !value.every((entry) => entry && typeof entry === "object"
&& typeof entry.name === "string" && typeof entry.url === "string" && typeof entry.connectionId === "string")) return null;
return value;
} catch { return null; }
};
const current = parse(input.currentIdentity);
const builtin = (entry: { name: string; url: string; connectionId: string }) => {
if (!input.paperclipApiUrl) return false;
try {
const base = new URL(input.paperclipApiUrl);
const expected = entry.connectionId === "paperclip-runtime-tools" && entry.name === "Paperclip connections"
? "/mcp/runtime-tools" : entry.connectionId === "paperclip-project-tools" && entry.name === "Paperclip projects"
? "/api/mcp/project-tools" : null;
return expected !== null && ["http:", "https:"].includes(base.protocol)
&& base.pathname === "/" && !base.username && !base.password && !base.search && !base.hash
&& entry.url === `${base.origin}${expected}`;
} catch { return false; }
};
if (!input.savedIdentity) {
if (!input.legacyPlatformSession) return false;
return current !== null && current.every(builtin)
&& JSON.stringify(current.map((entry) => entry.connectionId)) === JSON.stringify(input.currentConnectionIds);
}
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)));
}
export function claudeSessionCwdMatchesExecutionTarget(input: {
@ -559,13 +593,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Warning: skill "${entry.key}" is enabled for this agent but its files are unavailable and it was not mounted${entry.missingDetail ? `: ${entry.missingDetail}` : "."}\n`,
);
}
const shippedSkills = await listPaperclipSkillEntries(__moduleDir);
const promptBundle = await prepareClaudePromptBundle({
shippedSkills,
companyId: agent.companyId,
skills: mountableSkillEntries,
instructionsContents: combinedInstructionsContents,
onLog,
});
const runtimeMcpServers = ctx.runtimeMcp?.getServers() ?? [];
const runtimePaperclipApiUrl = env.PAPERCLIP_API_URL;
const runtimeMcpIdentity = JSON.stringify(
runtimeMcpServers.map(({ name, url, connectionId }) => ({ name, url, connectionId })),
);
@ -783,8 +820,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
const runtimeMcpServerIdentity = asString(runtimeSessionParams.mcpServerIdentity, "");
const hasMatchingPromptBundle =
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
const hasMatchingPromptBundle = executionTargetIsSandbox ? await claudePromptBundleCanResume({
companyId: agent.companyId, bundle: promptBundle, previousBundleKey: runtimePromptBundleKey,
previousCompatibilityKey: asString(runtimeSessionParams.promptCompatibilityKey, ""),
skills: mountableSkillEntries, shippedSkills, instructionsContents: combinedInstructionsContents,
}) : runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
// Older codecs dropped this field. Only a host-verified legacy session using
// the built-in platform server may migrate without an external-server identity.
const hasMatchingMcpServers = claudeSessionMcpServersMatch({
@ -792,6 +832,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
currentIdentity: runtimeMcpIdentity,
currentConnectionIds: runtimeMcpServers.map((server) => server.connectionId),
legacyPlatformSession: runtimeSessionParams.legacyPlatformMcpSession === true,
paperclipApiUrl: runtimePaperclipApiUrl,
sandboxUpgrade: executionTargetIsSandbox,
});
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runtimeSessionId);
const canResumeSession =
@ -838,7 +880,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
);
}
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
if (runtimeSessionId && !hasMatchingPromptBundle) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
@ -1162,6 +1204,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionId: resolvedSessionId,
cwd,
promptBundleKey: promptBundle.bundleKey,
promptCompatibilityKey: promptBundle.compatibilityKey,
mcpServerIdentity: runtimeMcpIdentity,
...(executionTargetIsRemote
? {

View File

@ -88,11 +88,13 @@ export const sessionCodec: AdapterSessionCodec = {
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
const remoteExecution = serializeSessionExecutionIdentity(record.remoteExecution);
const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity);
const promptCompatibilityKey = readNonEmptyString(record.promptCompatibilityKey);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(remoteExecution ? { remoteExecution } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(promptCompatibilityKey ? { promptCompatibilityKey } : {}),
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
@ -115,11 +117,13 @@ export const sessionCodec: AdapterSessionCodec = {
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
const remoteExecution = serializeSessionExecutionIdentity(params.remoteExecution);
const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity);
const promptCompatibilityKey = readNonEmptyString(params.promptCompatibilityKey);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(remoteExecution ? { remoteExecution } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(promptCompatibilityKey ? { promptCompatibilityKey } : {}),
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),

View File

@ -0,0 +1,74 @@
import { afterEach, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { prepareClaudePromptBundle, claudePromptBundleCanResume } from "./prompt-cache.js";
const roots: string[] = [];
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
async function fixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-context-")); roots.push(root);
vi.stubEnv("PAPERCLIP_HOME", root);
const source = path.join(root, "shipped", "paperclip"); await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "old shipped skill");
const skill = { key: "paperclipai/paperclip/paperclip", runtimeName: "paperclip", source };
const input = { companyId: "company", skills: [skill], shippedSkills: [skill], instructionsContents: "unchanged human instructions", onLog: async () => {} };
const old = await prepareClaudePromptBundle(input);
await fs.writeFile(path.join(source, "SKILL.md"), "new shipped skill");
return { root, input, old, bundle: await prepareClaudePromptBundle(input) };
}
it("separates shipped-content caching from conversation compatibility, including old codecs", async () => {
const { input, old, bundle } = await fixture();
expect(old.bundleKey).not.toBe(bundle.bundleKey);
expect(old.compatibilityKey).toBe(bundle.compatibilityKey);
for (const previousCompatibilityKey of ["", old.compatibilityKey]) {
expect(await claudePromptBundleCanResume({ ...input, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey })).toBe(true);
}
});
it("does not ignore changed human instructions or lose the old cache", async () => {
const { input, old, bundle } = await fixture();
const changed = { ...input, instructionsContents: "changed human instructions" };
const next = await prepareClaudePromptBundle(changed);
for (const previousCompatibilityKey of ["", old.compatibilityKey]) {
expect(await claudePromptBundleCanResume({ ...changed, bundle: next, previousBundleKey: old.bundleKey, previousCompatibilityKey })).toBe(false);
}
await fs.rm(old.rootDir, { recursive: true });
expect(await claudePromptBundleCanResume({ ...input, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey: "" })).toBe(false);
});
it("hashes non-shipped content and refuses unknown old symlink content", async () => {
const { root, input } = await fixture();
const source = path.join(root, "third-party"); await fs.mkdir(source); await fs.writeFile(path.join(source, "SKILL.md"), "v1");
const mixed = { ...input, skills: [...input.skills, { key: "custom", runtimeName: "custom", source }] };
const old = await prepareClaudePromptBundle(mixed);
await fs.writeFile(path.join(source, "SKILL.md"), "v2");
const bundle = await prepareClaudePromptBundle(mixed);
expect(bundle.compatibilityKey).not.toBe(old.compatibilityKey);
for (const previousCompatibilityKey of ["", old.compatibilityKey]) {
expect(await claudePromptBundleCanResume({ ...mixed, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey })).toBe(false);
}
});
it("does not trust a reserved skill key from another source or version pin", async () => {
const { root, input, old } = await fixture();
const source = path.join(root, "override"); await fs.mkdir(source); await fs.writeFile(path.join(source, "SKILL.md"), "override");
for (const skills of [[{ ...input.skills[0], source }], [{ ...input.skills[0], versionId: "pinned" }]]) {
const changed = { ...input, skills }; const bundle = await prepareClaudePromptBundle(changed);
expect(bundle.compatibilityKey).not.toBe(old.compatibilityKey);
expect(await claudePromptBundleCanResume({ ...changed, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey: "" })).toBe(false);
}
});
it("confines old bundle lookup to its company and refuses unknown skill sets", async () => {
const { input, old, bundle } = await fixture();
for (const change of [{ companyId: "other-company" }, { previousBundleKey: "../" + old.bundleKey }]) {
expect(await claudePromptBundleCanResume({ ...input, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey: "", ...change })).toBe(false);
}
await fs.mkdir(path.join(old.rootDir, ".claude", "skills", "unexpected"));
expect(await claudePromptBundleCanResume({ ...input, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey: "" })).toBe(false);
});
it("rejects an old third-party skill impersonating the shipped runtime name", async () => {
const { root, input, old, bundle } = await fixture();
const custom = path.join(root, "historical-custom"); await fs.mkdir(custom); await fs.writeFile(path.join(custom, "SKILL.md"), "custom instructions");
const oldSkill = path.join(old.rootDir, ".claude", "skills", "paperclip");
await fs.unlink(oldSkill); await fs.symlink(custom, oldSkill);
expect(await claudePromptBundleCanResume({ ...input, bundle, previousBundleKey: old.bundleKey, previousCompatibilityKey: "" })).toBe(false);
});

View File

@ -13,6 +13,7 @@ type SkillEntry = PaperclipSkillEntry;
export interface ClaudePromptBundle {
bundleKey: string;
compatibilityKey: string;
rootDir: string;
addDir: string;
instructionsFilePath: string | null;
@ -108,6 +109,63 @@ async function buildClaudePromptBundleKey(input: {
return hash.digest("hex");
}
async function isShippedSkill(entry: SkillEntry, shippedSkills: SkillEntry[]): Promise<boolean> {
// A reserved key alone cannot make an editable/company skill first-party.
const shipped = shippedSkills.find((candidate) => candidate.key === entry.key && candidate.runtimeName === entry.runtimeName);
if (!shipped || entry.versionId != null) return false;
const [actual, expected] = await Promise.all([
fs.realpath(entry.source).catch(() => null), fs.realpath(shipped.source).catch(() => null),
]);
return actual !== null && actual === expected;
}
async function buildCompatibilityKey(input: {
skills: SkillEntry[]; shippedSkills: SkillEntry[]; instructionsContents: string | null;
}): Promise<string> {
const hash = createHash("sha256");
hash.update("paperclip-claude-session-context:v1\n");
hash.update(JSON.stringify(input.instructionsContents));
for (const entry of [...input.skills].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName))) {
const shipped = await isShippedSkill(entry, input.shippedSkills);
hash.update(JSON.stringify([entry.key, entry.runtimeName, entry.versionId ?? null, shipped]));
if (!shipped) await hashPathContents(entry.source, hash, entry.runtimeName, new Set());
}
return hash.digest("hex");
}
/** Only shipped skill bytes may change without replacing an existing conversation. */
export async function claudePromptBundleCanResume(input: {
companyId: string; bundle: ClaudePromptBundle; previousBundleKey: string;
previousCompatibilityKey: string; skills: SkillEntry[]; shippedSkills: SkillEntry[];
instructionsContents: string | null;
}): Promise<boolean> {
if (!input.previousBundleKey || input.previousBundleKey === input.bundle.bundleKey) return true;
if (input.previousCompatibilityKey) return input.previousCompatibilityKey === input.bundle.compatibilityKey;
// Old sessions have no independent content fingerprint. Their cache contains
// symlinks, so it cannot prove historical third-party skill bytes. Fail closed.
if (!/^[a-f0-9]{64}$/.test(input.previousBundleKey) || input.skills.length === 0
|| !(await Promise.all(input.skills.map((skill) => isShippedSkill(skill, input.shippedSkills)))).every(Boolean)) return false;
const cacheRoot = resolveManagedClaudePromptCacheRoot(process.env, input.companyId);
const oldRoot = path.join(cacheRoot, input.previousBundleKey);
try {
if (await fs.realpath(oldRoot) !== path.join(await fs.realpath(cacheRoot), input.previousBundleKey)) return false;
const names = await fs.readdir(path.join(oldRoot, ".claude", "skills"));
if (JSON.stringify(names.sort()) !== JSON.stringify(input.skills.map((entry) => entry.runtimeName).sort())) return false;
for (const entry of input.skills) {
const oldSkill = path.join(oldRoot, ".claude", "skills", entry.runtimeName);
// Legacy bundles used symlinks. Verify their historical source provenance,
// not just a runtime name that a company-managed skill could also use.
if (!(await fs.lstat(oldSkill)).isSymbolicLink()
|| await fs.realpath(oldSkill) !== await fs.realpath(entry.source)) return false;
}
const instructionsPath = path.join(oldRoot, "agent-instructions.md");
const stat = await fs.lstat(instructionsPath).catch(() => null);
if (input.instructionsContents === null) return stat === null;
if (!stat?.isFile() || stat.size !== Buffer.byteLength(input.instructionsContents)) return false;
return await fs.readFile(instructionsPath, "utf8") === input.instructionsContents;
} catch { return false; }
}
async function ensureReadableFile(targetPath: string, contents: string): Promise<void> {
try {
await fs.access(targetPath, fsConstants.R_OK);
@ -136,6 +194,7 @@ export async function prepareClaudePromptBundle(input: {
skills: SkillEntry[];
instructionsContents: string | null;
onLog: AdapterExecutionContext["onLog"];
shippedSkills?: SkillEntry[];
}): Promise<ClaudePromptBundle> {
const { companyId, skills, instructionsContents, onLog } = input;
const bundleKey = await buildClaudePromptBundleKey({
@ -167,6 +226,7 @@ export async function prepareClaudePromptBundle(input: {
return {
bundleKey,
compatibilityKey: await buildCompatibilityKey({ skills, instructionsContents, shippedSkills: input.shippedSkills ?? [] }),
rootDir,
addDir: rootDir,
instructionsFilePath,

View File

@ -1176,6 +1176,8 @@ describe("claude execute", () => {
cwd: workspace,
});
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
expect(sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null))?.promptCompatibilityKey)
.toBe(first.sessionParams?.promptCompatibilityKey);
const second = await execute({
runId: "run-2",
@ -1290,6 +1292,60 @@ describe("claude execute", () => {
}
});
it.each(["sandbox", "local"])("allows an old shipped-skill bundle upgrade only in sandbox execution (%s)", async (mode) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-shipped-upgrade-"));
const command = path.join(root, "claude");
const instructions = path.join(root, "AGENTS.md");
await writeFakeClaudeCommand(command);
await fs.writeFile(instructions, "Unchanged agent instructions.");
vi.stubEnv("PAPERCLIP_HOME", root);
vi.stubEnv("HOME", root);
vi.stubEnv("PAPERCLIP_API_URL", "http://localhost:3100");
const capture = path.join(root, "capture.json");
const oldServers = [
{ name: "Paperclip connections", url: "http://localhost:3100/mcp/runtime-tools", connectionId: "paperclip-runtime-tools", token: "old-run-token" },
{ name: "paperclip-assigned", url: "http://localhost:3100/mcp/gateways/gw_same", connectionId: "assignment:same", token: "gateway-token" },
];
const base = {
agent: { id: "agent", companyId: "company", name: "Claude", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
config: { engine: "cli", command, cwd: root, instructionsFilePath: instructions,
paperclipSkillSync: { desiredSkills: ["paperclipai/paperclip/paperclip"] },
env: { PAPERCLIP_TEST_CAPTURE_PATH: capture } },
...(mode === "sandbox" ? { executionTarget: { kind: "remote" as const, transport: "sandbox" as const,
providerKey: "e2b", environmentId: "environment", leaseId: "lease", remoteCwd: root,
runner: createLocalSandboxRunner(), timeoutMs: 30_000 } } : {}),
context: {}, authToken: "test", onLog: async () => {}, runtimeMcp: { getServers: () => oldServers },
};
try {
const first = await execute({ ...base, runId: "first", runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task" } });
const initial = JSON.parse(await fs.readFile(capture, "utf8"));
const oldKey = "a".repeat(64);
const oldDir = path.join(root, "instances", "default", "companies", "company", "claude-prompt-cache", oldKey);
await fs.mkdir(path.join(oldDir, ".claude", "skills"), { recursive: true });
await fs.copyFile(initial.instructionsFilePath, path.join(oldDir, "agent-instructions.md"));
await fs.symlink(await fs.realpath(path.join(path.dirname(oldDir), String(first.sessionParams!.promptBundleKey), ".claude", "skills", "paperclip")), path.join(oldDir, ".claude", "skills", "paperclip"));
const legacy = { ...first.sessionParams, promptBundleKey: oldKey };
delete legacy.promptCompatibilityKey;
await execute({ ...base, runId: "second", runtimeMcp: { getServers: () => [
{ name: "Paperclip projects", url: "http://localhost:3100/api/mcp/project-tools", connectionId: "paperclip-project-tools", token: "new-run-token" },
...oldServers,
] }, runtime: { sessionId: null, sessionParams: legacy, sessionDisplayId: null, taskKey: "task" } });
const resumed = JSON.parse(await fs.readFile(capture, "utf8"));
if (mode === "sandbox") {
expect(resumed.argv).toContain("--resume");
expect(resumed.argv).toContain(first.sessionParams!.sessionId);
expect(resumed.argv).not.toContain("--append-system-prompt-file");
} else {
expect(resumed.argv).not.toContain("--resume");
expect(resumed.argv).toContain("--append-system-prompt-file");
}
expect(resumed.addDir).toBe(initial.addDir);
expect(resumed.skillEntries).toEqual(["paperclip"]);
expect(JSON.parse(resumed.mcpConfigContents).mcpServers).toHaveProperty("Paperclip projects");
expect(JSON.parse(resumed.mcpConfigContents).mcpServers).toHaveProperty("paperclip-assigned");
} finally { vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }); }
}, 15_000);
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
const workspace = path.join(root, "workspace");

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { sessionCodec as codex } from "@paperclipai/adapter-codex-local/server";
import { sessionCodec as claude, claudeSessionMcpServersMatch } from "@paperclipai/adapter-claude-local/server";
import { adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, type AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { recoverLegacySandboxSession } from "../services/legacy-sandbox-session.js";
import { recoverLegacySandboxSession, recoverLegacyClaudeMcpIdentity } from "../services/legacy-sandbox-session.js";
const target: AdapterSandboxExecutionTarget = {
kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env",
@ -32,7 +32,7 @@ describe("legacy sandbox conversation persistence", () => {
const input = fixture(); input.adapterType = "claude_local";
const recovered = recoverLegacySandboxSession(input)!;
expect(recovered.legacyPlatformMcpSession).toBe(true);
const match = { savedIdentity: "", currentIdentity: "current", currentConnectionIds: ["paperclip-runtime-tools"], legacyPlatformSession: true };
const match = { savedIdentity: "", currentIdentity: JSON.stringify([{ name: "Paperclip connections", url: "https://paperclip.test/mcp/runtime-tools", connectionId: "paperclip-runtime-tools" }]), currentConnectionIds: ["paperclip-runtime-tools"], legacyPlatformSession: true, paperclipApiUrl: "https://paperclip.test" };
expect(claudeSessionMcpServersMatch(match)).toBe(true);
expect(claudeSessionMcpServersMatch({ ...match, legacyPlatformSession: false })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...match, currentConnectionIds: ["external"] })).toBe(false);
@ -40,8 +40,27 @@ describe("legacy sandbox conversation persistence", () => {
const persisted = claude.serialize({ ...recovered, mcpServerIdentity: "current" });
expect(persisted?.legacyPlatformMcpSession).toBeUndefined();
expect(claude.deserialize(persisted)?.mcpServerIdentity).toBe("current");
expect(claudeSessionMcpServersMatch({ ...match, savedIdentity: "current", legacyPlatformSession: false })).toBe(true);
expect(claudeSessionMcpServersMatch({ ...match, savedIdentity: match.currentIdentity, legacyPlatformSession: false })).toBe(true);
});
it("ignores only exact host-owned MCP additions while retaining external identities", () => {
const external = { name: "paperclip-assigned", url: "https://paperclip.test/mcp/gateways/gw_one", connectionId: "assignment:digest" };
const builtin = { name: "Paperclip projects", url: "https://paperclip.test/api/mcp/project-tools", connectionId: "paperclip-project-tools" };
const input = { savedIdentity: JSON.stringify([external]), currentIdentity: JSON.stringify([builtin, external]), currentConnectionIds: [builtin.connectionId, external.connectionId], legacyPlatformSession: false, paperclipApiUrl: "https://paperclip.test", sandboxUpgrade: true };
expect(claudeSessionMcpServersMatch(input)).toBe(true);
expect(claudeSessionMcpServersMatch({ ...input, sandboxUpgrade: false })).toBe(false);
for (const changed of [
{ ...builtin, url: "https://foreign.test/api/mcp/project-tools" },
{ ...builtin, url: builtin.url + "?redirect=evil" },
{ ...builtin, connectionId: "external" },
{ ...builtin, name: "External tool" },
]) expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify([changed, external]) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify([builtin, { ...external, connectionId: "assignment:revoked" }]) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify([builtin, { ...external, url: "https://foreign.test/mcp/gateways/gw_one" }]) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, currentIdentity: JSON.stringify([builtin]) })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, savedIdentity: "", legacyPlatformSession: true })).toBe(false);
expect(claudeSessionMcpServersMatch({ ...input, savedIdentity: "malformed" })).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;
@ -108,6 +127,56 @@ describe("legacy sandbox conversation persistence", () => {
}
});
it("recovers historical host cwd after work folders recorded the remote workspace cwd", () => {
const input = fixture(); input.adapterType = "claude_local";
input.params = { ...input.params, cwd: "/host/project", workspaceId: "project-workspace" };
const context = input.previousRun!.contextSnapshot as Record<string, any>;
context.paperclipWorkspace = { cwd: target.remoteCwd, workspaceId: "project-workspace" };
context.paperclipEnvironment.workspaceRealization.local = { path: "/host/project", projectWorkspaceId: "project-workspace" };
expect(recoverLegacySandboxSession(input)).toMatchObject({ cwd: target.remoteCwd, legacyPlatformMcpSession: true });
context.paperclipWorkspace.cwd = "/another/remote";
expect(recoverLegacySandboxSession(input)).toBe(input.params);
});
it("reconstructs a discarded MCP identity only from the same successful run's host evidence", () => {
const params = recoverLegacySandboxSession({ ...fixture(), adapterType: "claude_local" })!;
const input = { params, previousRunId: "old-run", companyId: "company", agentId: "agent", taskId: "task", paperclipApiUrl: "https://paperclip.test",
invocations: [{ payload: { adapterType: "claude_local", context: { taskId: "task" }, env: { PAPERCLIP_GITHUB_BROKER_URL: "https://paperclip.test", PAPERCLIP_API_URL: "http://127.0.0.1:32123" }, commandNotes: ["Using 2 Paperclip-managed MCP server(s) from strict config /remote/mcp.json."] } }],
gateways: [{ companyId: "company", gatewayCompanyId: "company", subjectType: "heartbeat_run", subjectId: "old-run", createdByAgentId: "agent", gatewayPublicId: "gw_" + "a".repeat(32), metadata: { agentId: "agent", nativeRuntimeAssignmentDigest: "b".repeat(64) } }],
};
const failed = recoverLegacyClaudeMcpIdentity({ ...input, gateways: [] });
const builtin = { name: "Paperclip connections", url: "https://paperclip.test/mcp/runtime-tools", connectionId: "paperclip-runtime-tools" };
expect(claudeSessionMcpServersMatch({ savedIdentity: "", currentIdentity: JSON.stringify([builtin]),
currentConnectionIds: [builtin.connectionId], legacyPlatformSession: failed.legacyPlatformMcpSession === true,
paperclipApiUrl: input.paperclipApiUrl, sandboxUpgrade: true })).toBe(false);
expect(claudeSessionMcpServersMatch({ savedIdentity: "", currentIdentity: "[]", currentConnectionIds: [],
legacyPlatformSession: failed.legacyPlatformMcpSession === true,
paperclipApiUrl: input.paperclipApiUrl, sandboxUpgrade: true })).toBe(false);
expect(claudeSessionMcpServersMatch({ savedIdentity: JSON.stringify([builtin]), currentIdentity: JSON.stringify([builtin]),
currentConnectionIds: [builtin.connectionId], legacyPlatformSession: false,
paperclipApiUrl: input.paperclipApiUrl, sandboxUpgrade: true })).toBe(true);
const recovered = recoverLegacyClaudeMcpIdentity(input);
const identity = JSON.parse(recovered.mcpServerIdentity as string);
expect(identity[1].connectionId).toBe("assignment:" + "b".repeat(64));
expect(identity[1].url).toBe("https://paperclip.test/mcp/gateways/gw_" + "a".repeat(32));
for (const mutate of [
(copy: typeof input) => { copy.params.legacyPlatformMcpSession = false; },
(copy: typeof input) => { copy.previousRunId = "another-run"; },
(copy: typeof input) => { copy.companyId = "another-company"; },
(copy: typeof input) => { copy.agentId = "another-agent"; },
(copy: typeof input) => { copy.taskId = "another-task"; },
(copy: typeof input) => { copy.paperclipApiUrl = "https://foreign.test"; },
(copy: typeof input) => { copy.invocations[0].payload.env.PAPERCLIP_GITHUB_BROKER_URL = ""; },
(copy: typeof input) => { copy.invocations[0].payload.commandNotes = ["Using 3 Paperclip-managed MCP server(s) from strict config /remote/mcp.json."]; },
(copy: typeof input) => { copy.gateways[0].metadata.agentId = "another-agent"; },
(copy: typeof input) => { copy.gateways[0].metadata.nativeRuntimeAssignmentDigest = "unknown"; },
(copy: typeof input) => { copy.gateways.push(copy.gateways[0]); },
]) {
const changed = structuredClone(input); mutate(changed);
expect(recoverLegacyClaudeMcpIdentity(changed)).toEqual({ ...changed.params, legacyPlatformMcpSession: false });
}
});
it("keeps legacy host-lease matching when no physical identity is available", () => {
const oldTarget = { ...target, sandboxLeaseAcquisition: undefined };
const saved = adapterExecutionTargetSessionIdentity(oldTarget);

View File

@ -53,7 +53,7 @@ import fs from "node:fs/promises";
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "./work-folder-retention.js";
import { hasMatchingLegacySessionWorkspace } from "./legacy-session-workspace-compatibility.js";
import { findUnboundLegacyTaskWorkspace, hasLegacySandboxWorkspace } from "./legacy-sandbox-workspace.js";
import { recoverLegacySandboxSession } from "./legacy-sandbox-session.js";
import { recoverLegacySandboxSession, recoverLegacyClaudeMcpIdentity } from "./legacy-sandbox-session.js";
import { prepareSandboxWorkFolders } from "./sandbox-work-folders.js";
import { bindReusableSandboxWorkspace, shouldBindReusableSandboxWorkspace } from "./sandbox-workspace-binding.js";
import path from "node:path";
@ -22082,6 +22082,27 @@ export function heartbeatService(
responsibleUserId: run.responsibleUserId ?? null,
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null, previousRun,
});
if (agent.adapterType === "claude_local" && previousRun
&& previousSessionParams?.legacyPlatformMcpSession === true && !previousSessionParams.mcpServerIdentity) {
const [invocations, gateways] = await Promise.all([
db.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, agent.companyId), eq(heartbeatRunEvents.agentId, agent.id),
eq(heartbeatRunEvents.runId, previousRun.id), eq(heartbeatRunEvents.eventType, "adapter.invoke"),
)).limit(2),
db.select({ companyId: toolMcpGatewayTokens.companyId, subjectType: toolMcpGatewayTokens.subjectType,
subjectId: toolMcpGatewayTokens.subjectId, createdByAgentId: toolMcpGatewayTokens.createdByAgentId,
gatewayCompanyId: toolMcpGateways.companyId, gatewayPublicId: toolMcpGateways.gatewayPublicId,
metadata: toolMcpGateways.metadata }).from(toolMcpGatewayTokens)
.innerJoin(toolMcpGateways, eq(toolMcpGateways.id, toolMcpGatewayTokens.gatewayId)).where(and(
eq(toolMcpGatewayTokens.companyId, agent.companyId), eq(toolMcpGateways.companyId, agent.companyId),
eq(toolMcpGatewayTokens.subjectType, "heartbeat_run"), eq(toolMcpGatewayTokens.subjectId, previousRun.id),
eq(toolMcpGatewayTokens.createdByAgentId, agent.id),
)).limit(2),
]);
previousSessionParams = recoverLegacyClaudeMcpIdentity({ params: previousSessionParams,
previousRunId: previousRun.id, companyId: agent.companyId, agentId: agent.id, taskId: issueId,
paperclipApiUrl: paperclipApiBaseUrl(), invocations, gateways });
}
}
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
@ -23159,6 +23180,23 @@ export function heartbeatService(
});
nativeExecution = nativeExecutionWithCheckpoint.execution;
nativeResumeCheckpoint = nativeExecutionWithCheckpoint.checkpoint;
if (
previousNativeRun &&
executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" &&
nativeExecutionWithCheckpoint.sessionTransition.mode === "fresh"
) {
const transition = nativeExecutionWithCheckpoint.sessionTransition;
const detail = transition.reason === "native_tool_contract_unverified"
? "the saved native tool contract cannot be verified after an upgrade"
: transition.reason === "native_tool_contract_changed"
? "the native tool contract changed"
: "the saved native checkpoint is incompatible with the current runtime";
await appendRunEvent(currentRun, {
eventType: "native.session.transition", stream: "system", level: "info",
message: `Starting a fresh provider conversation because ${detail}. The full task context is included.`,
payload: transition,
});
}
if (
nativeSessionId !==
nativeExecutionWithCheckpoint.normalizedSessionId

View File

@ -42,7 +42,8 @@ export function recoverLegacySandboxSession(input: {
// conversation lived in the sandbox. Translate only the exact path and
// workspace recorded by the successful host run; never accept another path.
const savedHostCwd = typeof params.cwd === "string" && params.cwd.length > 0
&& params.cwd === workspace.cwd && params.cwd === local.path
&& (params.cwd === workspace.cwd || workspace.cwd === target.remoteCwd)
&& params.cwd === local.path
&& typeof params.workspaceId === "string" && params.workspaceId.length > 0
&& params.workspaceId === workspace.workspaceId
&& params.workspaceId === local.projectWorkspaceId;
@ -67,3 +68,58 @@ export function recoverLegacySandboxSession(input: {
? { legacyPlatformMcpSession: true } : {}),
};
}
/** Restore only the historical run-scoped gateway identity omitted by old codecs. */
export function recoverLegacyClaudeMcpIdentity(input: {
params: Record<string, unknown>;
previousRunId: string;
companyId: string;
agentId: string;
taskId: string;
paperclipApiUrl: string;
invocations: Array<{ payload: unknown }>;
gateways: Array<{
companyId: string; subjectType: string; subjectId: string | null; createdByAgentId: string | null;
gatewayCompanyId: string; gatewayPublicId: string; metadata: unknown;
}>;
}): Record<string, unknown> {
const { params } = input;
if (params.mcpServerIdentity || params.legacyPlatformMcpSession !== true) return params;
// Target continuity proves a sandbox identity, not its historical tool set.
// A failed reconstruction must never bless missing/purged external grants.
const rejected = { ...params, legacyPlatformMcpSession: false };
if (input.invocations.length !== 1 || input.gateways.length !== 1) return rejected;
const invocation = record(input.invocations[0].payload);
const context = record(invocation.context);
const env = record(invocation.env);
// The API variable may name a per-run localhost bridge. The host-authored
// broker URL records the real origin; absence is not evidence of equivalence.
if (invocation.adapterType !== "claude_local" || context.taskId !== input.taskId
|| typeof env.PAPERCLIP_GITHUB_BROKER_URL !== "string") return rejected;
let origin: string;
try {
const current = new URL(input.paperclipApiUrl);
const previous = new URL(env.PAPERCLIP_GITHUB_BROKER_URL);
if (!["http:", "https:"].includes(current.protocol) || current.origin !== previous.origin
|| current.username || current.password || previous.username || previous.password
|| current.pathname !== "/" || previous.pathname !== "/"
|| current.search || current.hash || previous.search || previous.hash) return rejected;
origin = current.origin;
} catch { return rejected; }
const notes = Array.isArray(invocation.commandNotes) ? invocation.commandNotes : [];
// This historical implementation supplied exactly one platform server and
// one assignment gateway. Do not reconstruct an unknown/custom server set.
if (!notes.some((note) => typeof note === "string" && /^Using 2 Paperclip-managed MCP server\(s\) from strict config /.test(note))) return rejected;
const gateway = input.gateways[0];
const metadata = record(gateway.metadata);
if (gateway.companyId !== input.companyId || gateway.gatewayCompanyId !== input.companyId
|| gateway.subjectType !== "heartbeat_run" || gateway.subjectId !== input.previousRunId
|| gateway.createdByAgentId !== input.agentId || metadata.agentId !== input.agentId
|| !/^gw_[a-f0-9]{32}$/.test(gateway.gatewayPublicId)
|| typeof metadata.nativeRuntimeAssignmentDigest !== "string"
|| !/^[a-f0-9]{64}$/.test(metadata.nativeRuntimeAssignmentDigest)) return rejected;
return { ...rejected, mcpServerIdentity: JSON.stringify([
{ name: "Paperclip connections", url: `${origin}/mcp/runtime-tools`, connectionId: "paperclip-runtime-tools" },
{ name: "paperclip-assigned", url: `${origin}/mcp/gateways/${gateway.gatewayPublicId}`, connectionId: `assignment:${metadata.nativeRuntimeAssignmentDigest}` },
]) };
}

View File

@ -1477,6 +1477,51 @@ const recoveryFakeCodex = resolve(
);
describe("rebindNativeSessionCheckpoint", () => {
it("reports an unverified historical tool contract with full context, then resumes the upgraded contract", () => {
const prior = previousRun({ nativeToolContractFingerprint: undefined });
const original = structuredClone(prior);
const upgraded = buildNativeExecutionWithCheckpoint({
previousRun: prior,
normalizedSessionId,
executionTargetKind: "remote",
buildExecution: (options) => ({
...execution(currentRunId),
session: { ...execution(currentRunId).session, normalizedSessionId: options.normalizedSessionId },
task: { ...execution(currentRunId).task, prompt: options.resumedSession ? "Compact delta" : "Full original task instructions" },
}),
});
expect(upgraded.sessionTransition).toEqual({
mode: "fresh", reason: "native_tool_contract_unverified", taskContext: "full",
});
expect(upgraded.checkpoint).toBeNull();
expect(upgraded.execution.task.prompt).toBe("Full original task instructions");
expect(prior).toEqual(original);
const nextRunId = "90000000-0000-4000-8000-000000000009";
const next = buildNativeExecutionWithCheckpoint({
previousRun: {
id: currentRunId, companyId, agentId, nativeSessionId: upgraded.normalizedSessionId,
runnerProfileJson: {
nativeToolContractFingerprint: nativeToolContractFingerprintForTarget("remote"),
nativeExecutionInput: upgraded.execution,
sessionCheckpoint: {
...original.runnerProfileJson.sessionCheckpoint,
sessionId: "upgraded-provider-thread", providerSessionId: "upgraded-provider-thread",
identity: { runId: currentRunId, companyId, issueId, agentId, sessionId: upgraded.normalizedSessionId },
},
},
},
normalizedSessionId: upgraded.normalizedSessionId,
executionTargetKind: "remote",
buildExecution: (options) => ({
...execution(nextRunId),
session: { ...execution(nextRunId).session, normalizedSessionId: options.normalizedSessionId },
}),
});
expect(next.sessionTransition).toEqual({ mode: "resumed", reason: "checkpoint_compatible", taskContext: "resume" });
expect(next.normalizedSessionId).toBe(upgraded.normalizedSessionId);
expect(next.checkpoint?.sessionId).toBe("upgraded-provider-thread");
});
it("rotates a carried session id when no checkpoint source exists", () => {
const calls: boolean[] = [];
const result = buildNativeExecutionWithCheckpoint({
@ -1802,6 +1847,9 @@ describe("rebindNativeSessionCheckpoint", () => {
expect(result.checkpoint).toBeNull();
expect(result.execution.task.prompt).toBe("Full task instructions");
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
expect(result.sessionTransition).toEqual({
mode: "fresh", reason: "native_tool_contract_changed", taskContext: "full",
});
});
it.each([

View File

@ -378,6 +378,18 @@ function sameWorkspaceScope(input: {
);
}
/** Explains provider continuity without changing task configuration identity. */
export type NativeSessionTransition = {
mode: "resumed" | "fresh";
reason:
| "checkpoint_compatible"
| "checkpoint_unavailable"
| "checkpoint_incompatible"
| "native_tool_contract_unverified"
| "native_tool_contract_changed";
taskContext: "resume" | "full";
};
/** A resume delta is valid only if the provider checkpoint really can be used. */
export function buildNativeExecutionWithCheckpoint(input: {
previousRun:
@ -392,6 +404,7 @@ export function buildNativeExecutionWithCheckpoint(input: {
execution: NativeExecutionInput;
checkpoint: PersistedNativeSession | null;
normalizedSessionId: string;
sessionTransition: NativeSessionTransition;
} {
const execution = input.buildExecution({
normalizedSessionId: input.normalizedSessionId,
@ -409,10 +422,23 @@ export function buildNativeExecutionWithCheckpoint(input: {
execution,
checkpoint,
normalizedSessionId: input.normalizedSessionId,
sessionTransition: { mode: "resumed", reason: "checkpoint_compatible", taskContext: "resume" },
};
// Rebuild both task context and wake instructions. Merely rotating the ID
// leaves a fresh provider with a compact delta and missing task context.
const normalizedSessionId = randomUUID();
// This explains a native-provider transition independently of task config
// freshness. Missing historical contracts are unverified, not equivalent.
const storedToolContract = input.previousRun
? record(input.previousRun.runnerProfileJson).nativeToolContractFingerprint
: null;
const reason: NativeSessionTransition["reason"] = !input.previousRun
? "checkpoint_unavailable"
: typeof storedToolContract !== "string" || !storedToolContract
? "native_tool_contract_unverified"
: storedToolContract !== nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local")
? "native_tool_contract_changed"
: "checkpoint_incompatible";
return {
execution: input.buildExecution({
normalizedSessionId,
@ -420,6 +446,7 @@ export function buildNativeExecutionWithCheckpoint(input: {
}),
checkpoint: null,
normalizedSessionId,
sessionTransition: { mode: "fresh", reason, taskContext: "full" },
};
}