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:
parent
26164f1d90
commit
355590536e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
]) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue