diff --git a/doc/architecture/paperclip-runner-compatibility.md b/doc/architecture/paperclip-runner-compatibility.md index b648186c5f..578c3357d8 100644 --- a/doc/architecture/paperclip-runner-compatibility.md +++ b/doc/architecture/paperclip-runner-compatibility.md @@ -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. diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index b0a25abb89..de27eee6f5 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -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. diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index b67af9093e..1b84057faa 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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 ({ name, url, connectionId })), ); @@ -783,8 +820,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 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 { 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); +}); diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts index 19c18809a5..8eb9cba6ea 100644 --- a/packages/adapters/claude-local/src/server/prompt-cache.ts +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -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 { + // 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 { + 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 { + 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 { 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 { 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, diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index b2fd8f0c86..46eceec209 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -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"); diff --git a/server/src/__tests__/legacy-sandbox-session.test.ts b/server/src/__tests__/legacy-sandbox-session.test.ts index 57a47cf5c4..3fb3adc2d5 100644 --- a/server/src/__tests__/legacy-sandbox-session.test.ts +++ b/server/src/__tests__/legacy-sandbox-session.test.ts @@ -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; + 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); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index af2c68c82f..0c2fdef7d1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 diff --git a/server/src/services/legacy-sandbox-session.ts b/server/src/services/legacy-sandbox-session.ts index 8273ac6f84..09cf46f9c3 100644 --- a/server/src/services/legacy-sandbox-session.ts +++ b/server/src/services/legacy-sandbox-session.ts @@ -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; + 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 { + 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}` }, + ]) }; +} diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index fb263da3b2..4b3284dfea 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -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([ diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index 3861496116..7224ac6d44 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -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" }, }; }