mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip evals verify real agent workflows. > - The preserved chat catalog runs on authenticated AWS hosts. > - Its host guard currently admits only the full-stack workflow. > - A dedicated TestOps pilot needs the same host verification. > - This change admits only its three selected scenarios and fixed profile. > - The existing workflow and all other catalog cases retain their gates. ## Linked Issues or Issue Description Refs #18377 and #18516. This is intentionally the catalog-only prerequisite for a dedicated TestOps connection to project work, project defaults and instance restart. The full catalog remains on the integration branch. This admission is inert until the companion main-only workflow in #18516 exists. No TestOps dispatch may run until both PRs pass their gates and the companion source pin is final. The companion exercises its real YAML environment against the actual pinned source host function before provider access. Registration and source checks do not establish a new live TestOps pass. ## What Changed - Admit the main-only `chat-completion-testops.yml` entry point for the three exact pilot IDs. - Require its admission marker, job-run identity and original attempt one. Preserve profile, source, actual AWS metadata, lifecycle and zero-retry checks. - Record the actual admitted workflow in the existing host receipt. Document the limited entry point. ## Verification - Red: all three new positive controls fail at the old workflow guard. The other 22 host controls pass. - Green: all 30 host controls pass. Negative controls reject foreign cases, missing authority, missing job identity, replay and candidate workflows before metadata or inference. - This guard and the new dedicated dispatcher both require at most 16 digits and a positive JavaScript-safe integer. The old backlog dispatcher is a separate entry point; its 20-digit string bound is not used here. Controls cover leading zeroes, unsafe 16-digit and 17-digit IDs, and exact maximum-safe ID preservation. - The receipt retains the admitted TestOps job-run ID and marker. Scenario admission cross-checks both execution ID and actual task ID. - Full catalog typecheck passes with the existing qualified dependency graph. The first fresh-checkout attempts lacked dependency links; the first broad unit startup also lacked prepared provider bundles. Those setup failures remain retained. The focused host controls make no native or provider calls. - Current-head CI and review are pending. No live run has been dispatched from this new entry point. ## Risks The new entry point has access only through a trusted main workflow. That workflow must authenticate the exact TestOps selection before provider access and retain private evidence through its existing owner. The prerequisite alone does not register cases, expose credentials, or grant a live pass. Model, queries, case clocks and graders are unchanged. ## Model Used OpenAI GPT-6 through Codex, with repository inspection and code execution. Exact hosted model revision and context-window size are not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
73 lines
6.3 KiB
TypeScript
73 lines
6.3 KiB
TypeScript
import { CHAT_SETUP_HEALTH_ID, CHAT_SETUP_HEALTH_SUITE } from "./chat-setup-health.js";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { reviewedChatProfile } from "./chat-runtime-profile.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
|
|
const workflow = "paperclipai/paperclip/.github/workflows/runner-full-stack-e2e.yml@refs/heads/master";
|
|
const testOpsWorkflow = "paperclipai/paperclip/.github/workflows/chat-completion-testops.yml@refs/heads/master";
|
|
const testOpsPilotIds = new Set(["projects.project-work", "projects.project-defaults", "instance-operations.restart-service"]
|
|
.map(scenario => `chat-features.runner-opencode.local.${scenario}`));
|
|
type Selection = Pick<MatrixExecution, "id" | "suite" | "profile" | "environment" | "task">;
|
|
type Environment = Record<string, string | undefined>;
|
|
export interface RemoteHostDependencies {
|
|
fetch: typeof fetch; platform: string; arch: string;
|
|
sourceRevision: (root: string) => Promise<string>;
|
|
}
|
|
const defaults: RemoteHostDependencies = { fetch: globalThis.fetch, platform: process.platform, arch: process.arch,
|
|
sourceRevision: async root => (await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd: root, timeout: 5000 })).stdout.trim() };
|
|
/** This new campaign lane is remote-only. Existing profiles/local defaults remain unchanged. */
|
|
export function selectsRemoteChat(executions: readonly Selection[]) {
|
|
return executions.some(e => (e.suite.id === "chat-features" || e.id === CHAT_SETUP_HEALTH_ID) && e.profile.id === "runner-opencode");
|
|
}
|
|
/** Qualifies only the actual trusted GitHub AWS host. The ordinary isolated
|
|
* embedded PostgreSQL bootstrap, migrations and cleanup remain owned by the launcher. */
|
|
export async function prepareChatRemoteHost(input: { executions: readonly Selection[]; env: Environment; repositoryRoot: string; outputDirectory: string; dependencies?: Partial<RemoteHostDependencies> }) {
|
|
if (!selectsRemoteChat(input.executions)) return undefined;
|
|
const deps = { ...defaults, ...input.dependencies }, env = input.env;
|
|
const receipt: Record<string, unknown> = { schema: "paperclip.chat-remote-host/v1", status: "blocked", scope: "before-evaluation", evaluationAdmitted: false,
|
|
allocationSpotOnlyClaimed: false, hostPolicy: "aws-fleet-spot-or-on-demand", databaseLane: "embedded-postgres", automaticRetries: 0, maximumParallel: 2,
|
|
executionIds: input.executions.map(e => e.id) };
|
|
const filename = path.join(input.outputDirectory, "chat-remote-host.json");
|
|
await mkdir(input.outputDirectory, { recursive: true });
|
|
const persist = () => writeFile(filename, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
let reason = "trusted-github-linux-source-required";
|
|
try {
|
|
const testOpsPilot = env.GITHUB_WORKFLOW_REF === testOpsWorkflow && env.PAPERCLIP_CHAT_COMPLETION_TESTOPS === "v1"
|
|
&& env.GITHUB_RUN_ATTEMPT === "1" && /^[1-9][0-9]{0,15}$/.test(env.ALLURE_JOB_RUN_ID ?? "")
|
|
&& Number.isSafeInteger(Number(env.ALLURE_JOB_RUN_ID))
|
|
&& input.executions.every(execution => testOpsPilotIds.has(execution.id)
|
|
&& execution.id === `chat-features.runner-opencode.local.${execution.task.id}`);
|
|
if (env.GITHUB_ACTIONS !== "true" || env.GITHUB_REPOSITORY !== "paperclipai/paperclip" || env.GITHUB_REF !== "refs/heads/master"
|
|
|| env.GITHUB_WORKFLOW_REF !== workflow && !testOpsPilot
|
|
|| !/^\d+$/.test(env.GITHUB_RUN_ID ?? "") || !/^\d+$/.test(env.GITHUB_RUN_ATTEMPT ?? "") || deps.platform !== "linux" || deps.arch !== "x64") throw new Error(reason);
|
|
if (input.executions.some(e => (e.suite.id !== "chat-features" && (e.suite.id !== CHAT_SETUP_HEALTH_SUITE || e.id !== CHAT_SETUP_HEALTH_ID)) || e.profile.id !== "runner-opencode" || !reviewedChatProfile(e.profile) || e.environment.id !== "local" || e.task.automaticRetryPolicy !== "single_attempt")) throw new Error(reason);
|
|
const source = await deps.sourceRevision(input.repositoryRoot);
|
|
if (!/^[a-f0-9]{40}$/.test(source) || env.PAPERCLIP_RUNNER_E2E_SOURCE_SHA !== source) throw new Error(reason);
|
|
Object.assign(receipt, { workflowRef: env.GITHUB_WORKFLOW_REF, workflowRunId: env.GITHUB_RUN_ID, workflowAttempt: Number(env.GITHUB_RUN_ATTEMPT), sourceRevision: source });
|
|
if (testOpsPilot) Object.assign(receipt, { testOps: { admission: "v1", allureJobRunId: env.ALLURE_JOB_RUN_ID } });
|
|
reason = "aws-instance-identity-unavailable";
|
|
const tokenResponse = await deps.fetch("http://169.254.169.254/latest/api/token", { method: "PUT", redirect: "error", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "60" }, signal: AbortSignal.timeout(5000) });
|
|
if (!tokenResponse.ok) throw new Error(reason);
|
|
const token = await tokenResponse.text(); if (!token || token.length > 4096) throw new Error(reason);
|
|
const metadata = async (url: string) => {
|
|
const response = await deps.fetch(url, { redirect: "error", headers: { "X-aws-ec2-metadata-token": token }, signal: AbortSignal.timeout(5000) });
|
|
if (!response.ok) throw new Error(reason); const text = await response.text(); if (text.length > 8192) throw new Error(reason); return text;
|
|
};
|
|
const lifecycle = (await metadata("http://169.254.169.254/latest/meta-data/instance-life-cycle")).trim();
|
|
const identity = JSON.parse(await metadata("http://169.254.169.254/latest/dynamic/instance-identity/document"));
|
|
if (!/^i-[a-f0-9]{8,17}$/.test(identity.instanceId ?? "") || !/^ami-[a-f0-9]{8,17}$/.test(identity.imageId ?? "") || identity.region !== "us-east-1" || identity.architecture !== "x86_64") throw new Error(reason);
|
|
Object.assign(receipt, { instanceId: identity.instanceId, imageId: identity.imageId, region: identity.region, architecture: identity.architecture, lifecycle });
|
|
reason = "unknown-host-lifecycle-refused-before-evaluation";
|
|
if (lifecycle !== "spot" && lifecycle !== "on-demand") throw new Error(reason);
|
|
Object.assign(receipt, { status: "qualified-host-only", spotVerified: lifecycle === "spot" });
|
|
await persist();
|
|
return { receiptPath: filename, databaseLane: "embedded-postgres" as const, maxAutomaticRetries: 0, maxParallel: 2 };
|
|
} catch {
|
|
receipt.reason = reason; await persist();
|
|
throw new Error(`Remote chat preflight blocked: ${reason}; inspect chat-remote-host.json. No evaluation admitted.`);
|
|
}
|
|
}
|