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
117 lines
10 KiB
TypeScript
117 lines
10 KiB
TypeScript
import { CHAT_SETUP_HEALTH_ID, CHAT_SETUP_HEALTH_SUITE } from "./chat-setup-health.js";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { MatrixExecution } from "./types.js";
|
|
import { prepareChatRemoteHost, selectsRemoteChat } from "./chat-remote-host.js";
|
|
import { CHAT_OPENCODE_MODEL } from "./chat-runtime-profile.js";
|
|
const roots: string[] = [];
|
|
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
|
async function fixture(lifecycle = "spot") {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "remote-host-offline-")); roots.push(root);
|
|
const execution = { id: "chat-features.runner-opencode.local.create-backlog", suite: { id: "chat-features" }, profile: { id: "runner-opencode", provider: "opencode", model: CHAT_OPENCODE_MODEL, adapterType: "paperclip_runner", credential: "OPENROUTER_API_KEY" }, environment: { id: "local" }, task: { automaticRetryPolicy: "single_attempt" } } as unknown as MatrixExecution;
|
|
const env = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "paperclipai/paperclip", GITHUB_REF: "refs/heads/master", GITHUB_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/runner-full-stack-e2e.yml@refs/heads/master", GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "1", PAPERCLIP_RUNNER_E2E_SOURCE_SHA: "a".repeat(40), OPENROUTER_API_KEY: "synthetic-private-canary" };
|
|
const fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const u = String(url);
|
|
if (u.endsWith("/api/token")) return new Response("synthetic-imds-token");
|
|
if (u.endsWith("/instance-life-cycle")) return new Response(lifecycle);
|
|
return Response.json({ instanceId: "i-1234567890abcdef0", imageId: "ami-1234567890abcdef0", region: "us-east-1", architecture: "x86_64", privateIp: "not-retained" });
|
|
});
|
|
return { executions: [execution], env, repositoryRoot: root, outputDirectory: root, dependencies: { fetch: fetch as typeof globalThis.fetch, platform: "linux", arch: "x64", sourceRevision: async () => "a".repeat(40) }, fetch,
|
|
receipt: async () => JSON.parse(await readFile(path.join(root, "chat-remote-host.json"), "utf8")) };
|
|
}
|
|
describe("remote-only chat host qualification without native/provider calls", () => {
|
|
it.each(["projects.project-work", "projects.project-defaults", "instance-operations.restart-service"])("admits only the bound TestOps pilot cell %s on the actual AWS host", async scenario => {
|
|
const f = await fixture();
|
|
f.env.GITHUB_WORKFLOW_REF = "paperclipai/paperclip/.github/workflows/chat-completion-testops.yml@refs/heads/master";
|
|
Object.assign(f.env, { PAPERCLIP_CHAT_COMPLETION_TESTOPS: "v1", ALLURE_JOB_RUN_ID: "77", GITHUB_RUN_ATTEMPT: "1" });
|
|
f.executions[0]!.id = `chat-features.runner-opencode.local.${scenario}`;
|
|
f.executions[0]!.task.id = scenario;
|
|
expect(await prepareChatRemoteHost(f)).toMatchObject({ databaseLane: "embedded-postgres", maxAutomaticRetries: 0 });
|
|
expect(await f.receipt()).toMatchObject({ workflowRef: f.env.GITHUB_WORKFLOW_REF, evaluationAdmitted: false,
|
|
testOps: { admission: "v1", allureJobRunId: "77" } });
|
|
});
|
|
it.each(["foreign-cell", "wrong-task-id", "missing-admission", "missing-jobrun", "replay", "candidate-workflow"])("rejects TestOps %s before AWS metadata or inference", async kind => {
|
|
const f = await fixture();
|
|
f.env.GITHUB_WORKFLOW_REF = "paperclipai/paperclip/.github/workflows/chat-completion-testops.yml@refs/heads/master";
|
|
const extra = { PAPERCLIP_CHAT_COMPLETION_TESTOPS: "v1", ALLURE_JOB_RUN_ID: "77" };
|
|
Object.assign(f.env, extra); f.executions[0]!.id = "chat-features.runner-opencode.local.projects.project-work";
|
|
f.executions[0]!.task.id = "projects.project-work";
|
|
if (kind === "foreign-cell") f.executions[0]!.id = "chat-features.runner-opencode.local.tasks.delete";
|
|
if (kind === "wrong-task-id") f.executions[0]!.task.id = "foreign";
|
|
if (kind === "missing-admission") Object.assign(f.env, { PAPERCLIP_CHAT_COMPLETION_TESTOPS: undefined });
|
|
if (kind === "missing-jobrun") Object.assign(f.env, { ALLURE_JOB_RUN_ID: undefined });
|
|
if (kind === "replay") f.env.GITHUB_RUN_ATTEMPT = "2";
|
|
if (kind === "candidate-workflow") f.env.GITHUB_WORKFLOW_REF = f.env.GITHUB_WORKFLOW_REF.replace("refs/heads/master", "refs/heads/candidate");
|
|
await expect(prepareChatRemoteHost(f)).rejects.toThrow("trusted-github-linux-source-required");
|
|
expect(f.fetch).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["00077", "9007199254740992", "12345678901234567"])("rejects the same unsafe job identity %s as the dedicated dispatcher", async jobRunId => {
|
|
const f = await fixture(); f.env.GITHUB_WORKFLOW_REF = "paperclipai/paperclip/.github/workflows/chat-completion-testops.yml@refs/heads/master";
|
|
Object.assign(f.env, { PAPERCLIP_CHAT_COMPLETION_TESTOPS: "v1", ALLURE_JOB_RUN_ID: jobRunId });
|
|
f.executions[0]!.task.id = "projects.project-work"; f.executions[0]!.id = `chat-features.runner-opencode.local.${f.executions[0]!.task.id}`;
|
|
await expect(prepareChatRemoteHost(f)).rejects.toThrow("trusted-github-linux-source-required"); expect(f.fetch).not.toHaveBeenCalled();
|
|
});
|
|
it("admits the maximum safe job identity and retains it exactly", async () => {
|
|
const f = await fixture(); f.env.GITHUB_WORKFLOW_REF = "paperclipai/paperclip/.github/workflows/chat-completion-testops.yml@refs/heads/master";
|
|
Object.assign(f.env, { PAPERCLIP_CHAT_COMPLETION_TESTOPS: "v1", ALLURE_JOB_RUN_ID: "9007199254740991" });
|
|
f.executions[0]!.task.id = "projects.project-work"; f.executions[0]!.id = `chat-features.runner-opencode.local.${f.executions[0]!.task.id}`;
|
|
await prepareChatRemoteHost(f); expect(await f.receipt()).toMatchObject({ testOps: { admission: "v1", allureJobRunId: "9007199254740991" } });
|
|
});
|
|
it("requires real Spot metadata and retains only closed source-bound host proof", async () => {
|
|
const f = await fixture(); const policy = await prepareChatRemoteHost(f); const receipt = await f.receipt();
|
|
expect(policy).toMatchObject({ databaseLane: "embedded-postgres", maxAutomaticRetries: 0, maxParallel: 2 });
|
|
expect(receipt).toMatchObject({ status: "qualified-host-only", spotVerified: true, evaluationAdmitted: false, allocationSpotOnlyClaimed: false, sourceRevision: "a".repeat(40) });
|
|
expect(JSON.stringify(receipt)).not.toMatch(/synthetic-private-canary|synthetic-imds-token|privateIp|not-retained/);
|
|
expect(f.fetch.mock.calls.every(([url]) => String(url).startsWith("http://169.254.169.254/latest/"))).toBe(true);
|
|
expect(receipt.databaseLane).toBe("embedded-postgres");
|
|
expect(receipt).not.toHaveProperty("postgres");
|
|
expect(receipt).not.toHaveProperty("dockerEngineSha256");
|
|
});
|
|
it("accepts the user-authorized fleet fallback while truthfully recording On-Demand", async () => {
|
|
const f = await fixture("on-demand"); await prepareChatRemoteHost(f);
|
|
expect(await f.receipt()).toMatchObject({ lifecycle: "on-demand", status: "qualified-host-only", spotVerified: false, hostPolicy: "aws-fleet-spot-or-on-demand", evaluationAdmitted: false });
|
|
});
|
|
it.each(["", "unknown", "preemptible"])("refuses unproven host lifecycle %s", async lifecycle => {
|
|
const f = await fixture(lifecycle); await expect(prepareChatRemoteHost(f)).rejects.toThrow("unknown-host-lifecycle-refused");
|
|
expect(await f.receipt()).toMatchObject({ lifecycle, status: "blocked", evaluationAdmitted: false });
|
|
});
|
|
it.each(["local", "branch-workflow", "wrong-source", "wrong-model", "automatic-retry", "foreign-repository"])("fails closed on %s before metadata", async kind => {
|
|
const f = await fixture();
|
|
if (kind === "local") f.dependencies.platform = "darwin";
|
|
if (kind === "branch-workflow") f.env.GITHUB_WORKFLOW_REF = "untrusted@refs/heads/branch";
|
|
if (kind === "wrong-source") f.env.PAPERCLIP_RUNNER_E2E_SOURCE_SHA = "b".repeat(40);
|
|
if (kind === "wrong-model") f.executions[0]!.profile.model = "other";
|
|
if (kind === "automatic-retry") f.executions[0]!.task.automaticRetryPolicy = undefined;
|
|
if (kind === "foreign-repository") f.env.GITHUB_REPOSITORY = "foreign/repository";
|
|
await expect(prepareChatRemoteHost(f)).rejects.toThrow("trusted-github-linux-source-required"); expect(f.fetch).not.toHaveBeenCalled();
|
|
});
|
|
it("records missing actual AWS identity without admitting an evaluation", async () => {
|
|
const f = await fixture(); f.dependencies.fetch = vi.fn(async () => new Response("", { status: 403 }));
|
|
await expect(prepareChatRemoteHost(f)).rejects.toThrow("aws-instance-identity-unavailable");
|
|
expect(await f.receipt()).toMatchObject({ status: "blocked", evaluationAdmitted: false, databaseLane: "embedded-postgres" });
|
|
});
|
|
it.each(["architecture", "region", "instanceId"])("rejects wrong AWS %s", async field => {
|
|
const f = await fixture(), original = f.dependencies.fetch;
|
|
f.dependencies.fetch = vi.fn(async (url, init) => {
|
|
const response = await original(url, init);
|
|
if (!String(url).endsWith("/document")) return response;
|
|
const identity = await response.json(); identity[field] = "wrong"; return Response.json(identity);
|
|
});
|
|
await expect(prepareChatRemoteHost(f)).rejects.toThrow("aws-instance-identity-unavailable");
|
|
});
|
|
it("keeps the one exact health cell on the same AWS/ordinary embedded database policy", async () => {
|
|
const f = await fixture("on-demand"); f.executions[0]!.id = CHAT_SETUP_HEALTH_ID; f.executions[0]!.suite.id = CHAT_SETUP_HEALTH_SUITE;
|
|
expect(selectsRemoteChat(f.executions)).toBe(true);
|
|
expect(await prepareChatRemoteHost(f)).toMatchObject({ databaseLane: "embedded-postgres", maxAutomaticRetries: 0, maxParallel: 2 });
|
|
expect(await f.receipt()).not.toHaveProperty("dockerEngineSha256");
|
|
f.executions[0]!.id = "chat-setup-health.runner-opencode.local.wrong";
|
|
expect(selectsRemoteChat(f.executions)).toBe(false);
|
|
});
|
|
it("does not change historical/other profiles or require IMDS for their discovery", async () => {
|
|
const f = await fixture(); f.executions[0]!.profile.id = "runner-codex-subscription";
|
|
expect(selectsRemoteChat(f.executions)).toBe(false); expect(await prepareChatRemoteHost(f)).toBeUndefined(); expect(f.fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|