diff --git a/tests/runner-e2e/CHAT-FEATURES.md b/tests/runner-e2e/CHAT-FEATURES.md index 84c897df287..2fc40e04a43 100644 --- a/tests/runner-e2e/CHAT-FEATURES.md +++ b/tests/runner-e2e/CHAT-FEATURES.md @@ -181,3 +181,32 @@ The frozen denominator is 244 requests across 35 areas and 191 capabilities. The Domain descriptors declare real setup/actions/independent grading and exact bounds. `setupHumanMessageCount` defaults to zero; genuine earlier human turns and native setup runs remain in the declared total, with persisted comment/run IDs captured before the canonical query. No historical model reply is fabricated. Only `steering.preserve-draft` and `tasks.attachment-brief` may currently declare one `queryAttachments` receipt. The canonical typed query remains unchanged; the production composer appends its exact attachment reference. Before and after send, the harness independently reads the public same-source receipt and actual bytes, verifies SHA-256 and human attribution, and binds it to the real canonical comment. Extra markdown, text changes, foreign receipts and hidden followups fail. Attachment fingerprints pin both production composer source hashes as data, without importing the UI graph. + +### Selected private inputs and published operator sources + +A case declares `requiredCredentials` by standard credential name and, where +needed, `externalInput` by provider and required field names. The launcher resolves +only that selected case into a mode0600 file outside diagnostic artifacts. The +worker exposes `input.privateInputs.credentials`, `.read(name)` and +`.configuration("external-provider")`; boundary callbacks retain their typed +`credentials` map. Extra wizard/external keys are never inserted into the chat +agent's secret references or inherited server environment. The launcher removes +raw input files even when retaining recovery/diagnostic state. The definition +snapshot retains only credential/reference/field names. Offline eligibility calls +`chatCatalogCaseFingerprint(descriptor, normalizedExecution, retainedNames, +retainedInstalledIdentity)` (or the boundary equivalent with its retained names), +so recomputing an identity after cleanup never requires the raw credential file. + +Only `instance-operations.update-instance` and `instance-operations.provision-runtime` +can declare `installedSource: "published-global-npm"`. They require private0600 +`PAPERCLIP_CHAT_INSTALLED_SOURCE_CONFIG` with schemaVersion1, disposable true, +`source: {version, integrity}`, and optional distinct `target: {version, integrity}`. +Provision additionally names `piCredentialEnv: "OPENROUTER_API_KEY"` and declares +that selected private credential. These are exact semver/sha512 pins, never a local +source pack or arbitrary URL. During the governed attempt the launcher obtains +registry metadata and actual public CLI/server tarballs, verifies their integrity, +installs the source into its owned prefix, and records the complete dependency tree. +The supervisor binds the actual CLI PID, version and CLI/server bytes before setup +and after an allowed update restart. The operator receipt stays private0600; +fingerprints bind public artifact hashes and the full owned dependency tree. +Source-local refusal remains the default for every other ordinary chat case. diff --git a/tests/runner-e2e/chat-catalog-contract.ts b/tests/runner-e2e/chat-catalog-contract.ts index 58887ee31fb..3927a8b2bf5 100644 --- a/tests/runner-e2e/chat-catalog-contract.ts +++ b/tests/runner-e2e/chat-catalog-contract.ts @@ -36,6 +36,7 @@ export interface ChatCatalogCase { scenarioId: string; requiredCredentials?: readonly CredentialName[]; externalInput?: ChatExternalInputRequirement; + installedSource?: "published-global-npm"; journeyKind?: "agent-chat"; authentication?: "local-trusted" | "human-session"; setupPolicy?: ChatCatalogSetupPolicy; diff --git a/tests/runner-e2e/chat-catalog-domains/privileged-provision.ts b/tests/runner-e2e/chat-catalog-domains/privileged-provision.ts index 78e7753a09c..f49eb26a4fe 100644 --- a/tests/runner-e2e/chat-catalog-domains/privileged-provision.ts +++ b/tests/runner-e2e/chat-catalog-domains/privileged-provision.ts @@ -33,7 +33,7 @@ export function gradeProvisionRuntime(value: unknown) { const provision = privilegedDefinition("instance-operations.provision-runtime", "privileged-provision", { async setup(ctx) { const target = await ownedInstalledTarget(ctx), envName = target.receipt.piCredentialEnv; - if (!envName || !process.env[envName]) throw new Error("Real Pi qualification requires the explicitly declared private OpenRouter credential environment reference"); + if (!envName || !ctx.input.privateInputs?.read(envName)) throw new Error("Real Pi qualification requires the explicitly declared private OpenRouter credential environment reference"); const vendor = path.resolve(target.server, "../vendor/paperclip-runner"), assets = path.join(vendor, "provider-assets/pi", `${process.platform}-${process.arch}`); let exists = false; try { exists = (await stat(assets)).isDirectory(); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; } if (exists) throw new Error("Canonical provision context requires this owned release to lack Pi assets; existing assets are never removed by the fixture"); @@ -50,7 +50,7 @@ const provision = privilegedDefinition("instance-operations.provision-runtime", state.assetPathOwned = closure.startsWith(target.root + path.sep); if (!state.assetPathOwned) throw new Error("Provisioned Pi closure escapes the intended installation"); state.nativeClosureSha256 = sha(await readFile(closure)); // Credentials remain exclusively in the public encrypted secret boundary. - const token = process.env[target.receipt.piCredentialEnv!]!; ctx.input.privateSecrets?.push(token); + const token = ctx.input.privateInputs?.read(target.receipt.piCredentialEnv!); if (!token) throw new Error("Missing selected private Pi credential"); ctx.input.privateSecrets?.push(token); const company = ctx.input.fixtures.company.id; const secret = await ctx.input.api.postSensitive<{ id: string }>(`/api/companies/${company}/secrets`, { name: "Owned Pi qualification", key: "OPENROUTER_API_KEY", value: token }); const profile = extendedHarnessProfiles.find(p => p.id === "runner-acpx-pi")!; @@ -84,6 +84,8 @@ const provision = privilegedDefinition("instance-operations.provision-runtime", }, grade: gradeProvisionRuntime, async verifyResult(ctx) { await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); }, }); provision.expectedRunCount = 3; provision.expectedHumanMessageCount = 2; provision.attemptTimeoutMs = 1_440_000; -provision.coverageGaps = ["installed-source-activation-and-artifact-fingerprint-lane"]; +provision.coverageGaps = []; +provision.installedSource = "published-global-npm"; +provision.requiredCredentials = ["OPENROUTER_API_KEY"]; provision.sourceFiles = [...provision.sourceFiles, "chat-catalog-domains/privileged-installed.ts", "../../cli/src/commands/runtime.ts", "../../packages/paperclip-runner/scripts/provision-pi.mjs"]; export const privilegedProvisionCases = [provision]; diff --git a/tests/runner-e2e/chat-catalog-domains/privileged-update.ts b/tests/runner-e2e/chat-catalog-domains/privileged-update.ts index 31b847b68d9..8ed3d2c4431 100644 --- a/tests/runner-e2e/chat-catalog-domains/privileged-update.ts +++ b/tests/runner-e2e/chat-catalog-domains/privileged-update.ts @@ -54,7 +54,8 @@ const update = privilegedDefinition("instance-operations.update-instance", "priv await sendChatMessage(ctx.input.page, message); state.updateReport = await companionPhase(ctx, initial.runs.map(run => run.id), message); }, grade: gradeInstalledUpdate, async verifyResult(ctx) { await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); }, }); -update.coverageGaps = ["installed-source-activation-and-artifact-fingerprint-lane"]; +update.coverageGaps = []; +update.installedSource = "published-global-npm"; update.expectedRunCount = 2; update.expectedHumanMessageCount = 2; update.attemptTimeoutMs = 720_000; update.sourceFiles = [...update.sourceFiles, "chat-catalog-domains/privileged-installed.ts", "../../cli/src/commands/update.ts", "installed-release.ts"]; export const privilegedUpdateCases = [update]; diff --git a/tests/runner-e2e/chat-catalog-dynamic-resolutions.json b/tests/runner-e2e/chat-catalog-dynamic-resolutions.json index 3455a3c281d..a94eb9ecf80 100644 --- a/tests/runner-e2e/chat-catalog-dynamic-resolutions.json +++ b/tests/runner-e2e/chat-catalog-dynamic-resolutions.json @@ -21,8 +21,8 @@ ] }, "tests/runner-e2e/installed-cli.ts": { - "sourceSha256": "414a75423585d8120896e07a6b86636446e6ee88990af2726261b3a17fd8bba6", - "resolution": "inactive-source-local-chat-lane", + "sourceSha256": "6b421b69db24985d7310ac3a48bbc93e207ace22f1fbe7f42732633ed93e6761", + "resolution": "inactive-installed-startup-only-probe", "dependencies": [] }, "tests/runner-e2e/product-journeys/launch.ts": { diff --git a/tests/runner-e2e/chat-catalog-fingerprint.ts b/tests/runner-e2e/chat-catalog-fingerprint.ts index bfeb0c75582..f5688af7cfc 100644 --- a/tests/runner-e2e/chat-catalog-fingerprint.ts +++ b/tests/runner-e2e/chat-catalog-fingerprint.ts @@ -1,3 +1,4 @@ +import { installedChatFingerprintIdentity, installedChatScenarioIds } from "./chat-installed-lane.js"; import { chatPrivateInputRequirements, chatPrivateInputNameIdentity } from "./chat-private-inputs.js"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; @@ -74,21 +75,28 @@ export function fingerprintChatDependencyClosure(input: { } export function assertChatCatalogFingerprintLane(selected: unknown, env: NodeJS.ProcessEnv = process.env, args: readonly string[] = process.argv) { - const execution = selected as { suiteId?: string; flow?: string; environment?: string }; + const execution = selected as { suiteId?: string; flow?: string; environment?: string; executionId?: string; scenarioId?: string; installedSource?: string; profile?: { id?: string } }; + const installed = execution.installedSource === "published-global-npm" && (installedChatScenarioIds as readonly string[]).includes(String(execution.scenarioId)); const installedKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_STARTUP_ONLY"]; const forbiddenArguments = ["--target-config", "--github-review-config", "--connection-config", "--installed-startup-only", "product-journeys"]; - if (execution.suiteId !== "chat-features" || execution.flow !== "agent_chat" || execution.environment !== "local" - || installedKeys.some(key => env[key] && env[key] !== "0") || args.some(argument => forbiddenArguments.includes(argument))) + if (installed && execution.profile?.id !== "runner-codex-subscription" || execution.suiteId !== "chat-features" || execution.flow !== "agent_chat" || execution.environment !== "local" + || !installed && installedKeys.some(key => env[key] && env[key] !== "0") || args.some(argument => forbiddenArguments.includes(argument))) throw new Error("Reviewed dynamic resolutions require source-local Agent Chat without external dispatch overrides"); return { suiteId: execution.suiteId, flow: execution.flow, environment: execution.environment, - installedLane: false, externalJourneyDispatch: false }; + installedLane: installed, externalJourneyDispatch: false }; } -export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, selectedExecution: unknown, retainedPrivateInputNames?: ReturnType) { +export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, selectedExecution: unknown, retainedPrivateInputNames?: ReturnType, retainedInstalledIdentity?: Omit, "observedProcess">) { const root = fileURLToPath(new URL("../../", import.meta.url)); const privateInputNameIdentity = retainedPrivateInputNames === undefined ? chatPrivateInputNameIdentity((selectedExecution as { executionId: string }).executionId) : retainedPrivateInputNames; - const lane = assertChatCatalogFingerprintLane(selectedExecution); + const lane = assertChatCatalogFingerprintLane({ ...(selectedExecution as Record), scenarioId: descriptor.scenarioId, installedSource: descriptor.installedSource }); + const installedObservation = descriptor.installedSource && retainedInstalledIdentity === undefined + ? installedChatFingerprintIdentity(process.env, (selectedExecution as { executionId: string }).executionId) : undefined; + const installedIdentity = descriptor.installedSource ? retainedInstalledIdentity ?? (installedObservation && { + kind: installedObservation.kind, sourceVersion: installedObservation.sourceVersion, cliSha256: installedObservation.cliSha256, + serverSha256: installedObservation.serverSha256, treeSha256: installedObservation.treeSha256, publishedArtifacts: installedObservation.publishedArtifacts, + }) : null; const reviewedDynamicResolutions = JSON.parse(readFileSync(new URL("./chat-catalog-dynamic-resolutions.json", import.meta.url), "utf8")); const selectedModule = `tests/runner-e2e/${descriptor.modulePath.slice(2)}`; const scenario = JSON.parse(readFileSync(new URL("./chat-campaign-manifest.json", import.meta.url), "utf8")).scenarios @@ -102,13 +110,13 @@ export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, se entries: [selectedModule, "tests/runner-e2e/chat-catalog-flow.ts", "tests/runner-e2e/chat-catalog-fingerprint.ts", "tests/runner-e2e/chat-catalog-dynamic-resolutions.json", "tests/runner-e2e/chat-feature-flow.ts", "tests/runner-e2e/runner.spec.ts", "tests/runner-e2e/launch.ts", "tests/runner-e2e/chat-scenarios/defaults.yaml", ...descriptor.sourceFiles.map(file => `tests/runner-e2e/${file}`)], - selected: { descriptor, selectedExecution, lane, attachmentFormatter, privateInputRequirements: chatPrivateInputRequirements(descriptor), privateInputNameIdentity, scenario: { id, capabilities, context, query, expected_outcome } }, + selected: { descriptor, selectedExecution, lane, attachmentFormatter, privateInputRequirements: chatPrivateInputRequirements(descriptor), privateInputNameIdentity, installedIdentity, scenario: { id, capabilities, context, query, expected_outcome } }, reviewedDynamicResolutions, selectedDispatchFile: "tests/runner-e2e/chat-catalog-registry.ts", normalizedMetadata: { "tests/runner-e2e/chat-catalog-descriptors.json": descriptor, "tests/runner-e2e/chat-campaign-manifest.json": { id, capabilities, context, query, expected_outcome } }, }); - return { ...result, attachmentFormatter, privateInputNameIdentity, privateInputRequirements: chatPrivateInputRequirements(descriptor) }; + return { ...result, attachmentFormatter, privateInputNameIdentity, installedIdentity, installedSourceProcess: installedObservation?.observedProcess, privateInputRequirements: chatPrivateInputRequirements(descriptor) }; } export function normalizedChatCatalogExecution(execution: MatrixExecution) { diff --git a/tests/runner-e2e/chat-catalog-registry.ts b/tests/runner-e2e/chat-catalog-registry.ts index e9a123252b3..9c6b3eb452f 100644 --- a/tests/runner-e2e/chat-catalog-registry.ts +++ b/tests/runner-e2e/chat-catalog-registry.ts @@ -1,3 +1,4 @@ +import { installedChatScenarioIds } from "./chat-installed-lane.js"; import { existsSync, readFileSync } from "node:fs"; import { validChatCatalogHumanCounts } from "./chat-catalog-contract.js"; import type { ChatCatalogCase } from "./chat-catalog-contract.js"; @@ -21,6 +22,8 @@ export function findChatCatalogCase(scenarioId: string): ChatCatalogDescriptor | if (matches.length > 1) throw new Error(`Duplicate chat implementation: ${scenarioId}`); const descriptor = matches[0]; if (!descriptor) return; + if (descriptor.installedSource && (descriptor.installedSource !== "published-global-npm" || !(installedChatScenarioIds as readonly string[]).includes(scenarioId))) + throw new Error("Installed ordinary Chat is allowlisted only for the two published-source operator scenarios"); if (!known.has(scenarioId) || !descriptor.oracleVersion || !descriptor.sourceFiles.length || !/^\.\/chat-catalog-domains\/[a-z0-9-]+\.js$/.test(descriptor.modulePath) || !descriptor.exportName || (descriptor.journeyKind !== "browser-boundary" && !validChatCatalogHumanCounts(descriptor)) || descriptor.expectedRunCount < 0 || descriptor.turnTimeoutMs <= 0 || descriptor.attemptTimeoutMs <= 0 @@ -45,7 +48,7 @@ export async function loadChatCatalogImplementation( const selected = implementations.filter((row: ChatCatalogCase) => row.scenarioId === descriptor.scenarioId); if (selected.length !== 1) throw new Error(`Missing or duplicate runtime case: ${descriptor.scenarioId}`); const implementation = selected[0] as ChatCatalogCase; - for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments", "requiredCredentials", "externalInput"] as const) { + for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments", "requiredCredentials", "externalInput", "installedSource"] as const) { if (JSON.stringify(implementation[key]) !== JSON.stringify(descriptor[key])) throw new Error(`Runtime descriptor drift: ${descriptor.scenarioId}.${key}`); } if ((descriptor.setupPolicy?.funding === "preserve-scenario" || descriptor.setupPolicy?.readiness === "preserve-scenario") @@ -72,7 +75,7 @@ export async function loadIdentityBoundaryImplementation(descriptor: ChatCatalog const selected = Array.isArray(rows) ? rows.filter(row => row.scenarioId === descriptor.scenarioId) : []; if (selected.length !== 1) throw new Error("Missing or duplicate browser boundary implementation"); const implementation = selected[0] as IdentityBoundaryCase; - for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "boundaryTarget", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments", "requiredCredentials", "externalInput"] as const) { + for (const key of ["sourceFiles", "oracleVersion", "expectedRunCount", "minimumRunCount", "expectedHumanMessageCount", "setupHumanMessageCount", "turnTimeoutMs", "attemptTimeoutMs", "coverageGaps", "journeyKind", "boundaryTarget", "authentication", "setupPolicy", "runPolicy", "queryOutcome", "queryBindings", "queryAttachments", "requiredCredentials", "externalInput", "installedSource"] as const) { if (JSON.stringify(implementation[key]) !== JSON.stringify(descriptor[key])) throw new Error(`Runtime boundary descriptor drift: ${descriptor.scenarioId}.${key}`); } if ([implementation.run, implementation.grade, implementation.cleanup].some(value => typeof value !== "function") || descriptor.queryBindings?.length || descriptor.queryAttachments?.length diff --git a/tests/runner-e2e/chat-installed-lane.test.ts b/tests/runner-e2e/chat-installed-lane.test.ts new file mode 100644 index 00000000000..edd4605d2a1 --- /dev/null +++ b/tests/runner-e2e/chat-installed-lane.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, expect, it } from "vitest"; +import { runOwnedInstalledPreparation, admitsInstalledChat, installedChatTreeIdentity, refreshInstalledChatPins, validateChatInstallConfiguration, CHAT_INSTALL_PLAN } from "./chat-installed-lane.js"; +import type { MatrixExecution } from "./types.js"; +const roots: string[] = []; const sha = (file: string) => createHash("sha256").update(readFileSync(file)).digest("hex"); +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function root() { const folder = mkdtempSync(path.join(tmpdir(), "chat-installed-")); roots.push(folder); return folder; } +it("admits only exact published pins and the two selected source-local Codex operator cases", () => { + const pin = { version: "1.2.3", integrity: "sha512-YWJj" }; + expect(validateChatInstallConfiguration({ schemaVersion: 1, disposable: true, source: pin }).source).toEqual(pin); + for (const source of [{ ...pin, version: "file:/source-pack" }, { ...pin, integrity: "unverified" }]) + expect(() => validateChatInstallConfiguration({ schemaVersion: 1, disposable: true, source })).toThrow(); + const execution = { suite: { id: "chat-features", manualOnly: true }, task: { id: "instance-operations.update-instance", flow: "agent_chat" }, profile: { id: "runner-codex-subscription" }, environment: { id: "local" } } as MatrixExecution; + expect(admitsInstalledChat(execution)).toBe(true); + expect(admitsInstalledChat({ ...execution, task: { ...execution.task, id: "tasks.create-backlog" } })).toBe(false); + expect(admitsInstalledChat({ ...execution, environment: { ...execution.environment, id: "daytona" } })).toBe(false); +}); +it("hashes the complete actual dependency tree and rejects external dependency links", () => { + const folder = root(), prefix = path.join(folder, "prefix"); mkdirSync(prefix); writeFileSync(path.join(prefix, "dependency.js"), "original"); + const first = installedChatTreeIdentity(prefix); writeFileSync(path.join(prefix, "dependency.js"), "tampered"); + expect(installedChatTreeIdentity(prefix).treeSha256).not.toBe(first.treeSha256); + writeFileSync(path.join(folder, "outside.js"), "outside"); symlinkSync(path.join(folder, "outside.js"), path.join(prefix, "escaping.js")); + expect(() => installedChatTreeIdentity(prefix)).toThrow("escapes"); +}); +it("compares real source bytes to the retained public-artifact pins before every restart", () => { + const folder = root(), prefix = path.join(folder, "prefix"), cliRoot = path.join(prefix, "lib/node_modules/paperclipai"), serverRoot = path.join(cliRoot, "node_modules/@paperclipai/server"); + mkdirSync(path.join(cliRoot, "dist"), { recursive: true }); mkdirSync(path.join(serverRoot, "dist"), { recursive: true }); + const cli = path.join(cliRoot, "dist/index.js"), server = path.join(serverRoot, "dist/index.js"); + writeFileSync(cli, "export const cli=true;"); writeFileSync(server, "export const server=true;"); + writeFileSync(path.join(cliRoot, "package.json"), JSON.stringify({ name: "paperclipai", version: "1.2.3" })); + writeFileSync(path.join(serverRoot, "package.json"), JSON.stringify({ name: "@paperclipai/server", version: "1.2.3" })); + const planFile = path.join(folder, "plan.json"); + writeFileSync(planFile, JSON.stringify({ schemaVersion: 1, executionId: "selected", temporaryRoot: folder, prefix, cli, serverRoot, sourceVersion: "1.2.3", + artifacts: [{ package: "paperclipai", version: "1.2.3", entrySha256: sha(cli) }, { package: "@paperclipai/server", version: "1.2.3", entrySha256: sha(server) }] }), { mode: 0o600 }); + const env = { [CHAT_INSTALL_PLAN]: planFile, PAPERCLIP_RUNNER_E2E_TEMP_ROOT: folder }; + expect(refreshInstalledChatPins(env, "selected").version).toBe("1.2.3"); + expect(() => refreshInstalledChatPins(env, "other")).toThrow("another target"); + writeFileSync(server, "export const server='tampered';"); + expect(() => refreshInstalledChatPins(env, "selected")).toThrow("independently downloaded"); +}); + +it("retires the exact timed-out preparation child while retaining only the owned partial prefix for disposition", async () => { + const folder = root(), partial = path.join(folder, "partial-install.json"); + const source = `const child=require("node:child_process").spawn(process.execPath,["-e","setInterval(()=>{},1000)"],{stdio:"ignore"});require("node:fs").writeFileSync(${JSON.stringify(partial)},JSON.stringify({pid:process.pid,descendantPid:child.pid}));setInterval(()=>{},1000);`; + await expect(runOwnedInstalledPreparation(process.execPath, ["-e", source], { cwd: folder, env: { PATH: process.env.PATH }, timeout: 500 })).rejects.toThrow("preparation failed"); + const { pid, descendantPid } = JSON.parse(readFileSync(partial, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(); + expect(() => process.kill(descendantPid, 0)).toThrow(); + rmSync(folder, { recursive: true, force: true }); +}); diff --git a/tests/runner-e2e/chat-installed-lane.ts b/tests/runner-e2e/chat-installed-lane.ts new file mode 100644 index 00000000000..47756cd10ba --- /dev/null +++ b/tests/runner-e2e/chat-installed-lane.ts @@ -0,0 +1,156 @@ +import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js"; +import { spawn, execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readdirSync, realpathSync, readlinkSync, writeFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { MatrixExecution } from "./types.js"; +export class ChatInstalledCleanupError extends Error {} +export async function runOwnedInstalledPreparation(name: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv; timeout: number }) { + const child = spawn(name, args, { cwd: options.cwd, env: options.env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); + const owner = createProcessTreeOwner(child); + let timer: ReturnType | undefined; let stdout = "", bytes = 0; + const completed = new Promise<{ ok: boolean }>(resolve => { + timer = setTimeout(() => resolve({ ok: false }), options.timeout); + child.on("error", () => resolve({ ok: false })); child.on("close", code => resolve({ ok: code === 0 })); + child.stdout.on("data", chunk => { bytes += chunk.length; if (bytes > 2_097_152) resolve({ ok: false }); else stdout += chunk.toString("utf8"); }); + child.stderr.on("data", chunk => { bytes += chunk.length; if (bytes > 2_097_152) resolve({ ok: false }); }); + }); + try { await owner.observe(); if (!(await completed).ok) throw new Error("Preparation failed"); return stdout; } + catch { throw new Error("Published installed source preparation failed; raw output withheld"); } + finally { + if (timer) clearTimeout(timer); + try { await stopOwnedProcessTree(child, owner, 5000, 1000); } + catch { throw new ChatInstalledCleanupError("Installed preparation child ownership cleanup is unconfirmed"); } + finally { owner.stopObserving(); } + } +} + +export const CHAT_INSTALL_CONFIG = "PAPERCLIP_CHAT_INSTALLED_SOURCE_CONFIG"; +export const CHAT_INSTALL_PLAN = "PAPERCLIP_CHAT_INSTALLED_PLAN"; +export const CHAT_INSTALL_RECEIPT = "PAPERCLIP_CHAT_OPERATOR_INSTALL_RECEIPT"; +export const installedChatScenarioIds = ["instance-operations.update-instance", "instance-operations.provision-runtime"] as const; +interface PublishedPin { version: string; integrity: string } +export interface ChatInstallConfiguration { schemaVersion: 1; disposable: true; source: PublishedPin; target?: PublishedPin; piCredentialEnv?: "OPENROUTER_API_KEY" } +export interface PublishedArtifact { package: "paperclipai" | "@paperclipai/server"; version: string; integrity: string; archiveSha512: string; entrySha256: string } +interface InstallPlan { schemaVersion: 1; executionId: string; temporaryRoot: string; prefix: string; cli: string; serverRoot: string; + sourceVersion: string; artifacts: PublishedArtifact[]; targetVersion?: string; targetIntegrity?: string; piCredentialEnv?: string } +const hash = (bytes: Buffer | string, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex"); +function privateJson(filename: string, root?: string) { + const info = lstatSync(filename); + if (!info.isFile() || info.isSymbolicLink() || info.mode & 0o077 || info.size > 1_048_576 || root && !realpathSync(filename).startsWith(realpathSync(root) + path.sep)) + throw new Error("Installed chat configuration/receipt must be a bounded owned private0600 file"); + try { return JSON.parse(readFileSync(filename, "utf8")); } catch { throw new Error("Invalid installed chat private metadata; contents withheld"); } +} +export function validateChatInstallConfiguration(value: unknown): ChatInstallConfiguration { + const c = value as ChatInstallConfiguration; + const pin = (p: PublishedPin | undefined) => !!p && /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(p.version) && /^sha512-[A-Za-z0-9+/]+=*$/.test(p.integrity); + if (!c || c.schemaVersion !== 1 || c.disposable !== true || !pin(c.source) || c.target && !pin(c.target) + || c.target?.version === c.source.version || c.piCredentialEnv && c.piCredentialEnv !== "OPENROUTER_API_KEY") + throw new Error("Installed chat requires exact public npm version/integrity pins and disposable ownership"); + return c; +} +/** Complete owned npm-prefix closure, including dependency and executable bytes. */ +export function installedChatTreeIdentity(prefix: string) { + const root = realpathSync(prefix), entries: Array<{ file: string; sha256?: string; link?: string }> = []; + let bytes = 0; + function visit(directory: string) { + for (const name of readdirSync(directory).sort()) { + const filename = path.join(directory, name), info = lstatSync(filename); + if (entries.length >= 200000 || bytes + (info.isFile() ? info.size : 0) > 2_147_483_648) throw new Error("Installed source exceeds bounded dependency inventory"); + if (info.isSymbolicLink()) { + if (!realpathSync(filename).startsWith(root + path.sep)) throw new Error("Installed dependency escapes owned npm prefix"); + entries.push({ file: path.relative(root, filename), link: readlinkSync(filename) }); + } else if (info.isDirectory()) visit(filename); + else if (info.isFile()) { bytes += info.size; entries.push({ file: path.relative(root, filename), sha256: hash(readFileSync(filename)) }); } + else throw new Error("Installed closure contains nonregular filesystem data"); + } + } + visit(root); + return { treeSha256: hash(JSON.stringify(entries)), fileCount: entries.length, bytes }; +} +export function admitsInstalledChat(execution: Pick) { + return execution.suite.id === "chat-features" && execution.suite.manualOnly && execution.task.flow === "agent_chat" + && execution.profile.id === "runner-codex-subscription" && execution.environment.id === "local" + && (installedChatScenarioIds as readonly string[]).includes(execution.task.id); +} +/** Runs only inside an explicitly selected governed attempt; never during discovery. */ +export async function stagePublishedChatInstall(input: { execution: MatrixExecution; root: string; env: NodeJS.ProcessEnv }): Promise { + if (!admitsInstalledChat(input.execution)) throw new Error("Published ordinary-chat installation is restricted to update/provision scenarios"); + const configRef = input.env[CHAT_INSTALL_CONFIG]; if (!configRef) throw new Error("Missing private installed source configuration with public npm integrity pins"); + const config = validateChatInstallConfiguration(privateJson(configRef)); + if (input.execution.task.id.endsWith("update-instance") && !config.target) throw new Error("Update requires a distinct exact published target pin"); + if (input.execution.task.id.endsWith("provision-runtime") && !config.piCredentialEnv) throw new Error("Provision requires the declared selected OpenRouter credential name"); + const root = realpathSync(input.root), prefix = path.join(root, "published-prefix"), artifactsDir = path.join(root, "published-artifacts"); + await Promise.all([mkdir(prefix, { mode: 0o700 }), mkdir(artifactsDir, { mode: 0o700 })]); + const env: NodeJS.ProcessEnv = { PATH: input.env.PATH, HOME: root, NPM_CONFIG_CACHE: path.join(root, ".npm"), NPM_CONFIG_USERCONFIG: "/dev/null", NPM_CONFIG_PREFIX: prefix, NO_COLOR: "1" }; + const artifacts: PublishedArtifact[] = []; + async function command(name: string, args: string[], timeout = 120_000) { + return runOwnedInstalledPreparation(name, args, { cwd: root, env, timeout }); + } + for (const pin of [config.source, ...(config.target ? [config.target] : [])]) { + for (const name of ["paperclipai", "@paperclipai/server"] as const) { + const dist = JSON.parse(await command("npm", ["view", `${name}@${pin.version}`, "dist", "--json", "--registry=https://registry.npmjs.org"], 30000)); + if (typeof dist.integrity !== "string" || !/^sha512-[A-Za-z0-9+/]+=*$/.test(dist.integrity) || !String(dist.tarball).startsWith("https://registry.npmjs.org/") + || name === "paperclipai" && dist.integrity !== pin.integrity) throw new Error("Published source metadata differs from the exact public registry pin"); + const packed = JSON.parse(await command("npm", ["pack", `${name}@${pin.version}`, "--ignore-scripts", "--json", "--pack-destination", artifactsDir, "--registry=https://registry.npmjs.org"])); + const archive = realpathSync(path.join(artifactsDir, packed[0]?.filename ?? "missing")); + if (!archive.startsWith(artifactsDir + path.sep)) throw new Error("Published tarball escaped its owned artifact folder"); + const bytes = readFileSync(archive), actualIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if (actualIntegrity !== dist.integrity) throw new Error("Downloaded public tarball integrity differs from registry metadata"); + const entry = await command("tar", ["-xOf", archive, "package/dist/index.js"]); + artifacts.push({ package: name, version: pin.version, integrity: dist.integrity, archiveSha512: hash(bytes, "sha512"), entrySha256: hash(entry) }); + } + } + await command("npm", ["install", "--global", "--prefix", prefix, `paperclipai@${config.source.version}`, "--registry=https://registry.npmjs.org"], 600000); + const cli = realpathSync(path.join(prefix, "lib/node_modules/paperclipai/dist/index.js")); + // Resolve using the installed CLI's own public dependency graph, never the source checkout. + const { findPackageJSON } = await import("node:module"); const { pathToFileURL } = await import("node:url"); + const manifest = findPackageJSON("@paperclipai/server", pathToFileURL(cli)); + if (!manifest) throw new Error("Published CLI has no actual installed server dependency"); + const serverRoot = path.dirname(realpathSync(manifest)); + const plan: InstallPlan = { schemaVersion: 1, executionId: input.execution.id, temporaryRoot: root, prefix, cli, serverRoot, + sourceVersion: config.source.version, artifacts, targetVersion: config.target?.version, targetIntegrity: config.target?.integrity, piCredentialEnv: config.piCredentialEnv }; + const planFile = path.join(root, "published-chat-plan.json"); writeFileSync(planFile, JSON.stringify(plan), { mode: 0o600, flag: "wx" }); + const overrides: NodeJS.ProcessEnv = { [CHAT_INSTALL_PLAN]: planFile, [CHAT_INSTALL_RECEIPT]: path.join(root, "published-chat-source.json"), + PAPERCLIP_RUNNER_E2E_INSTALLED_CLI: cli, PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT: serverRoot }; + refreshInstalledChatPins({ ...input.env, ...overrides, PAPERCLIP_RUNNER_E2E_TEMP_ROOT: root }, input.execution.id, overrides); + return overrides; +} +export function refreshInstalledChatPins(env: NodeJS.ProcessEnv, executionId: string, destination: NodeJS.ProcessEnv = env) { + const reference = env[CHAT_INSTALL_PLAN], root = env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT; + if (!reference || !root) throw new Error("Installed chat has no owned publication plan"); + const plan = privateJson(reference, root) as InstallPlan; + if (plan.schemaVersion !== 1 || plan.executionId !== executionId || realpathSync(plan.temporaryRoot) !== realpathSync(root) + || ![plan.cli, plan.serverRoot, plan.prefix].every(filename => realpathSync(filename).startsWith(realpathSync(root) + path.sep))) throw new Error("Installed source plan belongs to another target"); + const server = path.join(plan.serverRoot, "dist/index.js"), cliManifest = JSON.parse(readFileSync(path.resolve(plan.cli, "../../package.json"), "utf8")), serverManifest = JSON.parse(readFileSync(path.join(plan.serverRoot, "package.json"), "utf8")); + const version = cliManifest.version; + if (cliManifest.name !== "paperclipai" || serverManifest.name !== "@paperclipai/server" || version !== serverManifest.version + || ![plan.sourceVersion, plan.targetVersion].filter(Boolean).includes(version)) throw new Error("Installed source version is not a declared publication transition"); + const cliSha256 = hash(readFileSync(plan.cli)), serverSha256 = hash(readFileSync(server)); + if (!plan.artifacts.some(a => a.package === "paperclipai" && a.version === version && a.entrySha256 === cliSha256) + || !plan.artifacts.some(a => a.package === "@paperclipai/server" && a.version === version && a.entrySha256 === serverSha256)) throw new Error("Actual installed CLI/server bytes differ from their independently downloaded published tarballs"); + destination.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256 = cliSha256; + destination.PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256 = serverSha256; + return { plan, server, version, cliSha256, serverSha256, ...installedChatTreeIdentity(plan.prefix) }; +} +export function publishInstalledChatReceipt(env: NodeJS.ProcessEnv, executionId: string, serverPid: number) { + const source = refreshInstalledChatPins(env, executionId); + const reference = env[CHAT_INSTALL_RECEIPT]; if (!reference || !Number.isSafeInteger(serverPid) || serverPid < 2) throw new Error("Installed source process identity missing"); + if (!realpathSync(path.dirname(reference)).startsWith(realpathSync(source.plan.temporaryRoot))) throw new Error("Installed source receipt escapes its owned attempt"); + const receipt = { schemaVersion: 1, disposable: true, cli: source.plan.cli, server: source.server, cliSha256: source.cliSha256, + serverSha256: source.serverSha256, installationTreeSha256: source.treeSha256, serverPid, version: source.version, + targetVersion: source.plan.targetVersion, targetIntegrity: source.plan.targetIntegrity, piCredentialEnv: source.plan.piCredentialEnv, + publishedArtifacts: source.plan.artifacts, sourceVersion: source.plan.sourceVersion }; + writeFileSync(reference, JSON.stringify(receipt), { mode: 0o600 }); + return receipt; +} +export function installedChatFingerprintIdentity(env: NodeJS.ProcessEnv, executionId: string) { + const source = refreshInstalledChatPins(env, executionId); + const receipt = privateJson(env[CHAT_INSTALL_RECEIPT]!, env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT) as { serverPid?: number }; + if (!receipt.serverPid) throw new Error("Actual installed source process receipt is missing"); + const owner = execFileSync("ps", ["-p", String(receipt.serverPid), "-o", "uid=", "-o", "command="], { encoding: "utf8", timeout: 5000 }).trim(); + if (!new RegExp(`^${process.getuid?.()}\\s`).test(owner) || !owner.split(/\s+/).includes(source.plan.cli)) throw new Error("Installed receipt does not identify this user's actual CLI source process"); + return { kind: "published-global-npm", sourceVersion: source.version, cliSha256: source.cliSha256, serverSha256: source.serverSha256, + treeSha256: source.treeSha256, publishedArtifacts: source.plan.artifacts, observedProcess: { pid: receipt.serverPid, uid: process.getuid?.() } }; +} diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 2fc4aa5c797..fdffb04051a 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -1,3 +1,4 @@ +import { admitsInstalledChat } from "./chat-installed-lane.js"; /** Optional published-install lane. No production hook or admission override. */ import { constants } from "node:fs"; import { lstat, mkdir, mkdtemp, open, realpath, rm } from "node:fs/promises"; @@ -45,6 +46,7 @@ export function usesInstalledCli(env: NodeJS.ProcessEnv): boolean { export function assertInstalledCliSelection(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]): void { if (!usesInstalledCli(env)) return; if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => !( + admitsInstalledChat(e) || (e.profile.id === "runner-acpx-pi" && e.profile.qualificationCandidate === "pi" && e.suite.manualOnly && suites.has(e.suite.id)) || (e.profile.id === "runner-codex" && e.profile.provider === "codex" && e.environment.id === "local" && installedCodexCells.has(e.id)) || (e.profile.id === "runner-acpx-copilot" && e.profile.qualificationCandidate === "copilot" && e.suite.manualOnly && installedCopilotCells.has(e.id)) diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 3a9985af4f8..814e311f7e5 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -1,3 +1,4 @@ +import { stagePublishedChatInstall, ChatInstalledCleanupError } from "./chat-installed-lane.js"; import { stageChatPrivateInputs, CHAT_PRIVATE_INPUTS_ENV } from "./chat-private-inputs.js"; import { findChatCatalogCase } from "./chat-catalog-registry.js"; import { recoverWorkerTiming } from "./worker-timing.js"; @@ -497,7 +498,9 @@ async function runAttempt(input: { ); } assertInstalledCliSelection(process.env, executions); - const installedCli = await verifyInstalledCli(process.env, executions); + let installedCli = await verifyInstalledCli(process.env, executions); + const selectedDescriptor = execution.suite.id === "chat-features" ? findChatCatalogCase(execution.task.id) : undefined; + let sourceEnvironment = { ...process.env }; const installedPlugin = await verifyInstalledDaytonaPlugin(process.env, executions); const startedAtMs = Date.now(); const sharedMemoryBaseline = snapshotDarwinSharedMemory(); @@ -528,6 +531,12 @@ async function runAttempt(input: { mkdir(workspace, { recursive: true }), mkdir(privateDir, { recursive: true }), ]); + if (selectedDescriptor?.installedSource === "published-global-npm") { + if (executions.length !== 1) throw new Error("Published chat source requires one explicit operation"); + try { sourceEnvironment = { ...sourceEnvironment, ...await stagePublishedChatInstall({ execution, root: temporaryRoot, env: sourceEnvironment }) }; } + catch (error) { if (error instanceof ChatInstalledCleanupError) processCleanupFailed = true; throw error; } + installedCli = await verifyInstalledCli(sourceEnvironment, executions); + } const providerPath = await prepareProviderPath( temporaryRoot, process.env.PATH, @@ -551,7 +560,6 @@ async function runAttempt(input: { }; const credentials = normalizedSecrets(Object.values(credentialSources)); attemptSecrets = credentials; - const selectedDescriptor = execution.suite.id === "chat-features" ? findChatCatalogCase(execution.task.id) : undefined; const needsPrivateInputs = selectedDescriptor && ((selectedDescriptor.requiredCredentials?.length ?? 0) > 0 || selectedDescriptor.externalInput); if (needsPrivateInputs && executions.length !== 1) throw new Error("Private case inputs require one explicitly selected execution"); if (needsPrivateInputs) await mkdir(selectedPrivateInputsDirectory, { mode: 0o700 }); @@ -563,7 +571,7 @@ async function runAttempt(input: { repositoryRoot, ); const childEnv: NodeJS.ProcessEnv = { - ...buildRunnerE2EProcessEnvironment(process.env, executions), + ...buildRunnerE2EProcessEnvironment(sourceEnvironment, executions), [CHAT_PRIVATE_INPUTS_ENV]: privateInputs?.filename, PATH: providerPath, PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: JSON.stringify( diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 451ed91e034..c589cdb63b7 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -1,3 +1,4 @@ +import { CHAT_INSTALL_PLAN, refreshInstalledChatPins, publishInstalledChatReceipt } from "./chat-installed-lane.js"; import { parseFinalizationCrashBinding, readFinalizationCrashBoundary, type FinalizationCrashBinding } from "./finalization-crash-boundary.js"; import { parseRestartRunnerIdentity, type RestartRunnerIdentity } from "./process-tree-owner.js"; import { recoverInterruptedFeedback } from "./feedback-recovery.js"; @@ -182,6 +183,7 @@ async function startServer() { if (shutdownRequested()) { throw new Error("Refusing to start Paperclip after wrapper shutdown"); } + if (process.env[CHAT_INSTALL_PLAN]) refreshInstalledChatPins(process.env, selectedExecutions[0]!.id); // Recheck bytes and dependency resolution on every controller restart. const installed = await verifyInstalledCli(process.env, selectedExecutions); const candidate = spawn( @@ -197,6 +199,7 @@ async function startServer() { }, ); child = candidate; + if (process.env[CHAT_INSTALL_PLAN] && candidate.pid) publishInstalledChatReceipt(process.env, selectedExecutions[0]!.id, candidate.pid); candidate.stdout?.on("data", (chunk) => { process.stdout.write(chunk);