diff --git a/tests/runner-e2e/source-sdk-evidence.ts b/tests/runner-e2e/source-sdk-evidence.ts index 2ecb99c3b96..d21e9bb676e 100644 --- a/tests/runner-e2e/source-sdk-evidence.ts +++ b/tests/runner-e2e/source-sdk-evidence.ts @@ -59,7 +59,12 @@ export async function retainPreparedSourceSdk(input: { repositoryRoot: string; t if (input.installed || !selected(input.executionIds)) return; if (!/^[a-f0-9]{40}$/.test(input.sourceRevision ?? "")) throw new Error("Source SDK retention requires exact source revision"); const root = await realpath(input.temporaryRoot), repository = await realpath(input.repositoryRoot); - if (await realpath(path.join(root, "artifacts-private")) !== path.join(root, "artifacts-private")) throw new Error("Source SDK staging custody invalid"); + // External private capture need not create the local SDK staging parent. + // Own its initialization before retention instead of relying on later server-log setup. + const privateRoot = path.join(root, "artifacts-private"); + try { await mkdir(privateRoot, { mode: 0o700 }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + if (await realpath(privateRoot) !== privateRoot) throw new Error("Source SDK staging custody invalid"); const secrets = diagnosticSecrets(readPrivateDiagnosticSecrets(path.join(root, "selected-private-inputs", "diagnostic-redaction.json"), root)).values; const scanner = createSdkArtifactScanner(repository, secrets); const buffers: Array = [], certifiedTargets: RetainedBuild["certifiedTargets"] = []; diff --git a/tests/runner-e2e/source-sdk-staging.test.ts b/tests/runner-e2e/source-sdk-staging.test.ts new file mode 100644 index 00000000000..adbb3049f36 --- /dev/null +++ b/tests/runner-e2e/source-sdk-staging.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { stagePrivateDiagnostics } from "./private-diagnostics.js"; +import { prepareSourceServerDependencies } from "./source-startup.js"; +import { copyRetainedSourceSdk, retainPreparedSourceSdk } from "./source-sdk-evidence.js"; + +const roots: string[] = [], executionId = "chat-features.runner-opencode.local.plugins.install-plugin", sourceRevision = "a".repeat(40); +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture(secrets: string[] = []) { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "source-sdk-synthetic-unit-"))); roots.push(root); + const repositoryRoot = path.join(root, "repo"), temporaryRoot = path.join(root, "attempt"), uploadDir = path.join(root, "uploaded"); + const put = async (file: string, value: string | Buffer) => { await mkdir(path.dirname(path.join(repositoryRoot, file)), { recursive: true }); await writeFile(path.join(repositoryRoot, file), value); }; + await put("scripts/ensure-plugin-build-deps.mjs", await readFile(new URL("../../scripts/ensure-plugin-build-deps.mjs", import.meta.url))); + await put("pnpm-lock.yaml", "synthetic unit lock\n"); + for (const directory of ["packages/shared", "packages/plugins/sdk"]) { + await put(`${directory}/src/index.ts`, "export const syntheticFixture = true;\n"); await put(`${directory}/tsconfig.json`, "{}"); + } + await put("node_modules/typescript/bin/tsc", `const fs=require('node:fs'),path=require('node:path');if(process.env.FAIL_COMPILER==='1')process.exit(7);const dir=path.join(path.dirname(process.argv[3]),'dist');fs.mkdirSync(path.join(dir,'nested'),{recursive:true});fs.writeFileSync(path.join(dir,'index.js'),process.env.SYNTHETIC_OUTPUT_CONTENT||'export const syntheticFixture = true;\\n');fs.writeFileSync(path.join(dir,'nested/child.js'),'export {};\\n');`); + await mkdir(path.join(temporaryRoot, "selected-private-inputs"), { recursive: true, mode: 0o700 }); + stagePrivateDiagnostics(temporaryRoot, [executionId], secrets); + const input = { repositoryRoot, temporaryRoot, executionIds: [executionId], sourceRevision, installed: false }; + const prepare = (environment: NodeJS.ProcessEnv = {}) => prepareSourceServerDependencies(repositoryRoot, environment, false, { temporaryRoot, executionIds: [executionId], sourceRevision }); + return { root, repositoryRoot, temporaryRoot, uploadDir, input, prepare }; +} + +it("initializes owned SDK staging before retention when private capture is outside the temporary root", async () => { + const f = await fixture(); + const externalCapture = path.join(f.root, "manual-handoff", "capture"); + await mkdir(externalCapture, { recursive: true, mode: 0o700 }); + await f.prepare(); + const staged = path.join(f.temporaryRoot, "artifacts-private"); + expect((await stat(staged)).mode & 0o777).toBe(0o700); + const receipt = JSON.parse(await readFile(path.join(staged, "source-sdk-build/receipt.json"), "utf8")); + expect(receipt).toMatchObject({ executionId, sourceRevision }); + expect(receipt.certifiedTargets).toHaveLength(2); + await expect(copyRetainedSourceSdk(f.temporaryRoot, executionId, sourceRevision, f.uploadDir, [])).resolves.toBeUndefined(); + await expect(stat(path.join(externalCapture, "source-sdk-build"))).rejects.toThrow(); +}); +it("rejects a symlink SDK staging parent without writing retained evidence outside the attempt", async () => { + const f = await fixture(); + const outside = path.join(f.root, "outside"); + await mkdir(outside, { mode: 0o700 }); + await symlink(outside, path.join(f.temporaryRoot, "artifacts-private")); + await expect(f.prepare()).rejects.toThrow("Source SDK staging custody invalid"); + await expect(stat(path.join(outside, "source-sdk-build"))).rejects.toThrow(); +}); +it("still rejects changed certified output after staging initialization", async () => { + const f = await fixture(); await f.prepare(); + await rm(path.join(f.temporaryRoot, "artifacts-private/source-sdk-build"), { recursive: true }); + await writeFile(path.join(f.repositoryRoot, "packages/plugins/sdk/dist/index.js"), "changed compiler bytes"); + await expect(retainPreparedSourceSdk(f.input)).rejects.toThrow("no longer matches"); + await expect(stat(path.join(f.temporaryRoot, "artifacts-private/source-sdk-build"))).rejects.toThrow(); +}); +it.each(["missing redaction registry", "credential contamination", "compiler failure"])("does not retain evidence for %s after staging initialization", async invalid => { + const f = await fixture(["synthetic-private-upstream-credential-value"]); + if (invalid === "missing redaction registry") await rm(path.join(f.temporaryRoot, "selected-private-inputs/diagnostic-redaction.json")); + await expect(f.prepare(invalid === "credential contamination" ? { SYNTHETIC_OUTPUT_CONTENT: "synthetic-private-upstream-credential-value" } + : invalid === "compiler failure" ? { FAIL_COMPILER: "1" } : {})).rejects.toThrow(); + await expect(stat(path.join(f.temporaryRoot, "artifacts-private/source-sdk-build"))).rejects.toThrow(); +});