Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/privileged-operators.test.ts
T
DottaandClaude Opus 5.5 5a332517da fix(evals): start the owned dev instance with a supported run command (#20885)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog has a case that checks how chat handles a
request to set up a throwaway Paperclip instance
> - In this case, a scripted operator creates an owned development
instance with the supported CLI after the agent answers
> - The operator step always failed, so the eval never measured the
agent follow-up
> - The fixture started `paperclipai run` with an unsupported `--yes`
flag, and it discarded the child output, so the cause was not visible
> - This pull request starts the owned instance with a supported
command, keeps the child output for diagnosis, and replaces reply
keyword checks with structural checks
> - The benefit is a valid eval that measures the case promise, and a
readable cause if the operator step fails again

## Linked Issues or Issue Description

**What happened**
The `cli-operations.owned-dev-instance` chat eval failed every time with
"Stopped waiting for Actual owned development target readiness: Owned
dev server exited before readiness". The agent reply was correct. The
scripted operator phase failed. The fixture ignored the child stdio, so
the evidence did not contain the cause.

**Expected behavior**
The fixture starts the owned development instance with a supported CLI
command. The instance becomes healthy. The eval then grades cleanup,
isolation and the agent follow-up. If the child exits, the error names a
bounded and redacted reason.

**Steps to reproduce**
1. Start a disposable source instance with an embedded database.
2. Copy the checkout like the fixture does, then run `paperclipai
worktree init --instance X --home H --from-config <source config>
--server-port P --db-port D --seed-mode minimal`.
3. Run `paperclipai run --config <checkout>/.paperclip/config.json
--yes`. It exits 1 with `error: unknown option '--yes'`.
4. Run the same command without `--yes`. It exits with `TS2688: Cannot
find type definition file for 'node'` because the copied plugin SDK has
no linked `node_modules`.

## What Changed

- Remove `--yes` from the owned `paperclipai run` command. `run` has no
such flag. It onboards only when the config is missing, and `worktree
init` writes the config.
- Link the installed `node_modules` of every workspace package into the
copied checkout. Before, the fixture linked only six packages, so `run`
could not compile the copied plugin SDK.
- Write the owned CLI output to a private `0600` log under the attempt
temp root, outside the owned root that cleanup deletes.
- Add a bounded reason (last 12 lines, 1200 characters at most, terminal
codes removed, known secrets and secret shapes redacted) to readiness
and CLI failure errors.
- Replace the `operator-dev-guidance` reply keyword check with
`initial-chat-left-host-unchanged`. The check compares the digests of
every Paperclip installation config under the attempt root (agent HOME,
workspace and runtime directories) before and after the initial turn.
- Change `native-reports-real-target` to require only the exact
`host:port` that the human receipt supplied (bare, linked or with the
scheme). It no longer matches outcome words.
- Bump the case `oracleVersion` to
`paperclip.chat-catalog.privileged.owned-dev-instance.v2` in code and in
the descriptor.

## Verification

- Local reproduction with a disposable source instance: the old command
exits with `unknown option '--yes'`. Without `--yes` and with the old
links, `run` exits with TS2688. With this change, the owned target
reports healthy in 16 to 19 seconds. The seeded canary task reads back
from the target. `worktree:cleanup` removes the instance.
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-catalog-domains/privileged-operators.test.ts
tests/runner-e2e/chat-catalog-contract.test.ts
tests/runner-e2e/chat-catalog-consistency.test.ts`: 17 passed.
- Calibration: the retained CI reply and reworded receipt reports pass.
A changed host installation, a missing target, a longer port, a
different port, an unattributed reply and an empty initial reply fail.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD sha>
--check`: no change.
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in the changed
files. Remaining errors come from a local dependency mismatch in
unrelated packages.
- The paid eval
`chat-features.runner-opencode.local.cli-operations.owned-dev-instance`
is not run yet.

## Risks

- Low risk. The change is in one eval fixture and its unit test.
- The installation inventory walks the attempt root. It skips
`node_modules` and `.git`, and it stops with an error after 1,000,000
entries.
- The initial-turn check does not grade the reply wording. A reply that
falsely claims to have created an instance, without host changes, needs
human review of the retained reply.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:04:22 -06:00

98 lines
13 KiB
TypeScript

import { expect, it } from "vitest";
import type { FeedbackTraceBundle } from "../../../packages/shared/src/types/feedback.js";
import { validInstalledReceipt } from "./privileged-installed.js";
import { gradeInstalledUpdate } from "./privileged-update.js";
import { gradeProvisionRuntime } from "./privileged-provision.js";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { boundedOwnedDevReason, gradeOwnedDev, paperclipInstallations, sameInstallations, validOwnedDevResult } from "./privileged-owned-dev.js";
import { gradeFeedbackBundle, interruptedBundleMatches, storedArchiveEntries, unescapeComposerMarkdown } from "./privileged-feedback.js";
const base = { scenarioId: "owned", origin: "http://owned.invalid", companyId: "company", leadId: "lead", state: {}, tasks: [], projects: [], goals: [], comments: [], activity: [], documents: [], revisions: [], links: [], humanQueries: ["canonical"], runs: [], reply: "" };
it("requires explicit disposable published artifact and full dependency-tree pins", () => {
const r = { schemaVersion: 1 as const, disposable: true as const, cli: "/owned/prefix/lib/node_modules/paperclipai/dist/index.js", cliSha256: "a".repeat(64), server: "/owned/server/dist/index.js", serverSha256: "b".repeat(64), installationTreeSha256: "c".repeat(64), serverPid: 123, version: "2026.1026.1", targetVersion: "2026.1026.2", targetIntegrity: "sha512-YWJjZA==" };
expect(validInstalledReceipt(r)).toBe(true);
for (const delta of [{ installationTreeSha256: "" }, { serverPid: 0 }, { targetVersion: "latest" }, { targetIntegrity: "unpinned" }]) expect(validInstalledReceipt({ ...r, ...delta })).toBe(false);
});
it("rejects download-only update proof, wrong service version and premature assistant success", () => {
const state = { installMode: "global-npm", registryIntegrityMatched: true, updateExecuted: true, installedVersion: "2.0.0", targetVersion: "2.0.0", healthVersion: "2.0.0", healthReady: true, serviceRestarted: true, finalCliSha256: "f".repeat(64), rollbackVersion: "1.0.0", originalVersion: "1.0.0", guidance: "The terminal operator should inspect the npm installation channel first.", updateReport: { attributed: true, reply: "The receipt shows 2.0.0 after restart and healthy readiness." } };
expect(gradeInstalledUpdate({ ...base, state }).passed).toBe(true);
for (const delta of [{ updateExecuted: false }, { healthVersion: "1.0.0" }, { registryIntegrityMatched: false }, { rollbackVersion: "unknown" }, { guidance: "I have updated to the newest version." }, { updateReport: { attributed: false, reply: state.updateReport.reply } }]) expect(gradeInstalledUpdate({ ...base, state: { ...state, ...delta } }).passed).toBe(false);
});
it("does not qualify Pi from provisioner/download evidence without a separate genuine native task", () => {
const state = { assetsInitiallyAbsent: true, provisionerSucceeded: true, provisionerSha256: "a".repeat(64), nativeClosureSha256: "b".repeat(64), releaseVersion: "1.0.0", healthVersion: "1.0.0", assetPathOwned: true, piAgentConfigurationVerified: true, piRunId: "pi-run", piAgentId: "pi", piArtifactMatches: true, guidance: "The operator can run runtime setup pi in the terminal.", provisionReport: { attributed: true, reply: "Pi completed a real native qualification run." } };
const runs = [{ id: "initial", agentId: "lead", status: "succeeded" }, { id: "pi-run", agentId: "pi", status: "succeeded", runtimeMode: "native" }, { id: "report", agentId: "lead", status: "succeeded" }];
expect(gradeProvisionRuntime({ ...base, state, runs }).passed).toBe(true);
for (const delta of [{ piArtifactMatches: false }, { piAgentConfigurationVerified: false }, { assetPathOwned: false }, { healthVersion: "other" }]) expect(gradeProvisionRuntime({ ...base, state: { ...state, ...delta }, runs }).passed).toBe(false);
expect(gradeProvisionRuntime({ ...base, state, runs: runs.filter(r => r.id !== "pi-run") }).passed).toBe(false);
});
it("requires actual seed readback, source/unrelated preservation and exact target cleanup", () => {
// The retained CI reply (initial guidance) and an honest receipt report pass; wording is not graded.
const guidance = "I can't provision a new Paperclip instance from this chat. For a throwaway instance, use your own Paperclip checkout: run `pnpm dev` and open the URL it prints.";
const state = { sourceCanaryUnchanged: true, targetCanaryMatches: true, actualTargetHealthy: true, targetStopped: true, targetCleaned: true, unrelatedSentinelUnchanged: true, sourceRevisionPinned: true, targetUrl: "http://127.0.0.1:54321", sourceCliMatches: true, hostInstallationsUnchanged: true, guidance, devReport: { attributed: true, reply: "http://127.0.0.1:54321 was healthy and is now stopped and cleaned." } };
expect(validOwnedDevResult(state)).toBe(true); expect(gradeOwnedDev({ ...base, state }).passed).toBe(true);
for (const reply of ["It ran at http://127.0.0.1:54321/ and then stopped.", "It ran at 127.0.0.1:54321.", "The operator's instance at [127.0.0.1:54321](http://127.0.0.1:54321) answered its health probe; it has been shut down and removed.", "Target 127.0.0.1:54321: up when checked, then torn down."])
expect(gradeOwnedDev({ ...base, state: { ...state, devReport: { attributed: true, reply } } }).passed).toBe(true);
for (const key of ["sourceCanaryUnchanged", "targetCanaryMatches", "actualTargetHealthy", "targetStopped", "targetCleaned", "unrelatedSentinelUnchanged", "sourceCliMatches", "hostInstallationsUnchanged"]) expect(gradeOwnedDev({ ...base, state: { ...state, [key]: false } }).passed).toBe(false);
for (const devReport of [{ attributed: false, reply: state.devReport.reply }, { attributed: true, reply: "The instance was healthy and is now cleaned up." }, { attributed: true, reply: "http://127.0.0.1:543210 was healthy." }, { attributed: true, reply: "http://127.0.0.1:3100 was healthy and is now stopped." }, { attributed: true, reply: "Open [it](http://127.0.0.1:54321@other.invalid)." }, { attributed: true, reply: "http://127.0.0.1:54321.other.invalid was healthy." }, { attributed: true, reply: "user@127.0.0.1:54321 was healthy." }])
expect(gradeOwnedDev({ ...base, state: { ...state, devReport } }).passed).toBe(false);
expect(gradeOwnedDev({ ...base, state: { ...state, guidance: "" } }).passed).toBe(false);
});
it("inventories Paperclip installations under the attempt root by content", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "owned-dev-installations-"));
try {
const put = async (file: string, body: string) => { await mkdir(path.dirname(path.join(root, file)), { recursive: true }); await writeFile(path.join(root, file), body); };
await put("paperclip-home/instances/source/config.json", "{\"server\":{\"port\":1}}");
await put("paperclip-home/instances/source/workspaces/w/node_modules/x/instances/y/config.json", "{}");
await put("paperclip-home/instances/source/workspaces/w/package-config.json", "{}");
const before = await paperclipInstallations(root);
expect(Object.keys(before)).toEqual([path.join("paperclip-home/instances/source/config.json")]);
expect(sameInstallations(before, await paperclipInstallations(root))).toBe(true);
expect(sameInstallations({}, {})).toBe(false);
await put("paperclip-home/instances/source/runtime/home/.paperclip/instances/default/config.json", "{}");
expect(sameInstallations(before, await paperclipInstallations(root))).toBe(false);
await rm(path.join(root, "paperclip-home/instances/source/runtime"), { recursive: true });
await put("paperclip-home/instances/source/workspaces/w/.paperclip/config.json", "{}");
expect(sameInstallations(before, await paperclipInstallations(root))).toBe(false);
await rm(path.join(root, "paperclip-home/instances/source/workspaces/w/.paperclip"), { recursive: true });
await put("paperclip-home/instances/source/config.json", "{\"server\":{\"port\":2}}");
expect(sameInstallations(before, await paperclipInstallations(root))).toBe(false);
} finally { await rm(root, { recursive: true, force: true }); }
});
it("surfaces a bounded, redacted reason for an owned CLI child that exits", () => {
const secret = "owned-generated-jwt-secret-value-0123456789";
const output = ["\u001b[36m┌ paperclipai run \u001b[0m", ...Array.from({ length: 40 }, (_, i) => `noise ${i}`), `PAPERCLIP_AGENT_JWT_SECRET=${secret}`, "error: unknown option '--yes'"].join("\n");
const reason = boundedOwnedDevReason(output, [secret]);
expect(reason).toContain("error: unknown option '--yes'");
expect(reason).not.toContain(secret); expect(reason).not.toContain("\u001b"); expect(reason).not.toContain("noise 20");
expect(reason.split("\n").length).toBeLessThanOrEqual(12);
expect(boundedOwnedDevReason("x".repeat(5000), []).length).toBeLessThanOrEqual(1200);
});
it("binds retained interrupted work to actual original cancelled run, comment and events", () => {
const run = { id: "run", companyId: "company", agentId: "morgan", status: "cancelled", eventCount: 1 };
const bundle: FeedbackTraceBundle = { traceId: "trace", exportId: null, issueIdentifier: "OWN-1", adapterType: "paperclip_runner", captureStatus: "partial", notes: [], surface: null, rawAdapterTrace: null, normalizedAdapterTrace: null, privacy: null, integrity: {}, companyId: "company", issueId: "task", envelope: { targetId: "comment" }, paperclipRun: run, files: [{ path: "paperclip/run.json", contentType: "application/json", encoding: "utf8", byteLength: 1, sha256: "x", source: "paperclip_run", contents: JSON.stringify(run) }, { path: "paperclip/run-events.json", contentType: "application/json", encoding: "utf8", byteLength: 1, sha256: "x", source: "paperclip_run_events", contents: JSON.stringify([{ runId: "run", seq: 1 }]) }] };
const ids = { company: "company", issue: "task", run: "run", agent: "morgan", comment: "comment" };
expect(interruptedBundleMatches(bundle, ids)).toBe(true);
expect(interruptedBundleMatches({ ...bundle, paperclipRun: { ...run, status: "failed" } }, ids)).toBe(false);
expect(interruptedBundleMatches(bundle, { ...ids, comment: "other" })).toBe(false);
expect(interruptedBundleMatches({ ...bundle, files: bundle.files.slice(0, 1) }, ids)).toBe(false);
});
it("requires actual private scoped bundle bytes and truthful local interruption report", () => {
const state = { agentReportStatus: 403, cliExecuted: true, workerWasReallyWaiting: true, cancelledBeforeCanonical: true, exportedInterruptionMatches: true, workerRunId: "run", originalTraceUnchanged: true, bundleWithinOwnedDirectory: true, exportCompanyMatches: true, exportIssueMatches: true, fullTraceBytesMatch: true, archiveBytesMatch: true, zipSha256: "a".repeat(64), zipBytes: 100, noPrivateCredentialBytes: true, remoteSharingUnchanged: true, bundlePublished: false, localOutputDirectory: "/owned/export", exportReport: { attributed: true, reply: "The local feedback and trace/events bundle is at /owned/export and retains the cancelled interrupted work." } };
const runs = [{ id: "run", status: "cancelled" }];
expect(gradeFeedbackBundle({ ...base, state, runs }).passed).toBe(true);
for (const delta of [{ agentReportStatus: 200 }, { archiveBytesMatch: false }, { remoteSharingUnchanged: false }, { noPrivateCredentialBytes: false }, { exportedInterruptionMatches: false }, { exportReport: { attributed: true, reply: "The bundle is in your downloads folder." } }, { exportReport: { attributed: false, reply: state.exportReport.reply } }]) expect(gradeFeedbackBundle({ ...base, state: { ...state, ...delta }, runs }).passed).toBe(false);
// A correct report that says what it did not do is not a publishing claim.
expect(gradeFeedbackBundle({ ...base, state: { ...state, exportReport: { attributed: true, reply: "Recorded the operator's bundle at /owned/export; it has not been published, uploaded or shared." } }, runs }).passed).toBe(true);
});
it("rejects arbitrary, truncated or path-escaping archive bytes", () => {
expect(() => storedArchiveEntries(Buffer.from("not zip"))).toThrow();
const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50); header.writeUInt16LE(5, 26); const bytes = Buffer.concat([header, Buffer.from("../xx"), Buffer.from([0x50, 0x4b, 0x01, 0x02])]);
expect(() => storedArchiveEntries(bytes)).toThrow();
});
it("matches a composer-escaped human receipt to the exact sent text", () => {
const sent = 'Facts:\n\n{"exportedContents":["scoped feedback vote","trace metadata"],"bytes":5}';
expect(unescapeComposerMarkdown('Facts:\n\n{"exportedContents":\\["scoped feedback vote","trace metadata"\\],"bytes":5}')).toBe(sent);
expect(unescapeComposerMarkdown('Facts:\n\n{"exportedContents":\\["other"\\],"bytes":5}')).not.toBe(sent);
});