diff --git a/tests/runner-e2e/docker-postgres.test.ts b/tests/runner-e2e/docker-postgres.test.ts index f352d52cab9..650a27a9bed 100644 --- a/tests/runner-e2e/docker-postgres.test.ts +++ b/tests/runner-e2e/docker-postgres.test.ts @@ -9,7 +9,9 @@ import { assertRunnerDatabaseIsolation, assertEmbeddedDatabaseIsolation } from " import { campaignDatabaseEvidenceEligible, type CampaignAttempt } from "./chat-campaign.js"; import { assertChatCatalogFingerprintLane, fingerprintChatDependencyClosure } from "./chat-catalog-fingerprint.js"; import { validateRetainedRunnerResult } from "./result-validation.js"; -import { buildPaperclipServerEnvironment } from "./harness-env.js"; +import { assertIsolatedServerEnvironment, buildPaperclipServerEnvironment } from "./harness-env.js"; +import { findSecretLeakInDirectory } from "./redaction.js"; +import { createSanitizedCodexEnvironment } from "../../packages/paperclip-runner/src/drivers/codex/app-server-transport.js"; const roots: string[] = []; beforeEach(() => { vi.stubEnv("DOCKER_HOST", ""); vi.stubEnv("DOCKER_CONTEXT", ""); }); afterEach(async () => { vi.unstubAllEnvs(); for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); @@ -71,7 +73,10 @@ it("binds exact image/engine/resources, strips admission data and removes only o expect(supervisor.source[DOCKER_DATABASE_ENV]).toBeUndefined(); expect(supervisor.source[DATABASE_LANE_ENV]).toBeUndefined(); const configPath = path.join(temp, "paperclip-home", "instances", "fixture", "config.json"); expect(await prepareRunnerE2EServerConfig({ temporaryRoot: temp, configPath, serverPort: 3100, dockerReceiptPath: owned.filename, dockerRun: run })).toBeNull(); - const config = JSON.parse(await readFile(configPath, "utf8")); expect(config.database.mode).toBe("postgres"); expect(config.database.connectionString).toBe(owned.receipt.connectionString); + const config = JSON.parse(await readFile(configPath, "utf8")); expect(config.database.mode).toBe("postgres"); expect(config.database.connectionString).toBeUndefined(); + expect(JSON.stringify(config)).not.toContain(secrets[0]); + expect(supervisor.serverOverrides.DATABASE_URL).toBe(owned.receipt.connectionString); + expect(supervisor.source.DATABASE_URL).toBeUndefined(); const key = config.secrets.localEncrypted.keyFilePath; await mkdir(path.dirname(key), { recursive: true }); await writeFile(key, "offline-key", { mode: 0o600 }); await expect(assertRunnerDatabaseIsolation(configPath, temp, owned.filename, run)).resolves.toBeUndefined(); const changed = structuredClone(config); changed.database.connectionString = "postgresql://foreign"; await writeFile(configPath, JSON.stringify(changed), { mode: 0o600 }); @@ -183,3 +188,34 @@ it("allows only exact Docker Desktop host translation with an owned canonical pr await rm(password); await writeFile(password, contents, { mode: 0o600 }); await owned.close(); expect(state.container).toBeNull(); expect(state.volume).toBeNull(); }); + +it("keeps owned DB credentials server-only without any persisted scan exemption", async () => { + const temp = await root(), { run } = daemon(), secrets: string[] = []; + const owned = await startOwnedDockerDatabase({ temporaryRoot: temp, privateSecrets: secrets, run }); + const admission = await dockerDatabaseSupervisorEnvironment({ temporaryRoot: temp, env: { [DATABASE_LANE_ENV]: "owned-docker-postgres", [DOCKER_DATABASE_ENV]: owned.filename, DATABASE_URL: "ambient-unrelated", DATABASE_MIGRATION_URL: "ambient-migration" }, run }); + const home = path.join(temp, "paperclip-home"), configPath = path.join(home, "instances", "custody", "config.json"); + await prepareRunnerE2EServerConfig({ temporaryRoot: temp, configPath, serverPort: 3100, dockerReceiptPath: owned.filename, dockerRun: run }); + const expected = { temporaryRoot: temp, paperclipHome: home, configPath, ownedDatabaseUrl: admission.serverOverrides.DATABASE_URL }; + const env = buildPaperclipServerEnvironment(admission.source, { ...admission.serverOverrides, PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: configPath, XDG_CACHE_HOME: path.join(temp, "xdg-cache"), PAPERCLIP_AGENT_JWT_SECRET: "jwt", PAPERCLIP_DECISION_SIGNING_SECRET: "decision", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "tool", BETTER_AUTH_SECRET: "auth" }); + expect(env.DATABASE_URL).toBe(owned.receipt.connectionString); + expect(env.DATABASE_MIGRATION_URL).toBeUndefined(); + expect(() => assertIsolatedServerEnvironment(env, expected)).not.toThrow(); + expect(() => assertIsolatedServerEnvironment({ ...env, DATABASE_URL: "ambient-unrelated" }, expected)).toThrow(); + expect(() => assertIsolatedServerEnvironment(env, { ...expected, ownedDatabaseUrl: undefined })).toThrow(); + expect(createSanitizedCodexEnvironment(env).DATABASE_URL).toBeUndefined(); + expect(createSanitizedCodexEnvironment(env).DATABASE_MIGRATION_URL).toBeUndefined(); + expect(await findSecretLeakInDirectory(home, secrets, { includeShapes: false })).toBeNull(); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.database.connectionString = owned.receipt.connectionString; + await writeFile(configPath, JSON.stringify(config), { mode: 0o600 }); + expect(await findSecretLeakInDirectory(home, secrets, { includeShapes: false })).not.toBeNull(); + await expect(prepareRunnerE2EServerConfig({ temporaryRoot: temp, configPath, serverPort: 3100, dockerReceiptPath: owned.filename, dockerRun: run })).rejects.toThrow("differs"); + delete config.database.connectionString; + config.unrelatedField = secrets[0]; + await writeFile(configPath, JSON.stringify(config), { mode: 0o600 }); + expect(await findSecretLeakInDirectory(home, secrets, { includeShapes: false })).not.toBeNull(); + config.unrelatedField = "provider-secret-canary"; + await writeFile(configPath, JSON.stringify(config), { mode: 0o600 }); + expect(await findSecretLeakInDirectory(home, [...secrets, "provider-secret-canary"], { includeShapes: false })).not.toBeNull(); + await owned.close(); +}); diff --git a/tests/runner-e2e/docker-postgres.ts b/tests/runner-e2e/docker-postgres.ts index e1fb5ca31c9..60427d5ca10 100644 --- a/tests/runner-e2e/docker-postgres.ts +++ b/tests/runner-e2e/docker-postgres.ts @@ -234,11 +234,11 @@ export async function startOwnedDockerDatabase(input: { temporaryRoot: string; p export async function dockerDatabaseSupervisorEnvironment(input: { temporaryRoot: string; env: NodeJS.ProcessEnv; run?: DockerRun }) { const lane = selectedDatabaseLane(input.env), source = { ...input.env }, filename = source[DOCKER_DATABASE_ENV]; delete source[DOCKER_DATABASE_ENV]; delete source[DATABASE_LANE_ENV]; - if (lane === "embedded-postgres") { if (filename) throw new Error("Undeclared Docker database receipt"); return { source, filename: undefined }; } + if (lane === "embedded-postgres") { if (filename) throw new Error("Undeclared Docker database receipt"); return { source, filename: undefined, serverOverrides: {} as NodeJS.ProcessEnv }; } if (!filename) throw new Error("Selected Docker database lane is missing its owned receipt"); - await readDockerDatabaseReceipt(filename, input.temporaryRoot, input.run); + const receipt = await readDockerDatabaseReceipt(filename, input.temporaryRoot, input.run); for (const key of ["DOCKER_CONFIG", "DOCKER_HOST", "DOCKER_CONTEXT"]) delete source[key]; - return { source, filename }; + return { source, filename, serverOverrides: { DATABASE_URL: receipt.connectionString } as NodeJS.ProcessEnv }; } export function databaseFingerprintIdentity(env = process.env) { diff --git a/tests/runner-e2e/harness-env.ts b/tests/runner-e2e/harness-env.ts index af403e887ab..b82dcf3950c 100644 --- a/tests/runner-e2e/harness-env.ts +++ b/tests/runner-e2e/harness-env.ts @@ -235,6 +235,8 @@ export function assertIsolatedServerEnvironment( temporaryRoot: string; paperclipHome: string; configPath: string; + /** Supplied only after exact owned Docker receipt validation by the supervisor. */ + ownedDatabaseUrl?: string; }, ) { const home = env.PAPERCLIP_HOME; @@ -263,11 +265,12 @@ export function assertIsolatedServerEnvironment( ...AMBIENT_PAPERCLIP_CREDENTIAL_KEYS, ...AMBIENT_EXTERNAL_STATE_KEYS, ]) { - if (env[key]) + if (env[key] && !(key === "DATABASE_URL" && expected.ownedDatabaseUrl && env[key] === expected.ownedDatabaseUrl)) throw new Error( `Paperclip server environment unexpectedly contains ${key}`, ); } + if (expected.ownedDatabaseUrl && env.DATABASE_URL !== expected.ownedDatabaseUrl) throw new Error("Paperclip server database URL differs from the validated owned receipt"); for (const key of GENERATED_SERVER_SECRET_KEYS) { if (!env[key]) throw new Error(`Paperclip server environment is missing ${key}`); diff --git a/tests/runner-e2e/instance-isolation.ts b/tests/runner-e2e/instance-isolation.ts index 0d7e8643228..ab1727c4d9c 100644 --- a/tests/runner-e2e/instance-isolation.ts +++ b/tests/runner-e2e/instance-isolation.ts @@ -28,7 +28,7 @@ export async function assertRunnerDatabaseIsolation( }; }; const docker = dockerReceiptPath ? await readDockerDatabaseReceipt(dockerReceiptPath, temporaryRoot, dockerRun) : undefined; - if (docker ? config.database?.mode !== "postgres" || config.database.connectionString !== docker.connectionString : config.database?.mode !== "embedded-postgres" || !!config.database.connectionString) { + if (docker ? config.database?.mode !== "postgres" || !!config.database.connectionString : config.database?.mode !== "embedded-postgres" || !!config.database.connectionString) { throw new Error("Runner E2E Paperclip instance did not use its explicitly owned database lane"); } if ( diff --git a/tests/runner-e2e/server-config.ts b/tests/runner-e2e/server-config.ts index d85714e33b7..c586ef25628 100644 --- a/tests/runner-e2e/server-config.ts +++ b/tests/runner-e2e/server-config.ts @@ -23,7 +23,7 @@ export async function prepareRunnerE2EServerConfig(input: { const docker = input.dockerReceiptPath ? await readDockerDatabaseReceipt(input.dockerReceiptPath, input.temporaryRoot, input.dockerRun) : undefined; try { const existing = JSON.parse(await readFile(input.configPath, "utf8")); - if (docker ? existing.database?.mode !== "postgres" || existing.database.connectionString !== docker.connectionString : existing.database?.mode !== "embedded-postgres" || existing.database.connectionString) throw new Error("Existing runner database config differs from its explicit owned lane"); + if (docker ? existing.database?.mode !== "postgres" || !!existing.database.connectionString : existing.database?.mode !== "embedded-postgres" || existing.database.connectionString) throw new Error("Existing runner database config differs from its explicit owned lane"); return null; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; @@ -37,9 +37,10 @@ export async function prepareRunnerE2EServerConfig(input: { updatedAt: new Date().toISOString(), source: "onboard", }, + // The owned Docker DSN belongs only to the validated server child env. + // Keep normal onboarding config private but entirely free of DB secrets. database: { mode: docker ? "postgres" : "embedded-postgres", - ...(docker ? { connectionString: docker.connectionString } : {}), embeddedPostgresDataDir: path.join(instanceRoot, "db"), embeddedPostgresPort: databaseReservation?.port ?? 54329, backup: { diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index a60c3a5d6fe..adcf5e168c1 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -108,11 +108,13 @@ const serverEnvironment = buildPaperclipServerEnvironment(installedRelease // to Playwright in this harness, so never create a developer desktop tab. PAPERCLIP_OPEN_ON_LISTEN: "false", ...vaultEnvironment, + ...dockerEnvironment.serverOverrides, }); assertIsolatedServerEnvironment(serverEnvironment, { temporaryRoot, paperclipHome, configPath, + ownedDatabaseUrl: dockerEnvironment.serverOverrides.DATABASE_URL, }); const definedServerEnvironment = Object.fromEntries( Object.entries(serverEnvironment).filter(