fix(evals): publish the canonical chat viewer on every direct run

Use a closed public replay projection and verify exact viewer assets. Keep read-only reports usable, preserve immutable campaign history, and add a no-provider report refresh command.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-06 18:14:47 -05:00
parent 856813ba3a
commit dfb35a3ac2
17 changed files with 1174 additions and 174 deletions

View File

@ -213,6 +213,11 @@ jobs:
--max-parallel "$MAX_PARALLEL" \
--output runner-protocol-eval-catalog.json
- name: Require the chat-report renderer before paid execution
run: |
set -euo pipefail
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report --help | grep -q -- --public-viewer
- name: Upload immutable campaign catalog
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
@ -290,6 +295,14 @@ jobs:
compression-level: 0
if-no-files-found: error
- name: Upload canonical viewer for publisher byte verification
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: runner-protocol-viewer-${{ github.run_id }}-${{ github.run_attempt }}
path: packages/paperclip-runner/dist-issue-thread/
retention-days: 30
if-no-files-found: error
eval_shard_0:
name: Direct eval ${{ matrix.rosterId }} / ${{ matrix.caseId }}
needs: [authorize, catalog, build_runner]
@ -595,6 +608,8 @@ jobs:
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report \
--runs-root runner-protocol-merged/public-runs \
--output runner-protocol-merged/public-report \
--viewer-root runner-protocol-build/extracted/dist-issue-thread \
--public-viewer \
--inventory .paperclip-evals/evals/paperclip-runner/inventory.json \
--coverage-matrix .paperclip-evals/evals/paperclip-runner/coverage-matrix.json
cp runner-protocol-merged/campaign.json runner-protocol-merged/public-report/campaign.json
@ -602,7 +617,7 @@ jobs:
- name: Enforce the static public allowlist
id: public_report
run: |
node --input-type=module -e 'import { validatePublicProtocolEvalReport } from "./packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs"; await validatePublicProtocolEvalReport("runner-protocol-merged/public-report");'
node --input-type=module -e 'import { validatePublicProtocolEvalReport } from "./packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs"; await validatePublicProtocolEvalReport("runner-protocol-merged/public-report", { viewerRoot: "runner-protocol-build/extracted/dist-issue-thread" });'
echo "ready=true" >> "$GITHUB_OUTPUT"
- name: Add campaign result to the workflow summary
@ -666,6 +681,12 @@ jobs:
name: runner-protocol-eval-public-${{ github.run_id }}-${{ github.run_attempt }}
path: runner-protocol-public-report
- name: Download the same-run canonical viewer for byte verification
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: runner-protocol-viewer-${{ github.run_id }}-${{ github.run_attempt }}
path: runner-protocol-trusted-viewer
- name: Exchange GitHub OIDC identity for scoped AWS credentials
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with:
@ -675,6 +696,7 @@ jobs:
- name: Publish versioned report and refresh the root index
env:
PAPERCLIP_RUNNER_PROTOCOL_EVAL_PUBLIC_REPORT_DIR: ${{ github.workspace }}/runner-protocol-public-report
PAPERCLIP_RUNNER_PROTOCOL_EVAL_VIEWER_DIR: ${{ github.workspace }}/runner-protocol-trusted-viewer
RUNNER_PROTOCOL_EVAL_HISTORY_S3_BUCKET: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_S3_BUCKET || vars.RUNNER_E2E_HISTORY_S3_BUCKET }}
RUNNER_PROTOCOL_EVAL_HISTORY_PREFIX: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_PREFIX || 'runner-protocol-evals' }}
RUNNER_PROTOCOL_EVAL_HISTORY_PUBLIC_BASE_URL: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_PUBLIC_BASE_URL || vars.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL }}

View File

