diff --git a/scripts/install-smoke-candidate-payload.mts b/scripts/install-smoke-candidate-payload.mts index 4c366d4f3f04..f8ac6fb77bc7 100644 --- a/scripts/install-smoke-candidate-payload.mts +++ b/scripts/install-smoke-candidate-payload.mts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { assembleStandaloneInstaller } from "./lib/standalone-installers.mjs"; const MANIFEST_NAME = "install-smoke-candidate-payload.json"; const SCHEMA = "openclaw.install-smoke-candidate-payload/v1"; @@ -239,16 +240,18 @@ export async function sealInstallSmokeCandidatePayload( // Re-read installers from the immutable source archive after candidate execution. Candidate // build hooks never get a writable handle to the sealed scripts consumed by privileged jobs. - const installScript = runPythonTarReader([ - "repo-file", - options.archivePath, - "scripts/install.sh", - ]); - const cliInstallScript = runPythonTarReader([ - "repo-file", - options.archivePath, - "scripts/install-cli.sh", - ]); + const readInstaller = (name: string) => + assembleStandaloneInstaller( + runPythonTarReader(["repo-file", options.archivePath, `scripts/${name}`]).toString("utf8"), + () => + runPythonTarReader([ + "repo-file", + options.archivePath, + "scripts/install-policy.sh", + ]).toString("utf8"), + ); + const installScript = readInstaller("install.sh"); + const cliInstallScript = readInstaller("install-cli.sh"); await fs.copyFile(sourceTarballPath, path.join(options.outputDir, "candidate.tgz")); await writeExclusive( path.join(options.outputDir, "candidate-pack.json"), diff --git a/scripts/lib/standalone-installers.mjs b/scripts/lib/standalone-installers.mjs new file mode 100644 index 000000000000..2cf2423f981b --- /dev/null +++ b/scripts/lib/standalone-installers.mjs @@ -0,0 +1,13 @@ +const POLICY_INCLUDE = 'source "${BASH_SOURCE[0]%${BASH_SOURCE[0]##*/}}./install-policy.sh"'; + +// The source can come from a sealed archive. Never evaluate candidate shell code +// or load its generator while assembling a privileged install-smoke payload. +export function assembleStandaloneInstaller(source, readPolicy) { + if (!source.includes(POLICY_INCLUDE)) { + return source; + } + if (source.indexOf(POLICY_INCLUDE) !== source.lastIndexOf(POLICY_INCLUDE)) { + throw new Error("Installer must include shared policy exactly once"); + } + return source.replace(POLICY_INCLUDE, () => readPolicy().trimEnd()); +} diff --git a/test/scripts/install-smoke-candidate-payload.test.ts b/test/scripts/install-smoke-candidate-payload.test.ts index 69ab6abcc8f9..71176ec77d27 100644 --- a/test/scripts/install-smoke-candidate-payload.test.ts +++ b/test/scripts/install-smoke-candidate-payload.test.ts @@ -1,6 +1,14 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -96,6 +104,29 @@ function verifyOptions(payloadDir: string, manifestSha256: string, sourceArchive } describe("install smoke candidate payload", () => { + it("inlines archived policy without executing candidate code or reading mutable sources", async () => { + const fixture = createFixture(); + const scripts = path.join(fixture.root, "candidate-root/scripts"); + const marker = path.join(fixture.root, "executed"); + writeFileSync( + path.join(scripts, "install.sh"), + '#!/bin/bash\nsource "${BASH_SOURCE[0]%${BASH_SOURCE[0]##*/}}./install-policy.sh"\necho install\n', + ); + writeFileSync(path.join(scripts, "install-policy.sh"), `touch '${marker}'\n`); + createTarball(fixture.archivePath, fixture.root, ["candidate-root"]); + writeFileSync(path.join(scripts, "install-policy.sh"), "mutable source must not be used\n"); + await sealInstallSmokeCandidatePayload({ + ...IDENTITY, + archivePath: fixture.archivePath, + outputDir: fixture.payloadDir, + packageDir: fixture.packageDir, + }); + expect(existsSync(marker)).toBe(false); + expect(readFileSync(path.join(fixture.payloadDir, "install.sh"), "utf8")).toBe( + `#!/bin/bash\ntouch '${marker}'\necho install\n`, + ); + }); + it("seals source installers and package bytes into a fully bound payload", async () => { const fixture = await sealFixture(); const verified = await verifyInstallSmokeCandidatePayload( @@ -138,6 +169,25 @@ describe("install smoke candidate payload", () => { ); }); + it("rejects a policy include backed by an archive symlink", async () => { + const fixture = createFixture(); + const scripts = path.join(fixture.root, "candidate-root/scripts"); + writeFileSync( + path.join(scripts, "install.sh"), + '#!/bin/bash\nsource "${BASH_SOURCE[0]%${BASH_SOURCE[0]##*/}}./install-policy.sh"\n', + ); + symlinkSync("install-target.sh", path.join(scripts, "install-policy.sh")); + createTarball(fixture.archivePath, fixture.root, ["candidate-root"]); + await expect( + sealInstallSmokeCandidatePayload({ + ...IDENTITY, + archivePath: fixture.archivePath, + outputDir: fixture.payloadDir, + packageDir: fixture.packageDir, + }), + ).rejects.toThrow("scripts/install-policy.sh must be a regular file"); + }); + it("rejects tampering with the final payload file after sealing", async () => { const fixture = await sealFixture(); writeFileSync(path.join(fixture.payloadDir, "install-cli.sh"), "tampered\n");