mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
test(evals): qualify owned published sources for two operator chat cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3344de11b3
commit
8f67a700a4
12 files changed
+285
-19
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
@@ -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];
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<typeof chatPrivateInputNameIdentity>) {
|
||||
export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, selectedExecution: unknown, retainedPrivateInputNames?: ReturnType<typeof chatPrivateInputNameIdentity>, retainedInstalledIdentity?: Omit<ReturnType<typeof installedChatFingerprintIdentity>, "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<string, unknown>), 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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<typeof setTimeout> | 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<MatrixExecution, "suite" | "task" | "profile" | "environment">) {
|
||||
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<NodeJS.ProcessEnv> {
|
||||
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?.() } };
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user