mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - The chat feature evaluation checks a real task request on AWS. > - The case passed, but its final TestOps reader rejected attachment metadata. > - The later browser view passes the same check. It uses a different identity and time. > - Another model run cannot explain that difference. > - This pull request adds a fixed read-only check of the retained measurement with the reporting API identity. > - The check separates its own executor from the original measurement and preserves the original grade. ## Linked Issues or Issue Description Refs #18470 and #18462. **What happened?** The real chat case passed all ten checks and cleanup in [run 41217990335](https://github.com/paperclipai/paperclip/actions/runs/41217990335). Final verification failed at `attachment_page / attachment_rows`. The reader had already accepted the source, run, case, status, page counts, and unique attachment IDs. Later interactive metadata had valid IDs and false attachment flags. That later view does not explain the original API caller failure. **Expected behavior** A read-only operation can inspect the same retained result with the existing protected reporting identity. It must report the exact current attachment flags without running a model, uploading results, or changing the original grade. **Steps to reproduce** 1. Read the closed failure from the linked evaluation run. 2. After this workflow is reviewed and deployed, dispatch its fixed no-input operation on master. 3. Inspect its closed receipt. It records both the new executor and the original measurement. **Paperclip version/commit** The retained measurement used `e40de0c9e88210dc476dcf651461c0a8d1b98a59`. **Deployment mode** GitHub Actions metadata job with the existing protected reporting environment. No app, database, provider, or AWS worker starts. ## What Changed - Add one manual no-input workflow for the fixed retained measurement. - Admit the live executor and the original run separately. Verify exact selection and sanitized export artifact hashes before TestOps authentication. - Read only the five existing TestOps metadata routes with the existing client. Its normal API-token session exchange is the only authentication POST. - Record closed per-row attachment types and flags. Replay the unchanged verifier against the captured responses using historical context as data. Keep the actual process environment unchanged. - Add nine mock and archive-safety tests to the existing Node unit roster. ## Verification - `node --test tests/runner-e2e/chat-feature-testops-retained-verification.node-test.mjs`: 9 passed on Node 26.4.0. - Three Node syntax checks, actionlint, the relevant workflow policy check, and `git diff --check` passed. - The real pinned sanitized export ZIP passes the new safe-export extractor and existing bound reader. The first local attempt used a noncanonical `/var` temporary path and hit the existing canonical-path guard; the canonical `/private/tmp` attempt passed without a source change. - Corrected a review finding in failure attribution. Only actual authentication or protected GET errors now receive `apiFailure`. Historical replay failures retain their original phase and predicate diagnostics. - Initial test run: eight passed and one test expected the generic upload error instead of the existing specific job-run error. Corrected that test expectation. No verifier behavior changed. - Tests exercise separate identities, foreign source and actor rejection, origin and artifact integrity, exact five GETs, all three attachment-row refusals, secret redaction, wrong job/run/source, and real archive traversal/symlink rejection. - The unchanged API and export readers are source-pinned. Existing evaluation and acceptance code is unchanged. - [Normal CI 41220476243](https://github.com/paperclipai/paperclip/actions/runs/41220476243) passed on exact head `b79e85f71567f662d6f3d680ec66ec49a132589a`: 55 successes, three skipped checks, and two neutral reviews. Both required checks passed. Greptile is 5/5 and all review threads are resolved. - The nine new controls are registered in the family unit command. The separate normal PR-CI runner suites do not execute that roster, so their green status is not proof these nine ran on Node 24. The manual workflow explicitly runs them on Node 24 before any protected API read. - The real protected API operation has not run. A separately approved merge and metadata dispatch remain required. No model, app, database, or native build ran locally. ## Risks - The fixed artifacts can expire. Any absent or changed artifact fails before TestOps authentication. - The current protected response snapshot may differ from the original failure-time state. The receipt states that limit and makes no historical cause claim. - This is a fixed diagnostic operation. It does not repair the original workflow or grant authority to run another evaluation. - The workflow uses the existing protected reporting token. It logs no response body, unknown field, credential, or unknown URL. Only closed receipts are retained. ## Model Used OpenAI `gpt-6.1-sol`, through Codex, with reasoning, code editing, shell tools, and source review. The host does not expose the context window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
140 lines
11 KiB
JavaScript
140 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { admitExecutor, admitOrigin, admitArtifact, admitSelection, attachmentProjection, snapshotAndReplay, extractArchive, origin, routes } from './testops-chat-retained-verification.mjs';
|
|
|
|
const workflow = '.github/workflows/chat-feature-testops-retained-verification.yml';
|
|
const env = { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: 'paperclipai/paperclip',
|
|
GITHUB_REF: 'refs/heads/master', GITHUB_WORKFLOW_REF: `paperclipai/paperclip/${workflow}@refs/heads/master`,
|
|
GITHUB_RUN_ATTEMPT: '1', GITHUB_RUN_ID: '900', GITHUB_SHA: 'a'.repeat(40), GITHUB_ACTOR_ID: '34892728', TESTOPS_ALLOWED_ACTOR_IDS: '[34892728]' };
|
|
const repo = { full_name: 'paperclipai/paperclip', private: false, default_branch: 'master' };
|
|
const event = { repository: repo, ref: 'refs/heads/master', sender: { id: 34892728 }, inputs: {} };
|
|
const ownRun = { id: 900, run_attempt: 1, head_sha: env.GITHUB_SHA, head_branch: 'master', event: 'workflow_dispatch', path: workflow,
|
|
workflow_id: 901, repository: repo, actor: { id: 34892728 }, triggering_actor: { id: 34892728 } };
|
|
const registered = { id: 901, path: workflow };
|
|
const selection = { schema: 'paperclip.chat-feature-testops-selection/v1', runId: origin.runId, attempt: origin.attempt,
|
|
workflowSha: origin.workflowSha, targetSha: origin.targetSha, allureJobRunId: String(origin.jobRunId),
|
|
projectId: 1, planId: 36, jobId: 34, caseId: 388, launchId: 1069, fullName: 'paperclip.Product E2E.chat-features.create-backlog',
|
|
executionId: 'chat-features.runner-opencode-deepseek-1017.local.create-backlog', canonicalScenario: 'tasks.create-backlog' };
|
|
const attachmentMetadata = [
|
|
{ name: 'Synthetic task and source conversation', contentType: 'image/png', contentLength: 104828 },
|
|
{ name: 'Execution provenance and checks', contentType: 'application/json', contentLength: 1443 },
|
|
{ name: 'Sanitized Product E2E record and accounting', contentType: 'application/json', contentLength: 9516 },
|
|
];
|
|
const receipt = { status: 'passed', cleanup: 'passed', caseId: 388, campaignId: `gha-${origin.runId}-1`, count: 1, attachmentMetadata };
|
|
function responses() {
|
|
const jobrun = { id: 1031, job: { id: 34, type: 'github' }, launchId: 1069,
|
|
url: `https://github.com/paperclipai/paperclip/actions/runs/${origin.runId}`, stage: 'finished' };
|
|
const { job, ...embedded } = jobrun;
|
|
return [jobrun, { id: 1069, projectId: 1 }, { content: [{ id: 2675, testCaseId: 388 }], totalElements: 1, totalPages: 1 },
|
|
{ id: 2675, projectId: 1, launchId: 1069, testCaseId: 388, fullName: selection.fullName, jobRun: embedded, links: [], status: 'passed' },
|
|
{ content: attachmentMetadata.map((row, index) => ({ ...row, id: index + 9274, missed: false, fromTestCase: false })), totalElements: 3, totalPages: 1 }];
|
|
}
|
|
|
|
test('retained executor uses its own live identity rather than the originating evaluation identity', () => {
|
|
const actual = admitExecutor(env, event, repo, ownRun, registered);
|
|
assert.equal(actual.runId, '900');
|
|
assert.equal(actual.source, 'a'.repeat(40));
|
|
assert.notEqual(actual.runId, origin.runId);
|
|
assert.notEqual(actual.source, origin.workflowSha);
|
|
assert.equal(admitExecutor(env, { ...event, inputs: undefined }, repo, ownRun, registered).workflowId, 901);
|
|
});
|
|
|
|
test('foreign source, branch, workflow, actor, replay or caller-selected origin cannot acquire executor admission', () => {
|
|
for (const changed of [{ GITHUB_SHA: 'b'.repeat(40) }, { GITHUB_REF: 'refs/heads/feature' }, { GITHUB_RUN_ATTEMPT: '2' },
|
|
{ GITHUB_RUN_ID: origin.runId }, { GITHUB_ACTOR_ID: '1' }, { GITHUB_WORKFLOW_REF: 'other' }]) {
|
|
assert.throws(() => admitExecutor({ ...env, ...changed }, event, repo, ownRun, registered), /executor_rejected/);
|
|
}
|
|
assert.throws(() => admitExecutor(env, { ...event, inputs: { origin: 'other' } }, repo, ownRun, registered), /executor_rejected/);
|
|
assert.throws(() => admitExecutor(env, event, repo, { ...ownRun, triggering_actor: { id: 1 } }, registered), /executor_rejected/);
|
|
assert.throws(() => admitExecutor(env, event, { ...repo, default_branch: 'other' }, ownRun, registered), /executor_rejected/);
|
|
assert.throws(() => admitExecutor(env, event, repo, ownRun, { ...registered, id: 902 }), /executor_rejected/);
|
|
});
|
|
|
|
test('fixed originating measurement requires actual workflow/source/attempt and terminal history', () => {
|
|
const original = { ...ownRun, id: Number(origin.runId), run_attempt: 1, workflow_id: origin.workflowId, head_sha: origin.workflowSha,
|
|
path: '.github/workflows/chat-feature-testops.yml', status: 'completed', conclusion: 'failure' };
|
|
admitOrigin(original);
|
|
for (const changed of [{ head_sha: 'b'.repeat(40) }, { run_attempt: 2 }, { workflow_id: 902 }, { head_branch: 'feature' },
|
|
{ conclusion: 'success' }, { actor: { id: 1 } }, { status: 'in_progress' }]) assert.throws(() => admitOrigin({ ...original, ...changed }), /origin_rejected/);
|
|
});
|
|
|
|
test('artifact bytes and metadata must bind the exact admitted origin before TestOps authentication', () => {
|
|
const archive = Buffer.from('synthetic archive');
|
|
const expected = { id: 1, name: 'fixed', limit: 1024, sha256: createHash('sha256').update(archive).digest('hex') };
|
|
const actual = { id: 1, name: 'fixed', size_in_bytes: archive.length, expired: false, digest: `sha256:${expected.sha256}`,
|
|
workflow_run: { id: Number(origin.runId), head_sha: origin.workflowSha } };
|
|
admitArtifact(actual, expected, archive);
|
|
for (const changed of [{ id: 2 }, { name: 'other' }, { expired: true }, { digest: 'sha256:wrong' },
|
|
{ workflow_run: { id: 2, head_sha: origin.workflowSha } }]) assert.throws(() => admitArtifact({ ...actual, ...changed }, expected, archive), /artifact_rejected/);
|
|
assert.throws(() => admitArtifact(actual, expected, Buffer.from('changed')), /artifact_rejected/);
|
|
assert.throws(() => admitSelection(Buffer.from(JSON.stringify(selection))), /selection_rejected/);
|
|
});
|
|
|
|
test('fixed protected snapshot performs only the five known GETs and replays the unchanged verifier without replacing live environment', async () => {
|
|
const before = { run: process.env.GITHUB_RUN_ID, source: process.env.GITHUB_SHA }, calls = [], values = responses();
|
|
const result = await snapshotAndReplay(async (route, method) => { calls.push([route, method]); return values[routes.indexOf(route)]; }, selection, receipt);
|
|
assert.deepEqual(calls, routes.map(route => [route, 'GET']));
|
|
assert.equal(result.verification.verification, 'VERIFIED');
|
|
assert.equal(result.verification.resultId, 2675);
|
|
assert.equal(result.verification.runId, origin.runId);
|
|
assert.deepEqual({ run: process.env.GITHUB_RUN_ID, source: process.env.GITHUB_SHA }, before);
|
|
assert.equal(result.attachmentRows.rows.every(row => row.idPositiveSafeInteger && row.missed === false && row.fromTestCase === false), true);
|
|
});
|
|
|
|
test('actual three-way attachment row refusal retains the precise current protected flags without raw fields', async () => {
|
|
for (const change of [{ id: '9274' }, { missed: true }, { fromTestCase: true }]) {
|
|
const values = responses();
|
|
Object.assign(values[4].content[0], change, { secret: 'do-not-retain', source: 'unknown-private-url' });
|
|
const result = await snapshotAndReplay(async route => values[routes.indexOf(route)], selection, receipt);
|
|
assert.equal(result.failure.verification.phase, 'attachment_page');
|
|
assert.equal(result.failure.verification.firstFailedPredicate, 'attachment_rows');
|
|
const safe = JSON.stringify(result);
|
|
assert.ok(!safe.includes('do-not-retain') && !safe.includes('unknown-private-url'));
|
|
assert.equal(result.verification, undefined);
|
|
if (typeof change.id === 'string') assert.equal(result.attachmentRows.rows[0].idType, 'string');
|
|
if (change.missed === true) assert.equal(result.attachmentRows.rows[0].missed, true);
|
|
if (change.fromTestCase === true) assert.equal(result.attachmentRows.rows[0].fromTestCase, true);
|
|
}
|
|
const safe = attachmentProjection({ content: [{ id: 1, name: 'raw secret name', contentType: 'raw secret type', missed: 'raw secret flag' }] });
|
|
assert.ok(!JSON.stringify(safe).includes('raw secret'));
|
|
assert.equal(safe.rows[0].missedType, 'string');
|
|
});
|
|
|
|
test('authoritative wrong job, wrong result jobrun and contradictory source remain failures during historical replay', async () => {
|
|
for (const [mutate, expected] of [[values => { values[0].job.id = 35; }, 'chat_testops_jobrun_mismatch'],
|
|
[values => { values[3].jobRun.id = 1032; }, 'chat_testops_upload_mismatch'],
|
|
[values => { values[3].links = [{ url: 'https://github.com/paperclipai/paperclip/commit/' + 'b'.repeat(40) }]; }, 'chat_testops_upload_mismatch']]) {
|
|
const values = responses(); mutate(values);
|
|
const result = await snapshotAndReplay(async route => values[routes.indexOf(route)], selection, receipt);
|
|
assert.equal(result.verification, undefined);
|
|
assert.equal(result.failure.code, expected);
|
|
}
|
|
});
|
|
|
|
test('real archive extraction admits only the fixed selection file and rejects traversal or symlinks', async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'retained-chat-archive-'));
|
|
try {
|
|
for (const kind of ['valid', 'traversal', 'symlink']) {
|
|
const archive = path.join(root, `${kind}.zip`), target = path.join(root, kind);
|
|
execFileSync('python3', ['-c', "import zipfile,sys; p,k=sys.argv[1:]; z=zipfile.ZipFile(p,'w'); n='testops-selection.json' if k!='traversal' else '../escaped'; i=zipfile.ZipInfo(n); i.external_attr=(0o120777 if k=='symlink' else 0o100600)<<16; z.writestr(i,b'{}'); z.close()", archive, kind]);
|
|
if (kind === 'valid') { await extractArchive(archive, target, 'selection'); assert.equal(await readFile(path.join(target, 'testops-selection.json'), 'utf8'), '{}'); }
|
|
else await assert.rejects(extractArchive(archive, target, 'selection'));
|
|
}
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('manual workflow has no selection inputs or provider/upload path and retains closed receipts only', async () => {
|
|
const yaml = await readFile(new URL('../../.github/workflows/chat-feature-testops-retained-verification.yml', import.meta.url), 'utf8');
|
|
assert.match(yaml, /workflow_dispatch:/);
|
|
assert.ok(!yaml.includes('inputs:') && !yaml.includes('allurectl') && !yaml.includes('OPENROUTER') && !yaml.includes('id-token: write'));
|
|
assert.match(yaml, /environment: runner-e2e-testops-reporting/);
|
|
assert.match(yaml, /ref: \$\{\{ github.sha \}\}/);
|
|
assert.match(yaml, /chat-testops-retained-verification\/retained-verification.json/);
|
|
assert.ok(!yaml.includes('safe-export\n') && !yaml.includes('selection.zip'));
|
|
});
|