diff --git a/server/src/services/native-runtime/provider-pack-upload.ts b/server/src/services/native-runtime/provider-pack-upload.ts index a891573ebf5..1a8606c268e 100644 --- a/server/src/services/native-runtime/provider-pack-upload.ts +++ b/server/src/services/native-runtime/provider-pack-upload.ts @@ -25,10 +25,7 @@ export async function uploadVerifiedProviderPack(input: { stageDirectory: (root: string) => Promise; verifyUploaded: () => Promise; }): Promise { - // The controller can only stage the pack it ships. A retained continuation - // pinned to an earlier release's pack cannot be satisfied by these bytes, by - // either transport. Fail before clearing the remote pack or uploading. - if (input.sourceDigest !== input.expectedDigest) { + if (input.sourceDigest !== input.expectedDigest && !input.manifest) { const short = (digest: string) => digest.replace(/^sha256:/, "").slice(0, 12); throw new Error(`runner_remote_provider_artifact_incompatible: selected provider pack ${short(input.expectedDigest)} is not the controller's pack ${short(input.sourceDigest)}; install the matching Paperclip package and Daytona image`); } @@ -36,7 +33,7 @@ export async function uploadVerifiedProviderPack(input: { input.signal?.throwIfAborted(); if (input.manifest && input.manifest.digest !== input.expectedDigest) throw Error("runner_remote_provider_source_invalid"); const source = input.manifest ? await measure("provider_pack.source", () => resolveProviderPackUploadSource({ - root: input.root, manifest: input.manifest!, signal: input.signal, + root: input.root, sourceDigest: input.sourceDigest, manifest: input.manifest!, signal: input.signal, })) : { root: input.root, archiveOnly: false }; const verifyDirectory = () => measure("provider_pack.directory.verify", async () => input.verifyDirectory(source.root)); // Archive installation reads the sealed archives, not the expanded tree. diff --git a/server/src/services/native-runtime/retained-provider-pack-source.test.ts b/server/src/services/native-runtime/retained-provider-pack-source.test.ts index 91bd0156da9..38b0ffe2639 100644 --- a/server/src/services/native-runtime/retained-provider-pack-source.test.ts +++ b/server/src/services/native-runtime/retained-provider-pack-source.test.ts @@ -148,7 +148,7 @@ async function fixture(options: { wrongCode?: boolean; unsafeOverlay?: boolean; } function upload(input: Awaited>, extra: Partial[0]> = {}) { - return uploadVerifiedProviderPack({ root: input.current.pack, expectedDigest: input.selected.manifest.digest, + return uploadVerifiedProviderPack({ root: input.current.pack, sourceDigest: input.current.manifest.digest, expectedDigest: input.selected.manifest.digest, manifest: input.selected.manifest, runner: input.runner, cwd: input.root, targetPath: input.targetPath, reuseRoots: [], verifyDirectory: () => { throw Error("current_directory_fallback_forbidden"); }, stageDirectory: async () => { throw Error("current_directory_fallback_forbidden"); }, verifyUploaded: async () => {}, ...extra } as Parameters[0]); @@ -167,7 +167,7 @@ it("installs the exact retained pack through the real uploader on an empty host it("keeps a matching current pack on its existing path without registry or cache reads", async () => { const input = await fixture(); - await upload(input, { root: input.selected.pack }); + await upload(input, { root: input.selected.pack, sourceDigest: input.selected.manifest.digest }); expect(input.requests).toEqual([]); await expect(fs.stat(path.join(input.temporary, `paperclip-provider-pack-sources-${process.getuid?.()}`))).rejects.toMatchObject({ code: "ENOENT" }); expect(await fs.readFile(path.join(input.targetPath, "bin/tool"), "utf8")).toBe("retained provider code"); @@ -184,7 +184,15 @@ it("refuses a different historical code pack even when OCI source labels and all it("does not mask a corrupt matching current archive by looking up a different code source", async () => { const input = await fixture(); await fs.writeFile(input.selected.pack + ".archive.json", "{"); - await expect(upload(input, { root: input.selected.pack })).rejects.toThrow("runner_remote_provider_archive_invalid"); + await expect(upload(input, { root: input.selected.pack, sourceDigest: input.selected.manifest.digest })).rejects.toThrow("runner_remote_provider_archive_invalid"); + expect(input.requests).toEqual([]); + expect(input.runner.execute).not.toHaveBeenCalled(); + expect(input.runner.syncIn).not.toHaveBeenCalled(); +}); + +it("rejects a changed controller code identity before looking up a historical source", async () => { + const input = await fixture(); + await expect(upload(input, { sourceDigest: input.selected.manifest.digest })).rejects.toThrow("runner_remote_provider_source_invalid"); expect(input.requests).toEqual([]); expect(input.runner.execute).not.toHaveBeenCalled(); expect(input.runner.syncIn).not.toHaveBeenCalled(); diff --git a/server/src/services/native-runtime/retained-provider-pack-source.ts b/server/src/services/native-runtime/retained-provider-pack-source.ts index 7aa7a54bb3a..0e8c1b130b8 100644 --- a/server/src/services/native-runtime/retained-provider-pack-source.ts +++ b/server/src/services/native-runtime/retained-provider-pack-source.ts @@ -265,6 +265,7 @@ async function importSource(cache: string, pointer: string, manifest: ProviderPa export async function resolveProviderPackUploadSource(input: { root: string; + sourceDigest?: string; manifest: ProviderPackIdentity; signal?: AbortSignal; }): Promise<{ root: string; archiveOnly: boolean }> { @@ -272,7 +273,8 @@ export async function resolveProviderPackUploadSource(input: { if (manifest?.schema !== "paperclip-runner/remote-provider-pack/v1" || manifest.digest !== `sha256:${digest(canonicalProviderPackJson(manifest.payload))}`) fail(); input.signal?.throwIfAborted(); const configured = JSON.parse((await readStable(join(input.root, "provider-pack.json"))).toString()) as ProviderPackIdentity; - if (configured?.schema !== manifest.schema || configured.digest !== `sha256:${digest(canonicalProviderPackJson(configured.payload))}`) fail(); + if (configured?.schema !== manifest.schema || configured.digest !== `sha256:${digest(canonicalProviderPackJson(configured.payload))}` + || input.sourceDigest !== undefined && input.sourceDigest !== configured.digest) fail(); if (configured.digest === manifest.digest) return { root: input.root, archiveOnly: false }; const cache = join(await realpath(tmpdir()), `paperclip-provider-pack-sources-${process.getuid?.()}`); await mkdir(cache, { recursive: true, mode: 0o700 });