@ -78,6 +78,7 @@ interface EmbeddedEvalCheck {
}
interface EmbeddedEvalReport {
publication?: { schema: string; notice: string };
attemptId: string;
caseId: string;
disposition: string;
@ -101,7 +102,7 @@ interface EmbeddedEvalReport {
runnerBuild: string;
startedAt: string;
finishedAt: string;
durationMs: number;
durationMs: number | null;
initialRevision: number;
finalRevision: number;
usage: {
@ -117,7 +118,7 @@ interface EmbeddedEvalReport {
} | null;
};
view: CapabilityIssueThreadSnapshot;
devtools: CapabilityDevtoolsSnapshot;
devtools: CapabilityDevtoolsSnapshot | null;
navigation: { suiteHref: string; previous: { label: string; href: string } | null; next: { label: string; href: string } | null };
}
@ -491,7 +492,7 @@ export function App() {
await document.fonts.ready;
}
if (cancelled) return;
if (scroller !== null) scroller.scrollTop = scroller.scrollHeight;
if (scroller !== null) scroller.scrollTop = embeddedEval === null ? scroller.scrollHeight : 0;
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
if (!cancelled) setSettled(true);
})();
@ -501,14 +502,14 @@ export function App() {
}, [snapshot]);
useEffect(() => {
if (!panelOpen || snapshot === null || route.mode !== "live") return;
if (embeddedEval !== null || !panelOpen || snapshot === null || route.mode !== "live") return;
if (historicSessionId !== null) return;
let cancelled = false;
void capabilityLiveClient.devtools(snapshot.sessionId)
.then((next) => { if (!cancelled) setDevtools(next); })
.catch((cause) => { if (!cancelled) setActionError(describe(cause)); });
return () => { cancelled = true; };
}, [historicSessionId, panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]);
}, [embeddedEval, historicSessionId, panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (!chat || snapshot === null || historicSessionId !== null) return;
@ -1363,7 +1364,7 @@ export function App() {
>
<div className="pit-thread">
{embeddedEval !== null ? (
<div className="pit-eval-boundary" data-phase="execution"><strong>Eval execution</strong></div>
<div className="pit-eval-boundary" data-phase="execution"><strong>Eval execution</strong>{embeddedEval.publication ? <span>{embeddedEval.publication.notice}</span> : null}</div>
) : null}
{snapshot.turns.length === 0 ? (
<section className="pit-empty-thread" data-testid="clean-room-empty">
@ -1404,7 +1405,7 @@ export function App() {
{embeddedEval !== null ? (
<div className="pit-eval-boundary" data-phase="post-run">
<strong>Post-run state</strong>
<span>Final mock control-plane revision {embeddedEval.run.finalRevision}</span>
<span>{embeddedEval.publication ? "Company-state details withheld from public replay" : `Final mock control-plane revision ${embeddedEval.run.finalRevision}`}</span>
<EvalAssertions assertions={embeddedEval.checks.filter((check) => check.anchor.kind === "run")} />
</div>
) : null}
@ -1425,7 +1426,7 @@ export function App() {
) : null}
</div>
<Composer
{embeddedEval === null ? <Composer
model={snapshot.composer}
sessionId={snapshot.sessionId}
onSend={send}
@ -1439,7 +1440,7 @@ export function App() {
.getElementById(`interaction-${interactionId}`)
?.scrollIntoView({ block: "center" });
}}
/>
/> : null}
</main>
{showPanel && layout === "side" ? (

View File

@ -23,6 +23,7 @@ export interface EvalInspectorReport {
disposition: string;
passed: boolean;
checks: EvalAssertion[];
publication?: { schema: string; notice: string };
run: {
model: string;
provider: string;
@ -41,7 +42,7 @@ export interface EvalInspectorReport {
runnerBuild: string;
startedAt: string;
finishedAt: string;
durationMs: number;
durationMs: number | null;
initialRevision: number;
finalRevision: number;
usage: {
@ -53,7 +54,7 @@ export interface EvalInspectorReport {
reasoningTokens: number;
providerReportedCostNanodollars?: number;
estimatedCostNanodollars: number;
pricingVersion: string;
pricingVersion?: string;
} | null;
};
}
@ -197,6 +198,149 @@ function documentsOf(state: Json): Array<{
});
}
export function EvalReportInspector({
evalReport,
}: {
evalReport: EvalInspectorReport;
}) {
const isPublic = Boolean(evalReport.publication);
return (
<div className="pit-devtools-pane pit-eval-inspector">
<div className="pit-eval-summary-head">
<a href="../../index.html"> Eval suite</a>
<strong>
{evalReport.passed
? "PASS"
: evalReport.disposition.replaceAll("_", " ").toUpperCase()}
</strong>
<code>{evalReport.attemptId}</code>
</div>
<dl className="pit-eval-run-facts">
<div>
<dt>Model</dt>
<dd>
{evalReport.run.model.startsWith(`${evalReport.run.provider}/`)
? evalReport.run.model
: `${evalReport.run.provider}/${evalReport.run.model}`}
</dd>
</div>
<div>
<dt>Configuration</dt>
<dd>{evalReport.run.configuration}</dd>
</div>
<div>
<dt>Session</dt>
<dd>
{isPublic
? "Withheld from public replay"
: evalReport.run.sessionId}
</dd>
</div>
<div>
<dt>Provider session</dt>
<dd>
{isPublic
? "Withheld from public replay"
: (evalReport.run.providerSessionId ?? "unavailable")}
</dd>
</div>
<div>
<dt>Driver</dt>
<dd>
{evalReport.run.driver}
{evalReport.run.providerVersion
? ` · ${evalReport.run.providerVersion}`
: ""}
</dd>
</div>
{evalReport.run.agentVersion ? (
<div>
<dt>Agent version</dt>
<dd>{evalReport.run.agentVersion}</dd>
</div>
) : null}
<div>
<dt>Retained session</dt>
<dd>
{isPublic
? "Withheld from public replay"
: evalReport.run.retainedSession === true
? (evalReport.run.retainedSessionStatus ?? "retained")
: "not applicable"}
</dd>
</div>
<div>
<dt>Duration</dt>
<dd>
{evalReport.run.durationMs == null
? "unavailable"
: `${evalReport.run.durationMs} ms`}
</dd>
</div>
<div>
<dt>Fixture</dt>
<dd>{evalReport.run.fixtureDigest}</dd>
</div>
<div>
<dt>State</dt>
<dd>
{isPublic
? "Withheld from public replay"
: `r${evalReport.run.initialRevision} → r${evalReport.run.finalRevision}`}
</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}
</dd>
</div>
<div>
<dt>Agent turns</dt>
<dd>{evalReport.run.usage?.agentTurns ?? "unknown"}</dd>
</div>
<div>
<dt>Provider requests</dt>
<dd>{evalReport.run.usage?.providerRequests ?? "unavailable"}</dd>
</div>
<div>
<dt>Estimated cost</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)}${evalReport.run.usage.pricingVersion ? ` · ${evalReport.run.usage.pricingVersion}` : ""}`}
</dd>
</div>
<div>
<dt>Provider list cost</dt>
<dd>
{typeof evalReport.run.usage?.providerReportedCostNanodollars !==
"number"
? "unknown"
: `$${(evalReport.run.usage.providerReportedCostNanodollars / 1_000_000_000).toFixed(6)}`}
</dd>
</div>
<div>
<dt>Runner</dt>
<dd>{evalReport.run.runnerPackageDigest}</dd>
</div>
<div>
<dt>Runner build</dt>
<dd>{evalReport.run.runnerBuild}</dd>
</div>
<div>
<dt>runnerd</dt>
<dd>{evalReport.run.runnerdDigest}</dd>
</div>
</dl>
<h3>Assertions</h3>
<EvalAssertions assertions={evalReport.checks} />
</div>
);
}
export function DevtoolsInspector({
snapshot,
onFork,
@ -291,6 +435,8 @@ export function DevtoolsInspector({
className="pit-button"
type="button"
onClick={() => onFork(revision)}
disabled={evalReport != null}
title={evalReport ? "Eval reports are read-only" : undefined}
>
<Icon name="branch" /> Fork r{revision}
</button>
@ -328,124 +474,7 @@ export function DevtoolsInspector({
))}
</div>
{tab === "eval" && evalReport ? (
<div className="pit-devtools-pane pit-eval-inspector">
<div className="pit-eval-summary-head">
<a href="../../index.html"> Eval suite</a>
<strong>
{evalReport.passed
? "PASS"
: evalReport.disposition.replaceAll("_", " ").toUpperCase()}
</strong>
<code>{evalReport.attemptId}</code>
</div>
<dl className="pit-eval-run-facts">
<div>
<dt>Model</dt>
<dd>
{evalReport.run.model.startsWith(`${evalReport.run.provider}/`)
? evalReport.run.model
: `${evalReport.run.provider}/${evalReport.run.model}`}
</dd>
</div>
<div>
<dt>Configuration</dt>
<dd>{evalReport.run.configuration}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{evalReport.run.sessionId}</dd>
</div>
<div>
<dt>Provider session</dt>
<dd>{evalReport.run.providerSessionId ?? "unavailable"}</dd>
</div>
<div>
<dt>Driver</dt>
<dd>
{evalReport.run.driver}
{evalReport.run.providerVersion
? ` · ${evalReport.run.providerVersion}`
: ""}
</dd>
</div>
{evalReport.run.agentVersion ? (
<div>
<dt>Agent version</dt>
<dd>{evalReport.run.agentVersion}</dd>
</div>
) : null}
<div>
<dt>Retained session</dt>
<dd>
{evalReport.run.retainedSession === true
? (evalReport.run.retainedSessionStatus ?? "retained")
: "not applicable"}
</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{evalReport.run.durationMs} ms</dd>
</div>
<div>
<dt>Fixture</dt>
<dd>{evalReport.run.fixtureDigest}</dd>
</div>
<div>
<dt>State</dt>
<dd>
r{evalReport.run.initialRevision} r
{evalReport.run.finalRevision}
</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}
</dd>
</div>
<div>
<dt>Agent turns</dt>
<dd>{evalReport.run.usage?.agentTurns ?? "unknown"}</dd>
</div>
<div>
<dt>Provider requests</dt>
<dd>{evalReport.run.usage?.providerRequests ?? "unavailable"}</dd>
</div>
<div>
<dt>Estimated cost</dt>
<dd>
{evalReport.run.usage === null
? "unknown"
: `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)} · ${evalReport.run.usage.pricingVersion}`}
</dd>
</div>
<div>
<dt>Provider list cost</dt>
<dd>
{typeof evalReport.run.usage
?.providerReportedCostNanodollars !== "number"
? "unknown"
: `$${(evalReport.run.usage.providerReportedCostNanodollars / 1_000_000_000).toFixed(6)}`}
</dd>
</div>
<div>
<dt>Runner</dt>
<dd>{evalReport.run.runnerPackageDigest}</dd>
</div>
<div>
<dt>Runner build</dt>
<dd>{evalReport.run.runnerBuild}</dd>
</div>
<div>
<dt>runnerd</dt>
<dd>{evalReport.run.runnerdDigest}</dd>
</div>
</dl>
<h3>Assertions</h3>
<EvalAssertions assertions={evalReport.checks} />
</div>
<EvalReportInspector evalReport={evalReport} />
) : null}
{tab === "timeline" ? (
<div className="pit-devtools-list">

View File

@ -10,7 +10,7 @@ import type {
CapabilityToolDisposition,
} from "../../../src/issue-thread/types";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { DevtoolsInspector, type CapabilityDevtoolsTab, type EvalInspectorReport } from "./DevtoolsInspector";
import { DevtoolsInspector, EvalReportInspector, type CapabilityDevtoolsTab, type EvalInspectorReport } from "./DevtoolsInspector";
import { Icon } from "./Icons";
import { capabilitySemanticToolDescriptor } from "../../../src/semantic-tools/catalog";
import {
@ -392,7 +392,7 @@ export function EvidencePanel(props: EvidencePanelProps) {
{devtools !== undefined ? (
<>
{devtools === null ? (
<p className="pit-muted pit-devtools-loading">Loading company state</p>
evalReport ? <EvalReportInspector evalReport={evalReport} /> : <p className="pit-muted pit-devtools-loading">Loading company state</p>
) : (
<DevtoolsInspector snapshot={devtools} onFork={onForkRevision} tab={devtoolsTab} onTabChange={setDevtoolsTab} evalReport={evalReport} />
)}

View File

@ -4,11 +4,23 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./issue-thread.css";
// Hosted Evalbooks use inert JSON plus the same trusted viewer bundle. No
// inline executable script or network fetch is needed to load an attempt.
const reportData = document.getElementById("paperclip-eval-report");
if (reportData !== null) {
window.__PAPERCLIP_EVAL_REPORT__ = JSON.parse(
reportData.textContent ?? "null",
);
}
// `?capture=1` freezes animation, caret, and smooth scrolling so the
// screenshot matrix is byte-stable across runs (contract §10.1).
const params = new URLSearchParams(window.location.search);
const hashQuery = window.location.hash.split("?")[1] ?? "";
if (params.get("capture") === "1" || new URLSearchParams(hashQuery).get("capture") === "1") {
if (
params.get("capture") === "1" ||
new URLSearchParams(hashQuery).get("capture") === "1"
) {
document.documentElement.dataset.capture = "true";
}

View File

@ -1,5 +1,48 @@
# Direct live Runner protocol evals
## One Evalbook presentation
Every new report uses the canonical Evalbook grid and the existing Runner Lab
chat viewer for attempt drill-downs. There is no plain-HTML attempt fallback.
Missing recordings show a notice in the same viewer; missing viewer builds
fail generation. Build with
`pnpm --filter @paperclipai/paperclip-runner build:issue-thread` and provide
`--viewer-root` or `PAPERCLIP_EVAL_VIEWER_ROOT` to the canonical Python renderer.
The Actions artifact contains full evidence. S3 uses the same viewer with a
closed public DTO: only isolated mock-run conversation text, scrubbed private
references, named tool outcomes, and checks. Tool arguments/results, reasoning,
provider identities, and company snapshots stay private. The public notice
explains these redactions. An unverified isolation boundary yields no public
conversation, not a guessed reconstruction.
Public attempts use inert JSON and one shared viewer asset directory. The
publisher verifies each shell and asset against the exact same-run viewer build,
checks the public payload contract and local links, and rejects other scripts.
The CSP prohibits network calls, forms and external resources. Supply
`PAPERCLIP_RUNNER_PROTOCOL_EVAL_VIEWER_DIR` to the publisher. The workflow sends
a viewer-only artifact to that job; raw attempts and provider secrets stay out.
### Refresh a completed report without calling models
Download the aggregate Actions artifact, then:
```sh
node packages/paperclip-runner/scripts/refresh-runner-protocol-eval-report.mjs \
--source /path/to/downloaded-aggregate \
--evals-root /path/to/paperclip-evals \
--viewer-root packages/paperclip-runner/dist-issue-thread \
--output /path/to/new-refresh-directory \
--revision chat-v1
```
Publish the returned `reportRoot` with the normal history publisher. The new ID
is `gha-RUN-ATTEMPT-report-chat-v1`. Original reports remain immutable; history
adds a labeled refresh and retains the source campaign, original measurement
timestamp, renderer digest, and `providerCalls: 0`. Scores and evaluated source
revisions do not change. This is not a new model qualification run. Future live
runs create chat reports automatically.
This is the provider-backed, one-turn protocol qualification layer in
`paperclipai/paperclip-evals/evals/paperclip-runner`. It is intentionally
separate from both the browser full-stack model E2E and the stress-derived
@ -149,11 +192,11 @@ read-only Runner issue-thread attempt pages, and raw immutable run records.
Public publishing uses a separate projection and a separate trusted OIDC job.
The projection retains model/config identity, status, usage totals, and check
outcomes but removes provider session identifiers, transcripts, semantic-tool
outcomes and scrubbed mock conversation, but removes provider session identifiers, semantic-tool
payloads, state revisions, traces, remote profile identities, and raw failure
text. The same Evalbook `report` command renders that projection, so the public
grid and test pages have the standard Evalbook layout. The publisher rejects
scripts, remote resources, symlinks, unknown paths, broken links, raw session
grid, test pages and chat viewer have the standard Evalbook layout. The publisher rejects
untrusted scripts, remote resources, symlinks, unknown paths, broken links, raw session
fields, and credential-shaped values.
S3 publication is additive:

View File

@ -115,7 +115,7 @@
"test:capability-evals": "vitest run src/conformance/capability-eval-suite.test.ts",
"test:eval-slice": "pnpm run ensure:eval-build-deps && vitest run src/eval",
"test:runner-workflow-evals": "pnpm run ensure:eval-build-deps && node --test scripts/render-runner-workflow-evalbook.test.mjs && vitest run src/eval/workflow-evals.test.ts src/eval/live-workflow-executor.test.ts",
"test:runner-protocol-eval-publish": "node --test scripts/runner-protocol-eval-campaign.test.mjs scripts/publish-runner-protocol-eval-history.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs",
"test:runner-protocol-eval-publish": "node --test scripts/runner-protocol-eval-campaign.test.mjs scripts/publish-runner-protocol-eval-history.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs scripts/public-eval-chat.test.mjs",
"check:runner-workflow-traceability": "pnpm run build:typescript && node scripts/check-runner-workflow-traceability.mjs",
"report:capability-evals": "pnpm run build:typescript && node scripts/run-capability-eval-suite.mjs",
"report:capability-live-evals": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/run-capability-live-eval-matrix.mjs",

View File

@ -0,0 +1,216 @@
// Public replay is a new DTO, never a recursive copy of a provider artifact.
export const PUBLIC_CHAT_SCHEMA =
"paperclip.runner-protocol-eval.public-chat/v1";
export const PUBLIC_CHAT_NOTICE =
"Public replay of an isolated mock eval. Conversation text is scrubbed; provider identities, tool payloads, traces, and company-state snapshots are withheld. Full evidence remains in the access-controlled Actions artifact.";
export const SECRET_TEXT = [
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu,
/\bsk-[A-Za-z0-9_-]{16,}\b/gu,
/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]{16,}\b/gu,
/\bBearer\s+[A-Za-z0-9._~+\/-]{8,}=*/giu,
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gu,
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu,
/\b(?:https?|file|s3):\/\/[^\s<>"')]+/giu,
/\barn:aws[^\s<>"')]+/gu,
/(?:\/(?:Users|home|tmp|private|var)\/|[A-Z]:\\)[^\s<>"')]+/gu,
/\b(?:api[_-]?key|access[_-]?token|secret|password|authorization|cookie)\s*[=:]\s*[^\s,;]+/giu,
];
export function publicText(value, privateValues = []) {
let text = typeof value === "string" ? value : "";
for (const secret of privateValues) {
if (typeof secret === "string" && secret.length >= 8)
text = text.replaceAll(secret, "[redacted]");
}
for (const pattern of SECRET_TEXT) text = text.replace(pattern, "[redacted]");
return text.length > 40_000 ? `${text.slice(0, 40_000)}\n[truncated]` : text;
}
function privateIdentities(value, found = new Set()) {
if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
if (
/(?:session|profile|account|runtime|endpoint|memory|agentversion).*id$|arn$|token$|secret$|password$/i.test(
key,
) &&
typeof child === "string"
)
found.add(child);
else if (child && typeof child === "object")
privateIdentities(child, found);
}
}
return [...found];
}
function timestamp(value) {
return typeof value === "string" && /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(value)
? value
: "1970-01-01T00:00:00.000Z";
}
function operation(value) {
return typeof value === "string" && /^[a-z][a-z_]{0,79}$/.test(value)
? value
: "unknown_operation";
}
export function publicChatView(artifact, evalCase) {
const privateValues = privateIdentities(artifact);
const scrub = (value) => publicText(value, privateValues);
const network = artifact.snapshot?.networkEvidence;
// Only the dedicated mock eval boundary can publish recorded conversation.
// Early infrastructure failures still receive a viewer with an honest notice.
const isolated =
network?.realPaperclipRequests === 0 &&
Array.isArray(network?.childPaperclipEnvironmentKeys) &&
network.childPaperclipEnvironmentKeys.length === 0;
const source =
isolated &&
artifact.issueThread?.schema === "paperclip.capability.issue-thread-view.v1"
? artifact.issueThread
: null;
const evidence = Object.fromEntries(
[
"tools",
"calls",
"authorization",
"control_plane",
"runner",
"state",
"traceability",
"parity",
].map((key) => [key, []]),
);
const turns = (source?.turns ?? []).map((turn, turnIndex) => {
const turnId = `public-turn-${turnIndex + 1}`;
const items = [];
for (const item of turn.items ?? []) {
const base = {
id: `public-item-${turnIndex + 1}-${items.length + 1}`,
at: timestamp(item.at),
};
if (
["user_message", "agent_message", "durable_comment"].includes(item.kind)
) {
items.push({
...base,
kind: item.kind === "user_message" ? "user_message" : "agent_message",
author: item.kind === "user_message" ? "You (eval prompt)" : "Agent",
body: scrub(item.body),
streaming: false,
});
} else if (item.kind === "tool_activity") {
const operationId = operation(item.operationId);
const status = ["ok", "denied", "running"].includes(item.status)
? item.status
: "running";
const result = {
outcome: status,
detail: "Tool payload withheld from public replay.",
};
const recordId = `public-call-${turnIndex + 1}-${items.length + 1}`;
items.push({
...base,
kind: "tool_activity",
operationId,
status,
summary: `${operationId}: ${status}`,
input: { detail: "Arguments withheld from public replay." },
result,
evidenceRef: { section: "calls", recordId },
});
if (status !== "running")
evidence.calls.push({
id: recordId,
turnId,
operationId,
version: 1,
providerRequest: operationId,
dispatchedCommand: operationId,
outcome: status,
result,
redactions: ["arguments", "result payload", "provider identities"],
threadAnchorId: base.id,
});
}
// Provider activity, reasoning, raw events, file refs and unrecognized
// future item kinds are deliberately not part of the public contract.
}
return {
id: turnId,
ordinal: turnIndex + 1,
mode: "replay",
toolCallCount: items.filter((item) => item.kind === "tool_activity")
.length,
at: timestamp(turn.at),
stoppedByUser: turn.stoppedByUser === true,
items,
};
});
if (!turns.length) {
turns.push({
id: "public-turn-1",
ordinal: 1,
mode: "replay",
toolCallCount: 0,
at: timestamp(artifact.snapshot?.createdAt),
stoppedByUser: false,
items: [
{
id: "public-notice",
at: timestamp(artifact.snapshot?.createdAt),
kind: "system_notice",
glyph: "",
text: "No publishable conversation was recorded for this attempt. See the checks and the access-controlled artifact for diagnostics.",
evidenceRef: { section: "runner", recordId: "public-notice" },
},
],
});
}
return {
schema: "paperclip.capability.issue-thread-view.v1",
sessionId: "public-report",
mode: "replay",
identity: {
agentLabel: "Recorded agent",
runnerLabel: "Recorded runner",
runnerAttached: false,
controlPlaneLabel: "Mock Paperclip",
controlPlaneTooltip: PUBLIC_CHAT_NOTICE,
replaySource: "live",
},
issue: {
identifier: "EVAL",
title: scrub(evalCase.title || evalCase.id),
status: [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"blocked",
"cancelled",
].includes(source?.issue?.status)
? source.issue.status
: "in_review",
priority: "medium",
assignee: null,
runState: "Read-only public replay",
scenarioId: evalCase.id,
fixtureProfile: evalCase.id,
},
turns,
composer: {
state: "disabled",
helper: null,
reason: "Read-only eval report",
pendingInteractionId: null,
},
evidence,
connection: { state: "closed", attempt: 0 },
replay: null,
renderedAt: timestamp(source?.renderedAt || artifact.snapshot?.createdAt),
};
}

View File

@ -0,0 +1,234 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, cp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
publicChatView,
publicText,
PUBLIC_CHAT_SCHEMA,
PUBLIC_CHAT_NOTICE,
} from "./public-eval-chat.mjs";
import {
publicViewerShell,
validatePublicChatPayload,
trustedViewerFiles,
} from "./public-eval-viewer.mjs";
import { validatePublicProtocolEvalReport } from "./publish-runner-protocol-eval-history.mjs";
function artifact() {
return {
providerSessionId: "private-session-canary",
snapshot: {
networkEvidence: {
realPaperclipRequests: 0,
childPaperclipEnvironmentKeys: [],
},
},
issueThread: {
schema: "paperclip.capability.issue-thread-view.v1",
issue: { status: "blocked" },
turns: [
{
items: [
{ kind: "user_message", body: "Please block this task." },
{
kind: "agent_message",
body: "Blocked. private-session-canary https://private.example/path",
privateField: "private-field-canary",
},
{
kind: "tool_activity",
operationId: "block_task",
status: "ok",
input: { secret: "argument-canary" },
result: { token: "result-canary" },
},
{ kind: "thinking", body: "reasoning-canary" },
{ kind: "future_kind", body: "future-canary" },
],
},
],
},
};
}
function payload() {
return {
attemptId: "attempt-01",
caseId: "block-task",
disposition: "pass",
passed: true,
checks: [],
publication: { schema: PUBLIC_CHAT_SCHEMA, notice: PUBLIC_CHAT_NOTICE },
view: publicChatView(artifact(), { id: "block-task" }),
devtools: null,
navigation: { suiteHref: "../../index.html", previous: null, next: null },
run: {
model: "test",
provider: "test",
sessionId: "public-report",
effectiveModelHistory: [],
managedProfile: null,
acpxProfile: null,
usage: null,
},
};
}
test("projects only isolated recorded messages and bounded tool facts", () => {
const view = publicChatView(artifact(), { id: "block-task" });
assert.equal(view.issue.status, "blocked");
assert.deepEqual(
view.turns[0].items.map((item) => item.kind),
["user_message", "agent_message", "tool_activity"],
);
assert.match(view.turns[0].items[1].body, /Blocked/);
assert.doesNotMatch(JSON.stringify(view), /canary|private\.example/);
assert.deepEqual(view.turns[0].items[2].input, {
detail: "Arguments withheld from public replay.",
});
validatePublicChatPayload(payload());
for (const networkEvidence of [
undefined,
{ realPaperclipRequests: 1, childPaperclipEnvironmentKeys: [] },
{
realPaperclipRequests: 0,
childPaperclipEnvironmentKeys: ["PAPERCLIP_API_KEY"],
},
]) {
const source = artifact();
source.snapshot.networkEvidence = networkEvidence;
const unavailable = publicChatView(source, { id: "block-task" });
assert.equal(unavailable.turns[0].items[0].kind, "system_notice");
assert.doesNotMatch(JSON.stringify(unavailable), /Please block|Blocked\./);
}
});
test("scrubs credentials and private references before truncating text", () => {
for (const secret of [
"sk-" + "a".repeat(32),
"ghp_" + "b".repeat(32),
"Bearer abcdef123456",
"password=secret-canary",
"/Users/someone/private.txt",
"arn:aws:service:region:account:resource",
"-----BEGIN PRIVATE KEY-----\n" +
"x".repeat(41_000) +
"\n-----END PRIVATE KEY-----",
]) {
assert.equal(publicText(secret), "[redacted]");
}
assert.match(publicText("a".repeat(41_000)), /\[truncated\]$/);
});
test("fails closed on unknown fields, raw tools, private evidence and identities", () => {
for (const mutate of [
(p) => {
p.view.turns[0].items[0].extra = "unprojected";
},
(p) => {
p.view.turns[0].items[2].input = { password: "oops" };
},
(p) => {
p.view.evidence.calls[0].result.detail = "raw-result";
},
(p) => {
p.view.evidence.state.push({ secret: "raw-state" });
},
(p) => {
p.run.providerSessionId = "private-session";
},
(p) => {
p.view.turns[0].items[1].body = "sk-" + "x".repeat(30);
},
(p) => {
p.devtools = {};
},
(p) => {
p.view.composer.state = "ready";
},
]) {
const value = payload();
mutate(value);
assert.throws(() => validatePublicChatPayload(value));
}
});
test("publisher permits only the exact trusted shell/assets and valid local navigation", async () => {
const root = await mkdtemp(join(tmpdir(), "eval-chat-contract-"));
try {
const viewer = join(root, "trusted");
const report = join(root, "report");
await mkdir(join(viewer, "assets"), { recursive: true });
await mkdir(join(report, "attempts/attempt-01"), { recursive: true });
const index =
'<!doctype html><html><head><script type="module" src="./assets/app.js"></script><link rel="stylesheet" href="./assets/app.css"></head><body><div id="root"></div></body></html>';
await writeFile(join(viewer, "index.html"), index);
await writeFile(join(viewer, "assets/app.js"), "// trusted build");
await writeFile(join(viewer, "assets/app.css"), ":root {}");
await cp(join(viewer, "assets"), join(report, "viewer/assets"), {
recursive: true,
});
await writeFile(
join(report, "index.html"),
'<a href="attempts/attempt-01/index.html">PASS</a>',
);
await writeFile(
join(report, "campaign.json"),
JSON.stringify({
schema: "paperclip.runner-protocol-eval.campaign/v1",
campaignId: "gha-42-1",
}),
);
const page = join(report, "attempts/attempt-01/index.html");
const writePayload = async (value) =>
writeFile(
page,
publicViewerShell(
index,
JSON.stringify(value).replaceAll("<", "\\u003c"),
),
);
await writePayload(payload());
await validatePublicProtocolEvalReport(report, { viewerRoot: viewer });
await assert.rejects(validatePublicProtocolEvalReport(report));
await writeFile(
join(report, "viewer/assets/app.js"),
"// substituted build",
);
await assert.rejects(
validatePublicProtocolEvalReport(report, { viewerRoot: viewer }),
/trusted/,
);
await writeFile(join(report, "viewer/assets/app.js"), "// trusted build");
await writeFile(
page,
publicViewerShell(index, JSON.stringify(payload())) +
"<script>alert(1)</script>",
);
await assert.rejects(
validatePublicProtocolEvalReport(report, { viewerRoot: viewer }),
/trusted shell/,
);
const escaped = payload();
escaped.view.turns[0].items[0].body =
'</script><script>alert("not executable")</script>';
await writePayload(escaped);
await validatePublicProtocolEvalReport(report, { viewerRoot: viewer });
const broken = payload();
broken.navigation.next = {
label: "Next attempt",
href: "../missing/index.html",
};
await writePayload(broken);
await assert.rejects(
validatePublicProtocolEvalReport(report, { viewerRoot: viewer }),
/link|reference/i,
);
await symlink(viewer, join(root, "symlink"));
await assert.rejects(trustedViewerFiles(join(root, "symlink")));
} finally {
await rm(root, { recursive: true, force: true });
}
});

View File

@ -0,0 +1,229 @@
import { readFile, readdir, lstat } from "node:fs/promises";
import { join } from "node:path";
import { PUBLIC_CHAT_SCHEMA, SECRET_TEXT } from "./public-eval-chat.mjs";
export const PUBLIC_VIEWER_CSP =
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'";
export const PUBLIC_VIEWER_DATA =
/<script type="application\/json" id="paperclip-eval-report">([^<]*)<\/script>/u;
const ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*\.(?:js|css|woff2)$/;
export function publicViewerShell(index, encodedPayload) {
return index
.replaceAll('"./assets/', '"../../viewer/assets/')
.replace(
"<head>",
`<head>\n <meta http-equiv="Content-Security-Policy" content="${PUBLIC_VIEWER_CSP}">`,
)
.replace(
'<script type="module"',
`<script type="application/json" id="paperclip-eval-report">${encodedPayload}</script>\n <script type="module"`,
);
}
export async function trustedViewerFiles(viewerRoot) {
if (!viewerRoot || !(await lstat(viewerRoot)).isDirectory())
throw new Error(
"A trusted viewer build is required for public chat reports",
);
if (
!(await lstat(join(viewerRoot, "index.html"))).isFile() ||
!(await lstat(join(viewerRoot, "assets"))).isDirectory()
)
throw new Error("Trusted viewer must not use symlinks");
const index = await readFile(join(viewerRoot, "index.html"), "utf8");
const files = new Map();
for (const entry of await readdir(join(viewerRoot, "assets"), {
withFileTypes: true,
})) {
if (!entry.isFile() || entry.isSymbolicLink() || !ASSET.test(entry.name))
throw new Error("Unexpected trusted viewer asset");
files.set(
`viewer/assets/${entry.name}`,
await readFile(join(viewerRoot, "assets", entry.name)),
);
}
if (
![...files.keys()].some((name) => name.endsWith(".js")) ||
!index.includes('<script type="module"')
)
throw new Error("Incomplete trusted viewer build");
return { index, files };
}
export function validatePublicChatPayload(payload) {
if (
payload?.publication?.schema !== PUBLIC_CHAT_SCHEMA ||
payload.view?.sessionId !== "public-report" ||
payload.view?.composer?.state !== "disabled" ||
payload.view?.connection?.state !== "closed" ||
payload.devtools !== null
)
throw new Error(
"Public attempt must contain the read-only public chat projection",
);
const allowed = new Set([
"attemptId",
"caseId",
"disposition",
"passed",
"checks",
"view",
"devtools",
"navigation",
"run",
"publication",
]);
if (Object.keys(payload).some((key) => !allowed.has(key)))
throw new Error("Unknown public chat payload field");
const fields = (value, names) => {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).some((key) => !names.split(" ").includes(key))
)
throw new Error("Unknown public chat projection field");
};
fields(payload.publication, "schema notice");
fields(payload.navigation, "suiteHref previous next");
for (const link of [payload.navigation.previous, payload.navigation.next])
if (link !== null) fields(link, "label href");
fields(
payload.run,
"model provider driver providerVersion runnerProvider acpxAgent acpxProfile requestedModel effectiveModelHistory configuration sessionId providerSessionId agentVersion managedProfile retainedSession retainedSessionStatus fixtureDigest runnerPackageDigest runnerdDigest startedAt finishedAt durationMs runnerBuild initialRevision finalRevision usage",
);
if (
payload.run.effectiveModelHistory?.length ||
payload.run.managedProfile != null ||
payload.run.acpxProfile != null
)
throw new Error("Public replay contains private provider metadata");
if (payload.run.usage !== null)
fields(
payload.run.usage,
"agentTurns providerRequests inputTokens outputTokens cachedInputTokens reasoningTokens providerReportedCostNanodollars estimatedCostNanodollars pricingVersion",
);
fields(
payload.view,
"schema sessionId mode identity issue turns composer evidence connection replay renderedAt",
);
fields(
payload.view.identity,
"agentLabel runnerLabel runnerAttached controlPlaneLabel controlPlaneTooltip replaySource",
);
fields(
payload.view.issue,
"identifier title status priority assignee runState scenarioId fixtureProfile",
);
fields(payload.view.composer, "state helper reason pendingInteractionId");
fields(payload.view.connection, "state attempt");
fields(
payload.view.evidence,
"tools calls authorization control_plane runner state traceability parity",
);
for (const check of payload.checks) {
fields(
check,
"id kind passed detail evidenceRefs title description definition anchor",
);
fields(check.definition, "id kind");
fields(check.anchor, "kind id");
if (check.evidenceRefs.length)
throw new Error("Public replay contains raw evidence references");
}
const visit = (value, key = "") => {
if (typeof value === "string") {
if (
/(?:sessionId|providerSessionId)$/i.test(key) &&
!["public-report", "unknown", "redacted"].includes(value)
)
throw new Error("Public replay contains a private session identity");
for (const pattern of SECRET_TEXT) {
pattern.lastIndex = 0;
if (pattern.test(value))
throw new Error(
"Public replay contains credential or private reference material",
);
}
} else if (value && typeof value === "object") {
for (const [name, child] of Object.entries(value)) {
if (
/^(?:managedProfile|acpxProfile|providerTrace|mockState|stateHistory|trace|environment|env|apiKey|accessToken|password|secret)$/i.test(
name,
) &&
child != null
)
throw new Error("Public replay contains a private field");
visit(child, name);
}
}
};
visit(payload);
for (const section of [
"tools",
"authorization",
"control_plane",
"runner",
"state",
"traceability",
"parity",
]) {
if (
!Array.isArray(payload.view.evidence?.[section]) ||
payload.view.evidence[section].length
)
throw new Error("Public replay contains unprojected evidence");
}
for (const call of payload.view.evidence.calls) {
fields(
call,
"id turnId operationId version providerRequest dispatchedCommand outcome result redactions threadAnchorId",
);
fields(call.result, "outcome detail");
if (call.result.detail !== "Tool payload withheld from public replay.")
throw new Error("Public replay contains raw call evidence");
}
for (const turn of payload.view.turns ?? []) {
fields(turn, "id ordinal mode toolCallCount at stoppedByUser items");
for (const item of turn.items ?? []) {
if (
![
"user_message",
"agent_message",
"tool_activity",
"system_notice",
].includes(item.kind)
)
throw new Error("Public replay contains an unprojected item");
const shapes = {
user_message: "kind id at author body streaming",
agent_message: "kind id at author body streaming",
tool_activity:
"kind id at operationId status summary input result evidenceRef",
system_notice: "kind id at glyph text evidenceRef",
};
fields(item, shapes[item.kind]);
if (item.evidenceRef) fields(item.evidenceRef, "section recordId");
if (
item.kind === "tool_activity" &&
(JSON.stringify(item.input) !==
JSON.stringify({
detail: "Arguments withheld from public replay.",
}) ||
Object.keys(item.result).sort().join(",") !== "detail,outcome" ||
item.result.detail !== "Tool payload withheld from public replay.")
)
throw new Error("Public replay contains a raw tool payload");
}
}
}
export function validatePublicViewerPage(content, trustedIndex) {
const match = content.match(PUBLIC_VIEWER_DATA);
if (!match || publicViewerShell(trustedIndex, match[1]) !== content)
throw new Error("Public viewer page differs from the trusted shell");
const payload = JSON.parse(match[1]);
validatePublicChatPayload(payload);
return payload;
}

View File

@ -13,13 +13,20 @@ import {
import { tmpdir } from "node:os";
import { extname, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import {
trustedViewerFiles,
validatePublicViewerPage,
} from "./public-eval-viewer.mjs";
const execFileAsync = promisify(execFile);
const SAFE_CAMPAIGN = /^gha-[1-9][0-9]*-[1-9][0-9]*$/;
const SAFE_CAMPAIGN =
/^gha-[1-9][0-9]*-[1-9][0-9]*(?:-report-[a-z0-9][a-z0-9-]{0,39})?$/;
const SAFE_REPORT_PATHS = [
/^(?:index|latest|inventory|real-server)\.html$/,
/^tests\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.html$/,
/^attempts\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.html$/,
/^attempts\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}\/index\.html$/,
/^viewer\/assets\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:js|css|woff2)$/,
/^campaign\.json$/,
];
const CREDENTIAL_PATTERNS = [
@ -136,9 +143,16 @@ function internalHtmlHrefs(content) {
.filter((href) => href && !href.startsWith("#"));
}
export async function validatePublicProtocolEvalReport(reportRoot) {
export async function validatePublicProtocolEvalReport(
reportRoot,
{ viewerRoot } = {},
) {
const root = resolve(reportRoot);
const files = await relativeFiles(root);
const hasChat = files.some((file) =>
/^attempts\/[^/]+\/index\.html$/.test(file),
);
const viewer = hasChat ? await trustedViewerFiles(viewerRoot) : null;
if (!files.includes("index.html") || !files.includes("campaign.json")) {
throw new Error(
"Public protocol eval report requires index.html and campaign.json",
@ -157,21 +171,44 @@ export async function validatePublicProtocolEvalReport(reportRoot) {
`Public protocol eval file exceeds its size boundary: ${file}`,
);
}
if (file.startsWith("viewer/")) {
const expected = viewer?.files.get(file);
if (!expected || !expected.equals(await readFile(absolute)))
throw new Error(
`Public viewer asset differs from trusted build: ${file}`,
);
continue;
}
const content = await readFile(absolute, "utf8");
for (const pattern of CREDENTIAL_PATTERNS) {
if (pattern.test(content))
throw new Error(
`Public report contains credential/session material: ${file}`,
);
const richAttempt = /^attempts\/[^/]+\/index\.html$/.test(file);
const payload = richAttempt
? validatePublicViewerPage(content, viewer.index)
: null;
if (!richAttempt) {
for (const pattern of CREDENTIAL_PATTERNS) {
if (pattern.test(content))
throw new Error(
`Public report contains credential/session material: ${file}`,
);
}
if (extname(file) !== ".html") continue;
for (const pattern of ACTIVE_HTML_PATTERNS) {
if (pattern.test(content))
throw new Error(
`Public report contains active or remote HTML: ${file}`,
);
}
}
if (extname(file) !== ".html") continue;
for (const pattern of ACTIVE_HTML_PATTERNS) {
if (pattern.test(content))
throw new Error(
`Public report contains active or remote HTML: ${file}`,
);
}
for (const href of internalHtmlHrefs(content)) {
const navigation = payload
? [
payload.navigation?.suiteHref,
payload.navigation?.previous?.href,
payload.navigation?.next?.href,
].filter(Boolean)
: [];
for (const href of [...internalHtmlHrefs(content), ...navigation]) {
if (typeof href !== "string" || /[?:\\]|^\/|^[a-z]+:/i.test(href))
throw new Error(`Unsafe report navigation in ${file}`);
const clean = href.split("#", 1)[0].split("?", 1)[0];
const target = resolve(
root,
@ -189,6 +226,15 @@ export async function validatePublicProtocolEvalReport(reportRoot) {
}
}
}
if (viewer) {
for (const file of viewer.files.keys())
if (!files.includes(file))
throw new Error(`Missing public viewer asset: ${file}`);
if (files.some((file) => /^attempts\/[^/]+\.html$/.test(file)))
throw new Error(
"Chat Evalbook must not mix in legacy plain attempt pages",
);
}
const campaign = await loadObject(join(root, "campaign.json"));
if (
campaign.schema !== "paperclip.runner-protocol-eval.campaign/v1" ||
@ -199,11 +245,17 @@ export async function validatePublicProtocolEvalReport(reportRoot) {
return { files, campaign };
}
export async function createProtocolEvalBundleManifest(reportRoot, campaignId) {
export async function createProtocolEvalBundleManifest(
reportRoot,
campaignId,
{ viewerRoot } = {},
) {
if (!SAFE_CAMPAIGN.test(campaignId))
throw new Error("Unsafe protocol eval campaign ID");
const { files, campaign } =
await validatePublicProtocolEvalReport(reportRoot);
const { files, campaign } = await validatePublicProtocolEvalReport(
reportRoot,
{ viewerRoot },
);
if (campaign.campaignId !== campaignId)
throw new Error("Report campaign ID does not match publication target");
const entries = await Promise.all(
@ -250,6 +302,9 @@ export function protocolEvalHistoryRecord(campaign, publicRoot) {
totals: campaign.totals,
rosters: campaign.rosters,
source: campaign.source,
...(campaign.reportRevision
? { reportRevision: campaign.reportRevision }
: {}),
};
}
@ -278,9 +333,7 @@ export function mergeProtocolEvalHistory(history, record) {
const retained = campaigns.slice(0, MAX_HISTORY_CAMPAIGNS);
if (
latestGreen &&
!retained.some(
(campaign) => campaign.campaignId === latestGreen.campaignId,
)
!retained.some((campaign) => campaign.campaignId === latestGreen.campaignId)
) {
retained[retained.length - 1] = latestGreen;
}
@ -343,7 +396,7 @@ export function renderProtocolEvalHistoryIndex(history) {
`${html(roster.model)} · ${roster.passed}/${roster.selected}`,
)
.join("<br>");
return `<tr><td><a href="${html(campaign.publicUrl)}"><code>${html(campaign.campaignId)}</code></a><small>${html(date(campaign.generatedAt))} UTC</small></td><td><span class="status ${status}">${status}</span></td><td><strong>${html(campaign.totals.passed)}/${html(campaign.totals.selected)}</strong><small>${html(campaign.totals.behaviorFailures)} behavior · ${html(campaign.totals.infrastructureFailures)} infrastructure</small></td><td>${rosters}</td><td><code>${html(campaign.source?.paperclip?.sha?.slice(0, 8) ?? "unknown")}</code><small>evals ${html(campaign.source?.evals?.sha?.slice(0, 8) ?? "unknown")}</small></td><td><a href="${html(campaign.publicUrl)}">Open Evalbook →</a></td></tr>`;
return `<tr><td><a href="${html(campaign.publicUrl)}"><code>${html(campaign.campaignId)}</code></a><small>${html(date(campaign.generatedAt))} UTC</small>${campaign.reportRevision ? `<small>Report refresh · no new model calls · source ${html(campaign.reportRevision.sourceCampaignId)}</small>` : ""}</td><td><span class="status ${status}">${status}</span></td><td><strong>${html(campaign.totals.passed)}/${html(campaign.totals.selected)}</strong><small>${html(campaign.totals.behaviorFailures)} behavior · ${html(campaign.totals.infrastructureFailures)} infrastructure</small></td><td>${rosters}</td><td><code>${html(campaign.source?.paperclip?.sha?.slice(0, 8) ?? "unknown")}</code><small>evals ${html(campaign.source?.evals?.sha?.slice(0, 8) ?? "unknown")}</small></td><td><a href="${html(campaign.publicUrl)}">Open Evalbook →</a></td></tr>`;
})
.join("")
: '<tr><td colspan="6" class="empty">No campaigns have been published yet.</td></tr>';
@ -435,13 +488,20 @@ async function uploadImmutableReport(bucket, prefix, reportRoot) {
);
}
export async function publishProtocolEvalHistory({ reportRoot, destination }) {
export async function publishProtocolEvalHistory({
reportRoot,
destination,
viewerRoot,
}) {
const validatedDestination =
validateProtocolEvalHistoryDestination(destination);
const { campaign } = await validatePublicProtocolEvalReport(reportRoot);
const { campaign } = await validatePublicProtocolEvalReport(reportRoot, {
viewerRoot,
});
const manifest = await createProtocolEvalBundleManifest(
reportRoot,
campaign.campaignId,
{ viewerRoot },
);
const temporary = await mkdtemp(
join(tmpdir(), "runner-protocol-eval-history-"),
@ -518,6 +578,7 @@ export async function publishProtocolEvalHistory({ reportRoot, destination }) {
async function main() {
const result = await publishProtocolEvalHistory({
viewerRoot: process.env.PAPERCLIP_RUNNER_PROTOCOL_EVAL_VIEWER_DIR,
reportRoot: resolve(
process.env.PAPERCLIP_RUNNER_PROTOCOL_EVAL_PUBLIC_REPORT_DIR ??
"runner-protocol-eval-public-report",

View File

@ -111,12 +111,13 @@ test("keeps publication to the canonical static Evalbook surface", () => {
"inventory.html",
"tests/get-context.html",
"attempts/attempt-01.html",
"attempts/attempt-01/index.html",
"viewer/assets/index-build.js",
"campaign.json",
]) {
assert.equal(isPublicProtocolEvalPath(file), true, file);
}
for (const file of [
"attempts/attempt-01/index.html",
"runs/attempt/artifact.json",
"provider-trace.log",
"../secret",

View File

@ -0,0 +1,99 @@
#!/usr/bin/env node
// Re-render immutable recorded evidence; this command never invokes a model.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile, lstat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { sanitizeProtocolEvalRuns } from "./runner-protocol-eval-campaign.mjs";
import { validatePublicProtocolEvalReport } from "./publish-runner-protocol-eval-history.mjs";
export async function refreshProtocolEvalReport({
sourceRoot,
evalsRoot,
viewerRoot,
outputRoot,
revision,
renderedAt = new Date().toISOString(),
}) {
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(revision ?? ""))
throw new Error("A safe, unique --revision is required");
if (await lstat(outputRoot).catch(() => null))
throw new Error("Report refresh output must be a new directory");
const campaign = JSON.parse(
await readFile(join(sourceRoot, "campaign.json"), "utf8"),
);
if (!/^gha-[1-9][0-9]*-[1-9][0-9]*$/.test(campaign.campaignId ?? ""))
throw new Error("Expected an original Actions campaign");
const program = join(
evalsRoot,
"evals/paperclip-runner/tools/eval_program.py",
);
const rendererDigest = createHash("sha256")
.update(await readFile(program))
.digest("hex");
await mkdir(outputRoot, { recursive: true });
const runsRoot = join(outputRoot, "public-runs");
const reportRoot = join(outputRoot, "report");
await sanitizeProtocolEvalRuns({
runsRoot: join(sourceRoot, "runs"),
publicRunsRoot: runsRoot,
});
execFileSync(
"python3",
[
program,
"report",
"--runs-root",
runsRoot,
"--output",
reportRoot,
"--viewer-root",
viewerRoot,
"--public-viewer",
"--inventory",
join(evalsRoot, "evals/paperclip-runner/inventory.json"),
"--coverage-matrix",
join(evalsRoot, "evals/paperclip-runner/coverage-matrix.json"),
],
{ stdio: "inherit" },
);
const refreshed = {
...campaign,
campaignId: `${campaign.campaignId}-report-${revision}`,
generatedAt: renderedAt,
reportRevision: {
sourceCampaignId: campaign.campaignId,
sourceGeneratedAt: campaign.generatedAt,
renderedAt,
rendererDigest,
providerCalls: 0,
},
};
await writeFile(
join(reportRoot, "campaign.json"),
`${JSON.stringify(refreshed, null, 2)}\n`,
);
await validatePublicProtocolEvalReport(reportRoot, { viewerRoot });
return { reportRoot, campaignId: refreshed.campaignId, providerCalls: 0 };
}
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(import.meta.filename)
) {
const arg = (name) => {
const index = process.argv.indexOf(name);
if (index < 0 || !process.argv[index + 1])
throw new Error(`Missing ${name}`);
return process.argv[index + 1];
};
console.log(
await refreshProtocolEvalReport({
sourceRoot: resolve(arg("--source")),
evalsRoot: resolve(arg("--evals-root")),
viewerRoot: resolve(arg("--viewer-root")),
outputRoot: resolve(arg("--output")),
revision: arg("--revision"),
}),
);
}

View File

@ -309,6 +309,15 @@ export async function renderRunnerWorkflowWithCanonicalEvalbook({
environment = process.env,
}) {
const program = await resolveCanonicalEvalProgram(packageRoot, environment);
const viewerRoot = resolve(
environment.PAPERCLIP_EVAL_VIEWER_ROOT ??
resolve(packageRoot, "dist-issue-thread"),
);
await access(resolve(viewerRoot, "index.html")).catch(() => {
throw new Error(
"Evalbook requires the chat viewer. Run pnpm --filter @paperclipai/paperclip-runner build:issue-thread first.",
);
});
const runsRoot = resolve(outputDirectory, "evalbook-runs");
await rm(runsRoot, { recursive: true, force: true });
const attempts = await writeRunnerWorkflowEvalbookAttempts({
@ -331,6 +340,8 @@ export async function renderRunnerWorkflowWithCanonicalEvalbook({
runsRoot,
"--output",
outputDirectory,
"--viewer-root",
viewerRoot,
]);
const programBytes = await readFile(program);
const manifest = {

View File

@ -11,6 +11,11 @@ import {
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import {
publicChatView,
PUBLIC_CHAT_SCHEMA,
PUBLIC_CHAT_NOTICE,
} from "./public-eval-chat.mjs";
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
const ATTEMPT_FILES = new Set([
@ -97,7 +102,9 @@ async function maintainedRosterSelection(programRoot) {
return basename(rosterPath);
});
if (selected.length === 0 || new Set(selected).size !== selected.length) {
throw new Error("Maintained live campaign must contain unique enabled rosters");
throw new Error(
"Maintained live campaign must contain unique enabled rosters",
);
}
return new Set(selected);
}
@ -120,8 +127,7 @@ export async function buildProtocolEvalCatalog({
const programRoot = resolve(evalsRoot, "evals/paperclip-runner");
const rosterRoot = resolve(programRoot, "rosters");
const requested = parseRosterSelection(rosterSelection);
const selected =
requested ?? (await maintainedRosterSelection(programRoot));
const selected = requested ?? (await maintainedRosterSelection(programRoot));
const rosterFiles = (await readdir(rosterRoot, { withFileTypes: true }))
.filter(
(entry) =>
@ -509,7 +515,8 @@ export async function aggregateProtocolEvalCampaign({
return campaign;
}
function publicArtifact(artifact) {
function publicArtifact(artifact, evalCase) {
const issueThread = publicChatView(artifact, evalCase);
const model = artifact.snapshot?.providerModel ?? {};
const infrastructure = artifact.infrastructureFailure;
const providerVersion =
@ -525,12 +532,29 @@ function publicArtifact(artifact) {
provider: artifact.provider,
driver: artifact.driver,
providerVersion,
retainedSession: false,
retainedSession: null,
retainedSessionStatus: "redacted from the public report",
usage: safeUsage(artifact.usage),
turn: { status: artifact.turn?.status ?? "failed" },
timing: {
startedAt:
typeof artifact.timing?.startedAt === "string"
? artifact.timing.startedAt
: null,
finishedAt:
typeof artifact.timing?.finishedAt === "string"
? artifact.timing.finishedAt
: null,
durationMs: Number.isFinite(artifact.timing?.durationMs)
? artifact.timing.durationMs
: null,
},
turn: {
status: artifact.turn?.status ?? "failed",
turnId: issueThread.turns.at(-1).id,
},
snapshot: {
createdAt: artifact.snapshot?.createdAt ?? artifact.createdAt,
sessionId: "public-report",
providerModel: {
id: model.id ?? artifact.requestedModel,
provider: model.provider ?? artifact.provider,
@ -539,6 +563,8 @@ function publicArtifact(artifact) {
evidence: [],
},
devtools: { revisions: [] },
publication: { schema: PUBLIC_CHAT_SCHEMA, notice: PUBLIC_CHAT_NOTICE },
issueThread,
...(infrastructure && typeof infrastructure === "object"
? {
infrastructureFailure: {
@ -622,15 +648,22 @@ export async function sanitizeProtocolEvalRuns({ runsRoot, publicRunsRoot }) {
await Promise.all([
writeFile(
join(destination, "artifact.json"),
json(publicArtifact(artifact)),
json(publicArtifact(artifact, evalCase)),
{ mode: 0o600 },
),
writeFile(join(destination, "score.json"), json(publicScore(score)), {
mode: 0o600,
}),
writeFile(join(destination, "case.json"), json(evalCase), {
mode: 0o600,
}),
writeFile(
join(destination, "case.json"),
json({
id: evalCase.id,
checks: (evalCase.checks ?? []).map(({ id, kind }) => ({ id, kind })),
}),
{
mode: 0o600,
},
),
writeFile(join(destination, "config.json"), json(publicConfig(config)), {
mode: 0o600,
}),

View File

@ -286,7 +286,7 @@ test("rejects downloaded cells that were not declared by the immutable catalog",
);
});
test("public run projection removes provider sessions, traces, transcripts, evidence, and state", async () => {
test("public run projection removes raw evidence and gives unverified recordings an empty chat view", async () => {
const { root, config, evalCase } = await fixture();
const attemptId = "get-task-context-opencode-gha-42-1-attempt-01";
const source = join(root, "raw-runs", attemptId);
@ -357,12 +357,14 @@ test("public run projection removes provider sessions, traces, transcripts, evid
);
assert.doesNotMatch(
serialized,
/private-session|private transcript|private-turn|issueThread|trace/,
/private-session|private transcript|private-turn|"trace":/,
);
const artifact = JSON.parse(serialized);
assert.deepEqual(artifact.snapshot.transcript, []);
assert.deepEqual(artifact.snapshot.evidence, []);
assert.deepEqual(artifact.devtools.revisions, []);
assert.equal(artifact.issueThread.composer.state, "disabled");
assert.equal(artifact.issueThread.turns[0].items[0].kind, "system_notice");
const score = JSON.parse(
await readFile(join(root, "public-runs", attemptId, "score.json"), "utf8"),
);

View File

@ -136,6 +136,11 @@ test("publishes only the separately sanitized Evalbook through trusted OIDC code
/Upload access-controlled canonical Evalbook and raw attempts/u,
);
assert.match(report, /Upload publisher-only sanitized Evalbook/u);
assert.match(
report,
/--viewer-root runner-protocol-build\/extracted\/dist-issue-thread\s*\\\n\s*--public-viewer/u,
);
assert.equal([...report.matchAll(/--viewer-root /gu)].length, 2);
const publisher = workflow.slice(workflow.indexOf(" publish_history:"));
assert.match(publisher, /ref: \$\{\{ github\.sha \}\}/u);
@ -143,6 +148,8 @@ test("publishes only the separately sanitized Evalbook through trusted OIDC code
assert.match(publisher, /runner-protocol-eval-public-/u);
assert.match(publisher, /publish-runner-protocol-eval-history\.mjs/u);
assert.match(publisher, /runner-protocol-evals/u);
assert.match(publisher, /runner-protocol-viewer-/u);
assert.match(publisher, /PAPERCLIP_RUNNER_PROTOCOL_EVAL_VIEWER_DIR/u);
assert.doesNotMatch(publisher, /(?:OPENAI|ANTHROPIC|OPENROUTER)_API_KEY/u);
assert.doesNotMatch(publisher, /paperclipai\/paperclip-evals/u);
assert.doesNotMatch(publisher, /downloaded-runner-protocol-evals/u);