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. > - Product E2E evaluations check real user requests and their saved results. > - The TestOps reporter also verifies the uploaded result and attachment evidence. > - One failed attachment-row snapshot stopped the reader before its existing observation allowance. > - A later read with the same protected credential reference verified the original result. The original failed subfield remains unknown. > - This pull request handles attachment readiness within the existing bound while preserving the complete final evidence predicate. > - The reporter can wait for complete evidence without accepting missing, inherited, or incorrect files. ## Linked Issues or Issue Description Refs #18477 and #18470. **What happened?** The chat case passed its ten checks and cleanup in [run 41217990335](https://github.com/paperclipai/paperclip/actions/runs/41217990335), but the final reader failed at `attachment_page / attachment_rows`. The unchanged reader later verified that same retained result through the protected reporting identity in [read-only run 41268693639](https://github.com/paperclipai/paperclip/actions/runs/41268693639). This proves the observed snapshot differs. It does not identify which original row subfield failed or prove an upload race. The reader already permits twelve polls and 120 seconds for complete result and attachment metadata. It nevertheless rejects `missed === true` immediately. That flag cannot satisfy complete evidence, but it can be observed again within the existing allowance. **Expected behavior** Wait within the original limit for attachment evidence to become complete. Accept only the same full source, case, status, cleanup, attachment-name, media-type, byte-count, and non-missed predicate. Reject malformed IDs and inherited rows immediately. Permanent missing or incorrect evidence must still fail. **Steps to reproduce** 1. Call the real verifier with otherwise valid responses and one `missed: true` attachment. 2. Return the complete attachment on the second poll. 3. The old reader fails on the first snapshot. The regression now reaches the original complete acceptance predicate on the second snapshot. **Paperclip version/commit** Base `987e0932bf66e703750cd04698b3c7e8993580a7`. **Deployment mode** TestOps reporting observer after a Product E2E evaluation. This change starts no model or app and changes no feature oracle. ## What Changed - Move the strict `missed === true` condition from immediate structural rejection into bounded readiness. - Keep page shape, counts, unique positive IDs, inherited-row rejection, and every source and case guard hard. - Require the same complete final signature and status before success. Keep twelve polls, five-second intervals, the 120-second deadline, and request limits. - Retain closed per-poll counts and readiness booleans on verification and unverified failure. Clone failure observations so callers cannot change retained diagnostics. - Add seven focused regression and negative controls. Sync one existing consumer test with the precise new failure phase and predicate. ## Verification - Genuine parent RED: the real missed-then-complete verifier test failed at the original attachment-row guard. - API owner tests: 60 passed. Direct CLI, exporter, reporter, and retained-reader consumer tests: 31 passed. Node 26.4.0. - Permanent missing evidence reaches twelve polls and eleven waits, then fails. An elapsed 120-second deadline stops after the first page. Malformed IDs and inherited rows still fail immediately. Incorrect byte counts never pass. Closed diagnostics exclude private fields and return detached observation copies. - Three Node syntax checks and `git diff --check` passed. - Two review reproductions failed on the first PR head: a null row lost its controlled invalid-ID error, and a final-GET deadline crossing lost its exact readiness predicate. The corrected head preserves both diagnostics. Null rows still fail immediately; no deadline or acceptance condition changed. - No local app, database, provider, native build, or new evaluation ran. The MJS change needs no TypeScript emission. - [Normal CI 41269715187](https://github.com/paperclipai/paperclip/actions/runs/41269715187) passed on exact head `4e77c1220b0ba81e971591c1c7d28f9064e992cc`: 55 successes, three skipped checks, and one neutral review. Both required checks passed. Greptile is 5/5 and review threads are resolved. - The controlled normal TestOps-launched confirmation is pending merge. The family unit roster runs the local controls; normal PR CI uses separate runner suites, so CI green alone does not claim that roster ran there. ## Risks - A missed file may remain missing. The observer fails if complete evidence does not arrive inside the original allowance. - This is readiness handling, not a claim that `missed` caused the historical failure. Prior passed case grades and failed reporting workflows remain retained. - The previous fixed retained diagnostic intentionally pins the original helper. Its completed receipt remains valid; after this helper changes, that old diagnostic fails closed instead of silently replaying a different observer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with reasoning, code editing, shell tools, and independent 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>
141 lines
11 KiB
JavaScript
141 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, change.missed === true ? 'attachment_readiness' : 'attachment_page');
|
|
assert.equal(result.failure.verification.firstFailedPredicate,
|
|
change.missed === true ? 'attachments_not_missed' : change.fromTestCase === true ? 'not_inherited' : 'ids_valid');
|
|
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'));
|
|
});
|