From 193c2baeeec22b2253faefe8d2df7994a2f6a88d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 21:02:41 -0500 Subject: [PATCH] Validate retained packages against the active provider contract --- .../native-session-executor.test.ts | 39 +++++++++++++++++ .../native-runtime/native-session-executor.ts | 43 ++++++++++++++----- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index fd97540e22..5203daf75c 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1476,6 +1476,40 @@ describe("remote provider pack manifest", () => { const changedPins = structuredClone(retainedManifest); changedPins.payload.pins = { ...changedPins.payload.pins, nodeMinimum: "0.0.0" as typeof changedPins.payload.pins.nodeMinimum }; expect(() => validateRemoteProviderPackIdentity(changedPins)).toThrow("pins or source revision"); + const rehash = (manifest: typeof retainedManifest) => { + manifest.digest = digest(canonical(manifest.payload)); + return manifest; + }; + const oldPack = structuredClone(retainedManifest); + Object.assign(oldPack.payload.pins, { opencode: "1.18.33", nodeMinimum: "24.10.0", codex: "0.159.0" }); + Object.assign(oldPack.payload.acpxProfileDigests, { codex: digest("old-codex-profile") }); + rehash(oldPack); + const retainedAuthority = { digest: oldPack.digest, agent: "claude" }; + // A controller-bound Claude session survives unrelated bundled-provider + // drift, while fresh admissions continue to require the current release. + expect(() => validateRemoteProviderPackIdentity(oldPack)).toThrow("pins or source revision"); + expect(validateRemoteProviderPackIdentity(oldPack, root, true, retainedAuthority).digest).toBe(oldPack.digest); + expect(() => validateRemoteProviderPackIdentity(oldPack, root, true, + { ...retainedAuthority, digest: retainedManifest.digest })).toThrow("manifest digest mismatch"); + for (const key of ["acpx", "claudeAcp"] as const) { + const incompatible = structuredClone(oldPack); + Object.assign(incompatible.payload.pins, { [key]: "0.0.1" }); + rehash(incompatible); + expect(() => validateRemoteProviderPackIdentity(incompatible, root, true, + { ...retainedAuthority, digest: incompatible.digest })).toThrow("pins or source revision"); + } + const incompatibleProfile = structuredClone(oldPack); + Object.assign(incompatibleProfile.payload.acpxProfileDigests, { claude: digest("old-claude-profile") }); + rehash(incompatibleProfile); + expect(() => validateRemoteProviderPackIdentity(incompatibleProfile, root, true, + { ...retainedAuthority, digest: incompatibleProfile.digest })).toThrow("pins or source revision"); + const tamperedManifest = structuredClone(oldPack); + tamperedManifest.payload.artifacts.nodeCommand.sha256 = digest("substitute-node"); + rehash(tamperedManifest); + expect(() => validateRemoteProviderPackIdentity(tamperedManifest, root, true, retainedAuthority)).toThrow("manifest digest mismatch"); + await writeFile(join(root, "node_modules", "node", "bin", "node"), "substitute-node"); + expect(() => validateRemoteProviderPackIdentity(oldPack, root, true, retainedAuthority)).toThrow("provider Node digest mismatch"); + await writeFile(join(root, "node_modules", "node", "bin", "node"), node); expect(readRemoteProviderPackManifest(root).payload.pins.opencode).toBe( "1.18.34", ); @@ -1499,6 +1533,11 @@ describe("remote provider pack manifest", () => { await writeManifest(); await cp(join(root, "provider-pack.json"), manifestPath); expect(() => readRemoteProviderPackManifest(root)).toThrow("Cursor profile or closure"); expect(() => readBundledRemoteProviderPackManifest(manifestPath)).toThrow("Cursor profile or closure"); + const oldCursorPack = JSON.parse(await readFile(manifestPath, "utf8")) as typeof retainedManifest; + expect(validateRemoteProviderPackIdentity(oldCursorPack, root, true, + { digest: oldCursorPack.digest, agent: "claude" }).digest).toBe(oldCursorPack.digest); + expect(() => validateRemoteProviderPackIdentity(oldCursorPack, root, true, + { digest: oldCursorPack.digest, agent: "cursor" })).toThrow("Cursor profile or closure"); Object.assign(cursor, { [field]: previous }); } await writeManifest(); await cp(join(root, "provider-pack.json"), manifestPath); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 07677cb8ee..0675e50c06 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10233,14 +10233,34 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles: return validateRemoteProviderPackIdentity(manifest, packRoot, verifyControllerFiles); } -export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackManifest, packRoot = "", verifyControllerFiles = false): RemoteProviderPackManifest { +export function validateRemoteProviderPackIdentity( + manifest: RemoteProviderPackManifest, + packRoot = "", + verifyControllerFiles = false, + retainedAuthority?: { digest: string; agent: string }, +): RemoteProviderPackManifest { const payload = manifest?.payload; + // A retained session may outlive unrelated bundled-provider upgrades. Its + // active provider must still satisfy this release's qualified ACPX contract. + const retainedAgent = retainedAuthority?.agent; + const retainedProfile = retainedAgent === "claude" || retainedAgent === "codex" || retainedAgent === "grok" + ? retainedAgent : null; + const activePins = retainedProfile === "claude" ? ["acpx", "claudeAcp"] as const + : retainedProfile === "codex" ? ["acpx", "codex", "codexAcp"] as const + : ["acpx", "grok"] as const; + const pinsMatch = retainedProfile + ? canonicalJson(Object.keys(payload?.pins ?? {}).sort()) === canonicalJson(Object.keys(REMOTE_PROVIDER_PACK_PINS).sort()) + && Object.values(payload?.pins ?? {}).every(value => typeof value === "string" && value.length > 0 && value.length <= 120) + && activePins.every(key => payload?.pins?.[key] === REMOTE_PROVIDER_PACK_PINS[key]) + && canonicalJson(Object.keys(payload?.acpxProfileDigests ?? {}).sort()) === canonicalJson(Object.keys(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS).sort()) + && Object.values(payload?.acpxProfileDigests ?? {}).every(value => /^sha256:[0-9a-f]{64}$/.test(value)) + && payload?.acpxProfileDigests?.[retainedProfile] === REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[retainedProfile] + : canonicalJson(payload?.pins) === canonicalJson(REMOTE_PROVIDER_PACK_PINS) + && canonicalJson(payload?.acpxProfileDigests) === canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS); if ( manifest?.schema !== REMOTE_PROVIDER_PACK_SCHEMA || !payload || - canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) || - canonicalJson(payload.acpxProfileDigests) !== - canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS) || + !pinsMatch || typeof payload.target?.platform !== "string" || typeof payload.target?.architecture !== "string" || !/^[0-9a-f]{40}(?:-dirty)?$/.test(payload.runnerSourceRevision) || @@ -10253,7 +10273,7 @@ export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackM const digest = `sha256:${createHash("sha256") .update(canonicalJson(payload)) .digest("hex")}`; - if (manifest.digest !== digest) { + if (manifest.digest !== digest || (retainedAuthority && digest !== retainedAuthority.digest)) { throw new Error( "runner_remote_provider_artifact_incompatible: provider pack manifest digest mismatch", ); @@ -10333,7 +10353,7 @@ export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackM throw new Error("runner_remote_provider_artifact_incompatible: invalid candidate identity"); } const candidatePath = providerPackRelativePath(candidate.path, "candidate assets"); - if (provider === "cursor") { + if (provider === "cursor" && !retainedProfile) { const target = `${payload.target.platform}-${payload.target.architecture}`; const distribution = Object.hasOwn(CURSOR_DISTRIBUTION_PINS, target) ? CURSOR_DISTRIBUTION_PINS[target as keyof typeof CURSOR_DISTRIBUTION_PINS] : undefined; @@ -12044,12 +12064,13 @@ async function createRunnerdBackendWithinSessionClaim( control: JSON.parse(readBoundedNativeFile(resolve(root, "control-plane", "control-plane-state.json"), NATIVE_CONTROL_PLANE_STATE_MAX_BYTES, "runner_durable_identity_too_large").toString("utf8")), }) : null; - if (retainedComputerPack && remoteCommandRunner && remoteStorage) { + if (retainedComputerPack && remoteCommandRunner && remoteStorage && input.execution.provider.kind === "acpx") { // The local attachment template chooses the digest. Remote content cannot // authorize a newer pack or rewrite the existing provider's launch identity. const [manifest, state] = await readPinnedComputerProviderMetadata({ runner: remoteCommandRunner, packRoot: retainedComputerPack.root, stateDirectory: posix.join(remoteStorage.sessionRoot, "runner") }); - expectedProviderPackManifest = validateRemoteProviderPackIdentity(manifest as RemoteProviderPackManifest); + expectedProviderPackManifest = validateRemoteProviderPackIdentity(manifest as RemoteProviderPackManifest, "", false, + { digest: retainedComputerPack.digest, agent: input.execution.provider.agent }); if (expectedProviderPackManifest.digest !== retainedComputerPack.digest || expectedProviderPackManifest.payload.target.platform !== "linux" || expectedProviderPackManifest.payload.target.architecture !== "x64") @@ -12282,8 +12303,8 @@ async function createRunnerdBackendWithinSessionClaim( "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')", "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}", "const version=process.versions.node.split('.').map(Number)", - "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)", - "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version", "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}", @@ -12315,7 +12336,7 @@ async function createRunnerdBackendWithinSessionClaim( if ( opencodeVersion.exitCode !== 0 || opencodeVersion.timedOut || - opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode + opencodeVersion.stdout.trim() !== expectedProviderPackManifest.payload.pins.opencode ) { throw new Error( "runner_remote_provider_artifact_incompatible: OpenCode version mismatch",